From 6be4151bf754575e2567c46436b1dba06bffb1c0 Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Fri, 31 Jul 2026 14:51:01 +0200 Subject: [PATCH 1/3] Add Config facade and Config_Exception --- README.md | 13 ++- src/Config.php | 139 ++++++++++++++++++++++++++++ src/Exceptions/Config_Exception.php | 18 ++++ tests/_support/Test_Container.php | 57 ++++++++++++ tests/unit/ConfigTest.php | 107 +++++++++++++++++++++ 5 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 src/Config.php create mode 100644 src/Exceptions/Config_Exception.php create mode 100644 tests/_support/Test_Container.php create mode 100644 tests/unit/ConfigTest.php diff --git a/README.md b/README.md index d17516b..fcf0e10 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,18 @@ Requires PHP 7.4+ and WordPress 6.4+. ## Usage -_Added as each piece lands._ +### Configure + +```php +use Nexcess\PluginAbsorber\Config; + +Config::set_hook_prefix( 'give' ); // required — keys hooks, transients, options +Config::set_version( GIVE_VERSION ); // optional +Config::set_container( give()->container ); // optional — see Rebinding below +``` + +The hook prefix accepts letters, numbers, hyphens, and underscores. Anything else throws +`Config_Exception`, as does reading it before it is set. ## License diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..de2bce1 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,139 @@ +container = $container ?: new DI52Container(); + } + + /** + * @inheritDoc + */ + public function bind( string $id, $implementation = null ) { + $this->container->bind( $id, $implementation ); + } + + /** + * @inheritDoc + */ + public function get( string $id ) { + return $this->container->get( $id ); + } + + /** + * @inheritDoc + */ + public function has( string $id ) { + return $this->container->has( $id ); + } + + /** + * @inheritDoc + */ + public function singleton( string $id, $implementation = null ) { + $this->container->singleton( $id, $implementation ); + } +} diff --git a/tests/unit/ConfigTest.php b/tests/unit/ConfigTest.php new file mode 100644 index 0000000..2633d74 --- /dev/null +++ b/tests/unit/ConfigTest.php @@ -0,0 +1,107 @@ +assertSame( 'give', Config::get_hook_prefix() ); + } + + public function test_it_accepts_letters_numbers_hyphens_and_underscores(): void { + Config::set_hook_prefix( 'give-recurring_2' ); + + $this->assertSame( 'give-recurring_2', Config::get_hook_prefix() ); + } + + /** + * @dataProvider invalid_hook_prefixes + * + * @param string $prefix Prefix under test. + */ + public function test_it_rejects_invalid_hook_prefixes( string $prefix ): void { + $this->expectException( Config_Exception::class ); + + Config::set_hook_prefix( $prefix ); + } + + /** + * @return array + */ + public function invalid_hook_prefixes(): array { + return [ + 'slash' => [ 'give/recurring' ], + 'space' => [ 'give recurring' ], + 'dot' => [ 'give.recurring' ], + 'backslash' => [ 'give\\recurring' ], + ]; + } + + public function test_it_rejects_an_empty_hook_prefix(): void { + $this->expectException( Config_Exception::class ); + + Config::set_hook_prefix( '' ); + } + + public function test_it_throws_when_the_hook_prefix_was_never_set(): void { + $this->expectException( Config_Exception::class ); + + Config::get_hook_prefix(); + } + + public function test_it_stores_and_returns_the_version(): void { + Config::set_version( '3.0.0' ); + + $this->assertSame( '3.0.0', Config::get_version() ); + } + + public function test_the_version_defaults_to_an_empty_string(): void { + $this->assertSame( '', Config::get_version() ); + } + + public function test_it_reports_no_container_by_default(): void { + $this->assertFalse( Config::has_container() ); + $this->assertNull( Config::get_container() ); + } + + public function test_it_stores_and_returns_a_container(): void { + $container = new Test_Container(); + + Config::set_container( $container ); + + $this->assertTrue( Config::has_container() ); + $this->assertSame( $container, Config::get_container() ); + } + + public function test_reset_clears_every_value(): void { + Config::set_hook_prefix( 'give' ); + Config::set_version( '3.0.0' ); + Config::set_container( new Test_Container() ); + + Config::reset(); + + $this->assertSame( '', Config::get_version() ); + $this->assertFalse( Config::has_container() ); + $this->assertNull( Config::get_container() ); + + $this->expectException( Config_Exception::class ); + Config::get_hook_prefix(); + } +} From 7d317876b8eaf219781c5653293bd6796faeb517 Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Mon, 3 Aug 2026 13:04:32 +0200 Subject: [PATCH 2/3] Address review: prove the RuntimeException contract, isolate Config state - Assert Config_Exception is catchable as RuntimeException; nothing covered that inheritance, so dropping it would have left the suite green. - Reset Config in setUp() as well as tearDown(), so the tests asserting on default state no longer depend on class execution order. - Note that the container fixture inherits DI52's class_exists() fallback in has(). - Drop a README pointer to a section that does not exist yet. --- README.md | 2 +- tests/_support/Test_Container.php | 5 +++++ tests/unit/ConfigTest.php | 12 ++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fcf0e10..8d2381b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ use Nexcess\PluginAbsorber\Config; Config::set_hook_prefix( 'give' ); // required — keys hooks, transients, options Config::set_version( GIVE_VERSION ); // optional -Config::set_container( give()->container ); // optional — see Rebinding below +Config::set_container( give()->container ); // optional — lets you rebind collaborators ``` The hook prefix accepts letters, numbers, hyphens, and underscores. Anything else throws diff --git a/tests/_support/Test_Container.php b/tests/_support/Test_Container.php index eeab57c..46e7c1f 100644 --- a/tests/_support/Test_Container.php +++ b/tests/_support/Test_Container.php @@ -42,6 +42,11 @@ public function get( string $id ) { } /** + * Reports whether the id is bound. + * + * Inherits DI52's permissive semantics: any existing *class* name reports true even with + * nothing bound, because DI52 falls back to `class_exists()`. Interface names are unaffected. + * * @inheritDoc */ public function has( string $id ) { diff --git a/tests/unit/ConfigTest.php b/tests/unit/ConfigTest.php index 2633d74..47f8d89 100644 --- a/tests/unit/ConfigTest.php +++ b/tests/unit/ConfigTest.php @@ -9,11 +9,17 @@ use Nexcess\PluginAbsorber\Config; use Nexcess\PluginAbsorber\Exceptions\Config_Exception; use Nexcess\PluginAbsorber\Tests\Support\Test_Container; +use RuntimeException; /** * @since 1.0.0 */ class ConfigTest extends WPTestCase { + public function setUp(): void { + parent::setUp(); + Config::reset(); + } + public function tearDown(): void { Config::reset(); parent::tearDown(); @@ -66,6 +72,12 @@ public function test_it_throws_when_the_hook_prefix_was_never_set(): void { Config::get_hook_prefix(); } + public function test_config_exception_is_catchable_as_a_runtime_exception(): void { + $this->expectException( RuntimeException::class ); + + Config::set_hook_prefix( 'give/recurring' ); + } + public function test_it_stores_and_returns_the_version(): void { Config::set_version( '3.0.0' ); From b4fcc161fb6506e3643d6d92b573323befddf319 Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Mon, 3 Aug 2026 13:04:33 +0200 Subject: [PATCH 3/3] Plan: record the container-contract correction and prefix-guard deviation --- .../plans/2026-07-31-plugin-absorber.md | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-07-31-plugin-absorber.md b/docs/superpowers/plans/2026-07-31-plugin-absorber.md index 07fa151..ec4ba12 100644 --- a/docs/superpowers/plans/2026-07-31-plugin-absorber.md +++ b/docs/superpowers/plans/2026-07-31-plugin-absorber.md @@ -32,7 +32,7 @@ Every task's requirements implicitly include this section. ``` - **Branching:** stacked. Each branch cuts from the previous branch, and merges to `main` in order. Never open PR N+1 before PR N's branch exists. - **Commits:** no co-author trailers, ever. -- **Every source file** carries a file-level docblock with `@package Nexcess\PluginAbsorber` and every method a docblock with `@since 1.0.0`. +- **Every source file** carries a file-level docblock with `@package Nexcess\PluginAbsorber` and every method a docblock with `@since 1.0.0`. This binds `src/` only. Test classes and test support classes keep the file-level docblock, but their methods do not need `@since` — the test code in this plan's own tasks is written that way deliberately (ruled 2026-07-31). ## File Structure @@ -864,6 +864,11 @@ Expected: all matrix legs green. **Do not proceed until they are** — every lat > `RuntimeException`. This throws `Config_Exception`, which extends `RuntimeException`, so the > documented contract still holds while callers get one catchable type across the whole library. +> **Second deviation, deliberate (added 2026-08-03):** `set_hook_prefix()` also rejects the empty +> string. The character-class check alone would accept `''` — it contains no invalid character — +> and the failure would resurface at `get_hook_prefix()` as the misleading "You must call +> `Config::set_hook_prefix()`" long after the real mistake. + - [ ] **Step 1: Cut the branch** ```bash @@ -976,8 +981,18 @@ class ConfigTest extends WPTestCase { } ``` -> `lucatume\DI52\Container` implements `StellarWP\ContainerContract\ContainerInterface` and is the -> dev-only container this library tests against. +> **CORRECTION (2026-07-31, verified against vendor/):** `lucatume\DI52\Container` does **not** +> implement `StellarWP\ContainerContract\ContainerInterface`. It implements `ArrayAccess` and +> **PSR's** `Psr\Container\ContainerInterface`. `stellarwp/container-contract` ships an adapter +> example at `examples/di52/Container.php` precisely because DI52 must be wrapped. +> `new Container()` therefore cannot be passed to `Config::set_container()` — it is a `TypeError`. +> +> Tests must use the test-support adapter `Nexcess\PluginAbsorber\Tests\Support\Test_Container` +> (wraps a DI52 container, implements the StellarWP contract's four methods: `bind`, `get`, +> `has`, `singleton`). This affects **Task 4 and Task 10** — both of their test blocks below still +> show the incorrect `use lucatume\DI52\Container;`. `Config::set_container()`'s signature is +> unchanged: the StellarWP contract stays the public API, per the Global Constraint that +> `stellarwp/container-contract` is the only production dependency. - [ ] **Step 3: Run it to verify it fails**