Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,17 @@ 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_container( give()->container ); // optional — lets you rebind collaborators
```

The hook prefix accepts letters, numbers, hyphens, and underscores. Anything else throws
`Config_Exception`, as does reading it before it is set.

## License

Expand Down
55 changes: 45 additions & 10 deletions docs/superpowers/plans/2026-07-31-plugin-absorber.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ 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).
- **No test-only seams in `src/`** (ruled 2026-08-11, PR 4 review). Production classes do not carry a `reset()` for the suite's benefit — that is API the library then supports forever. Tests clear static state by reflection instead, through a helper under `tests/_support/`. `Config` is served by `Nexcess\PluginAbsorber\Tests\Support\Config_State::reset()`; **every `Config::reset()` in the task blocks below means `Config_State::reset()`.** The same applies to `Registrar` and `Loader` when tasks 8 and 9 land.

## File Structure

Expand Down Expand Up @@ -854,16 +855,39 @@ Expected: all matrix legs green. **Do not proceed until they are** — every lat
- `Config_Exception extends \RuntimeException`
- `Config::set_hook_prefix( string ): void` — throws `Config_Exception` on characters outside `[a-zA-Z0-9_-]`
- `Config::get_hook_prefix(): string` — throws `Config_Exception` when unset
- `Config::set_version( string ): void` / `Config::get_version(): string`
- `Config::set_container( ContainerInterface ): void` / `get_container(): ?ContainerInterface` / `has_container(): bool`
- `Config::reset(): void`

Every later task calls `Config::set_hook_prefix()` in `setUp()` and `Config::reset()` in `tearDown()`.
Every later task calls `Config::set_hook_prefix()` in `setUp()` and `Config_State::reset()` in `tearDown()`.

> **Deviation from the engineering plan, deliberate:** the plan's sketch throws bare
> `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.

> **Third deviation, from the PR 4 review (2026-08-11) — two removals. The code blocks below still
> show both; they are wrong and were left for the record.**
>
> 1. **`set_version()` / `get_version()` are gone**, along with the `$version` property, its two
> tests, and the README line. Nothing in the library reads a host version, and the one scenario
> that would want it — telling a bundled copy apart from a standalone at a specific release, à la
> ProPanel v3.0 — is the host's problem, not this library's. This closes spec known-issue F by
> deletion rather than by use.
> 2. **`Config::reset()` is gone.** It existed only so the suite could clear static state, and a
> public method is a promise to everyone, not just to tests. The suite now uses
> `Tests\Support\Config_State::reset()`, which walks `Config`'s declared static defaults by
> reflection — so state added to `Config` later is cleared with no change to the helper. See the
> Global Constraint on test-only seams.
>
> The `ConfigTest` block below is also superseded on two points the review raised: the prefix tests
> are driven by `public static` `Generator` data providers (valid and invalid, the empty string
> among the invalid), and the `RuntimeException` test now catches as `RuntimeException` and asserts
> `instanceof Config_Exception` — proving both halves of the contract instead of passing merely
> because one extends the other.

- [ ] **Step 1: Cut the branch**

```bash
Expand Down Expand Up @@ -976,8 +1000,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**

Expand Down Expand Up @@ -1194,9 +1228,9 @@ RuntimeException`, so the documented contract still holds and callers get one ca
the library. `set_hook_prefix()` validates eagerly rather than at use, because a bad prefix
otherwise surfaces as a silently-never-firing filter much later.

Verify: `slic run unit` — 13 tests covering the validation regex, the unset-prefix throw, container
storage, and `reset()`. `get_version()` is stored but not yet read by anything; see the spec is
known-issue F.'
Verify: `slic run unit` — the validation regex over valid and invalid prefixes, the unset-prefix
throw, the `RuntimeException` catchability contract, and container storage. Version handling is not
covered because it no longer exists; see the third deviation above.'
```

---
Expand Down Expand Up @@ -5620,7 +5654,8 @@ Recorded in the spec, deliberately not fixed in 1.0.0:
Matches both reference implementations as-is.
- **E** — `Activation::maybe_run()` reads the option, runs the callback, then writes. Two
simultaneous first requests can both run it. `add_option()` as an atomic claim would close it.
- **F** — `Config::get_version()` is stored but never read.
- ~~**F** — `Config::get_version()` is stored but never read.~~ Closed 2026-08-11 by removing
version handling from `Config` outright; see Task 4's third deviation.

## Self-review

Expand Down
8 changes: 6 additions & 2 deletions docs/superpowers/specs/2026-07-31-plugin-absorber-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ callback — which may be `create_tables()`. Claiming the slot with `add_option(
would close the window.

**F. `Config::get_version()` is never read.** Set by the consumer, unused by the library.
*Resolved 2026-08-11 (PR 4 review): version handling was removed from `Config` rather than given a
reader. Nothing in the library needs the host's version.*

---

Expand Down Expand Up @@ -228,8 +230,10 @@ message. This is documented in `tests/README.md`.

- **3 — smoke.** Three tests: WordPress is loaded; `uopz` is available; a function can be stubbed.
There is deliberately no test that `exit` can be neutralised.
- **4 — `Config`.** Prefix regex rejects invalid characters (throws); `get_hook_prefix()` throws
when unset; version set/get; container set/get/has; `reset()` clears all three.
- **4 — `Config`.** Prefix regex accepts valid characters and rejects invalid ones, the empty
string included (throws); `get_hook_prefix()` throws when unset; `Config_Exception` is catchable
as `RuntimeException`; container set/get/has. No version handling and no production `reset()` —
see the plan's Task 4 deviations.
- **6 — `Conflict_Policy`.** Constant values; the three are distinct.
- **7 — `Sub_Plugin`.** Each of the three required keys missing throws `Config_Exception`;
`is_enabled()` bool vs callable; `is_already_loaded()` reacts to `define()`;
Expand Down
101 changes: 101 additions & 0 deletions src/Config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php
/**
* @package Nexcess\PluginAbsorber
*/

namespace Nexcess\PluginAbsorber;

use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
use StellarWP\ContainerContract\ContainerInterface;

/**
* Static configuration facade.
*
* @since 1.0.0
*/
class Config {
/**
* @var string
*/
protected static $hook_prefix = '';

/**
* @var ContainerInterface|null
*/
protected static $container = null;

/**
* Set the unique per-host slug that keys hooks, transients, and the activation option.
*
* @since 1.0.0
*
* @param string $prefix Host slug.
*
* @throws Config_Exception When the prefix is empty or contains unsupported characters.
*
* @return void
*/
public static function set_hook_prefix( string $prefix ): void {
if ( $prefix === '' ) {
throw new Config_Exception( 'The hook prefix cannot be empty.' );
}

if ( preg_match( '/[^a-zA-Z0-9_-]/', $prefix ) ) {
throw new Config_Exception(
'Hook prefix must only contain letters, numbers, hyphens, and underscores.'
);
}

self::$hook_prefix = $prefix;
}

/**
* @since 1.0.0
*
* @throws Config_Exception When no prefix has been set.
*
* @return string
*/
public static function get_hook_prefix(): string {
if ( self::$hook_prefix === '' ) {
throw new Config_Exception(
'You must call Config::set_hook_prefix() before booting the Plugin Absorber.'
);
}

return self::$hook_prefix;
}

/**
* Share the host's container so collaborators become bindable.
*
* Entirely optional — with no container the library instantiates its own defaults.
*
* @since 1.0.0
*
* @param ContainerInterface $container Host container.
*
* @return void
*/
public static function set_container( ContainerInterface $container ): void {
self::$container = $container;
}

/**
* @since 1.0.0
*
* @return ContainerInterface|null
*/
public static function get_container(): ?ContainerInterface {
return self::$container;
}

/**
* @since 1.0.0
*
* @return bool
*/
public static function has_container(): bool {
return self::$container !== null;
}
}
18 changes: 18 additions & 0 deletions src/Exceptions/Config_Exception.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
/**
* @package Nexcess\PluginAbsorber
*/

namespace Nexcess\PluginAbsorber\Exceptions;

use RuntimeException;

/**
* Thrown when the library is configured incorrectly.
*
* Extends RuntimeException so callers may catch either type.
*
* @since 1.0.0
*/
class Config_Exception extends RuntimeException {
}
59 changes: 59 additions & 0 deletions tests/_support/Config_State.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php
/**
* @package Nexcess\PluginAbsorber
*/

namespace Nexcess\PluginAbsorber\Tests\Support;

use LogicException;
use Nexcess\PluginAbsorber\Config;
use ReflectionClass;
use ReflectionProperty;

/**
* Restores `Config`'s static state between tests.
*
* `Config` is a static facade with no public way to clear itself, and deliberately so: a reset
* method would be API the library then has to support forever for the sake of its own test suite.
* Reflection keeps that seam on this side of the fence.
*/
class Config_State {
/**
* The value each of `Config`'s static properties starts life with.
*
* Spelled out rather than read from `ReflectionClass::getDefaultProperties()`, which reports a
* static property's *current* value on PHP below 8.3 — a reset built on it is a silent no-op on
* the 7.4 leg.
*
* @var array<string,mixed>
*/
protected const DEFAULTS = [
'hook_prefix' => '',
'container' => null,
];

/**
* Return every static property of `Config` to its default.
*
* @throws LogicException When `Config` has grown a static property this helper does not know
* about, rather than leaving it to leak between tests.
*
* @return void
*/
public static function reset(): void {
$reflection = new ReflectionClass( Config::class );

foreach ( $reflection->getProperties( ReflectionProperty::IS_STATIC ) as $property ) {
$name = $property->getName();

if ( ! array_key_exists( $name, self::DEFAULTS ) ) {
throw new LogicException(
sprintf( 'Config::$%s has no default in %s. Add one.', $name, self::class )
);
}

$property->setAccessible( true );
$property->setValue( null, self::DEFAULTS[ $name ] );
}
}
}
62 changes: 62 additions & 0 deletions tests/_support/Test_Container.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php
/**
* @package Nexcess\PluginAbsorber
*/

namespace Nexcess\PluginAbsorber\Tests\Support;

use lucatume\DI52\Container as DI52Container;
use StellarWP\ContainerContract\ContainerInterface;

/**
* Wraps a `lucatume\DI52\Container` so it satisfies `ContainerInterface` in tests.
*
* DI52's own container implements PSR-11's `ContainerInterface`, not StellarWP's — this adapter
* closes that gap, modelled on `stellarwp/container-contract`'s own `examples/di52/Container.php`.
*/
class Test_Container implements ContainerInterface {
/**
* @var DI52Container
*/
protected $container;

/**
* @param DI52Container|null $container Container to wrap; a new one is created when omitted.
*/
public function __construct( ?DI52Container $container = null ) {
$this->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 );
}

/**
* 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 ) {
return $this->container->has( $id );
}

/**
* @inheritDoc
*/
public function singleton( string $id, $implementation = null ) {
$this->container->singleton( $id, $implementation );
}
}
Loading