Skip to content
Open
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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — 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
21 changes: 18 additions & 3 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,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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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**

Expand Down
139 changes: 139 additions & 0 deletions src/Config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?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 string
*/
protected static $version = '';

/**
* @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;
}

/**
* @since 1.0.0
*
* @param string $version Host plugin version.
*
* @return void
*/
public static function set_version( string $version ): void {
self::$version = $version;
}

/**
* @since 1.0.0
*
* @return string
*/
public static function get_version(): string {
return self::$version;
}

/**
* 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;
}

/**
* Reset all static state. Test seam.
*
* @since 1.0.0
*
* @return void
*/
public static function reset(): void {
self::$hook_prefix = '';
self::$version = '';
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 {
}
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