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
42 changes: 32 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,10 @@ seams a host may rebind:
| `Contracts\Plugin_Checker_Interface` | `Plugin_Checker` | answers whether a plugin is active |
| `Contracts\Activator_Interface` | `Activator` | run-once activation-callback tracking |

The rest — `Boot\Scheduler`, `Loader`, `Conflict\Gatekeeper`, `Conflict\Redirector`,
`Notices\Store`, `Notices\Renderer` — are bound as concrete classes. A host that wants one of them
different rebinds the class name; there is no interface because nothing in the library dispatches on
one.
The rest — `Boot\Scheduler`, `Loader`, `Registry_Reader`, `Conflict\Detector`, `Conflict\Gatekeeper`,
`Conflict\Redirector`, `Notices\Store`, `Notices\Renderer` — are bound as concrete classes. A host
that wants one of them different rebinds the class name; there is no interface because nothing in the
library dispatches on one.

An interface belonging to a folder-scoped concern lives in that folder's `Contracts\`, not beside its
implementation and not in the top-level `src/Contracts/`. `src/Contracts/` is for the interfaces whose
Expand Down Expand Up @@ -118,10 +118,20 @@ it is di52-only: `stellarwp/container-contract` declares `bind`, `get`, `has` an
nothing else. `[ $resolved_object, 'method' ]` is the other wrong answer — it forces every
collaborator to be built at boot.

`Absorber` keeps the public surface. `registrar()` and `notices()` are one-line delegations to
`$container->get()`, so what a host calls is unchanged; what changed is that a *collaborator* now
`Absorber` keeps the public surface. `registrar()`, `notices()` and `all()` are one-line delegations
to `$container->get()`, so what a host calls is unchanged; what changed is that a *collaborator* now
depends on the peer it was handed rather than on the facade.

**Nothing but `Absorber` names `Absorber`.** The registration buffer belongs to `Registry_Reader`,
which is also what reads it back out: `Absorber::register()` pushes a `Sub_Plugin` into it and
`Absorber::all()` delegates to it, while `Conflict\Detector` and `Loader` are each handed one. The
buffer is static because it must be — `register()` is a static call a host makes at plugin-file
scope, before there is a container to resolve a registrar from — and what is decided
is only which class pays for that. Leaving it on the facade left an edge pointing back up: the passes
the facade boots read the registry by calling the facade, so `Absorber` sat both above and below its
own collaborators, and a pass could not be handed a registry to work on. The arrows run one way now,
and a pass is complete the moment it is built.

`Sub_Plugin` is a value object answering the per-sub-plugin questions it can answer **without a
container-bound collaborator** (`is_enabled()`, `is_already_loaded()`, `has_standalone_plugin()`,
`get_conflict_policy()`, …). Note that this is not the same as "config alone": `is_already_loaded()`
Expand All @@ -139,14 +149,16 @@ Currently:
| Path | What |
|---|---|
| `src/Config.php` | Static facade: hook prefix + container. |
| `src/Absorber.php` | Static facade: the registration buffer, `boot()`, and the accessors. |
| `src/Absorber.php` | Static facade: registration, `boot()`, and the accessors. Holds no collaborator's state. |
| `src/Provider.php` | Binds every collaborator; the only file that names a default implementation. |
| `src/Boot/Scheduler.php` | Hook wiring and boot timing: the sequence, the priorities, and the fallback for a host that boots too late. |
| `src/Loader.php` | The load pass: the gate chain, the `require_once`, the activation callback. |
| `src/Sub_Plugin.php` | Value object; validates config and answers what it can without a container-bound collaborator. |
| `src/Conflict_Policy.php` | The three policy constants, `default()`, `is_valid()`. |
| `src/Plugin_Deactivator.php`, `src/Plugin_Checker.php` | The only files that touch WordPress plugin functions, through `Traits\Loads_Plugin_Functions`. |
| `src/Registrar.php` | Holds registered `Sub_Plugin` objects. |
| `src/Registry_Reader.php` | The registration buffer, drained into the registrar on the way past; the object every pass reads the registry through. |
| `src/Conflict/` | `Detector` (whether a standalone is in the way), `Gatekeeper` (which requests, and which users, may have one resolved), `Redirector` (where the user lands afterwards). |
| `src/Traits/` | `Loads_Plugin_Functions` (pulls in `wp-admin/includes/plugin.php`), `Guards_Hook_Prefix` (a missing prefix warns and stands down rather than throwing). |
| `src/Notices/` | `Queue` (what a notice says, who may consume it), `Store` (keeps it), `Renderer` (draws it), `Contracts\Queue_Interface`. |
| `src/Contracts/`, `src/Exceptions/` | `Provider_Interface`, `Registrar_Interface`, `Plugin_Deactivator_Interface`, `Plugin_Checker_Interface`, `Config_Exception`. |
Expand Down Expand Up @@ -186,9 +198,19 @@ carries the whole re-declaration guarantee, and it is the only gate meaning "thi
running" — warning that requirements are unmet for a plugin the admin can watch working would send
them after the wrong problem. `docs/filters.md` and the spec agree.

`Absorber::all()` narrows to `Sub_Plugin` instances itself, so no caller repeats that guard. A host
may bind a registrar returning anything, and PHP 7.4 cannot express `array<string,Sub_Plugin>` in
the interface signature — so it is filtered once where the untrusted value enters.
`Registry_Reader::all()` narrows to `Sub_Plugin` instances itself, so no caller repeats that guard. A
host may bind a registrar returning anything, and PHP 7.4 cannot express `array<string,Sub_Plugin>` in
the interface signature — so it is filtered once where the untrusted value enters. The load pass and
`Conflict\Detector` read through the reader they were constructed with rather than through the
registrar they could resolve for themselves, because it drains the pending registrations before it
reads and a registrar asked directly would miss anything registered since the last flush.

The container is no longer the other half of that. A pass is handed a reader that already holds its
registrar, so a container that cannot supply one fails while the *pass* is being built — where an
unbuildable `Queue_Interface` or `Plugin_Checker_Interface` has always failed. Read-time and
build-time failures stopped being the same event when the registry became an argument, and the
registrar now fails like every other binding rather than being the one collaborator whose broken
binding surfaced late and politely.

`Conflict\Resolver` switches on the policy: `DEFER` no-ops, `NOTICE_ONLY` queues a notice, and
`DEACTIVATE` (the default) deactivates network-aware, queues a merge notice, and redirects.
Expand Down
71 changes: 11 additions & 60 deletions src/Absorber.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,6 @@
final class Absorber {
use Guards_Hook_Prefix;

/**
* Sub-plugins registered but not yet handed to the registrar.
*
* @var Sub_Plugin[]
*/
private static $pending = [];

/**
* Whether the hooks have been wired.
*
Expand Down Expand Up @@ -69,10 +62,10 @@ public static function notices(): Queue_Interface {
* Register one bundled sub-plugin. Call once per sub-plugin, before boot().
*
* The sub-plugin is buffered rather than handed straight to the registrar, so that registering
* resolves nothing. Reaching the registrar needs the container, and a host that registers before
* it calls Config::set_container() would otherwise fail on a call that has nothing to do with
* the container. Buffering is what lets the container arrive at any point before boot, like
* every other configuration call.
* resolves nothing — not even the container. A host that registers before it calls
* Config::set_container() would otherwise fail on a call that has nothing to do with the
* container. The buffer belongs to `Registry_Reader`, which is where it is read back out: this
* class hands its collaborators no work and holds none of their state.
*
* The configuration is still validated here: building the Sub_Plugin is what rejects it, and
* that happens at the call the host can see in its own stack trace. It is built rather than
Expand All @@ -88,12 +81,17 @@ public static function notices(): Queue_Interface {
* @return void
*/
public static function register( array $config ): void {
self::$pending[] = new Sub_Plugin( $config );
Registry_Reader::buffer( new Sub_Plugin( $config ) );
}

/**
* Every registered sub-plugin, keyed by slug, in registration order.
*
* A delegation like the accessors above it, and for the same reason: what a host calls is here,
* what it does is the collaborator's. The passes that read the registry are handed that
* collaborator directly rather than calling back through this method — a facade sits in front of
* its collaborators, never underneath them.
*
* @since 1.0.0
*
* @throws Config_Exception When no container has been set, or two sub-plugins were registered
Expand All @@ -102,20 +100,7 @@ public static function register( array $config ): void {
* @return array<string,Sub_Plugin>
*/
public static function all(): array {
self::flush();

// Registrar_Interface::all() can only declare `array` — PHP 7.4 has no way to say
// array<string,Sub_Plugin> in a signature — so a host binding its own registrar may return
// anything at all. Narrowed once here, where the untrusted value crosses into the library,
// rather than at each call site: a consumer that forgot the check would fatal inside
// plugins_loaded on its first predicate call, which is the exact failure this library
// exists to prevent, and every future consumer would have to remember it too.
return array_filter(
self::registrar()->all(),
static function ( $sub_plugin ): bool {
return $sub_plugin instanceof Sub_Plugin;
}
);
return self::collaborator( Registry_Reader::class )->all();
}

/**
Expand Down Expand Up @@ -228,38 +213,4 @@ private static function collaborator( string $interface ): object {

return $collaborator;
}

/**
* Hand every buffered registration to the registrar.
*
* The registrar stays the single source of truth: the buffer is a pre-store that needs no
* container, and duplicate-slug detection and ordering remain the registrar's alone rather
* than being restated here in a second dialect.
*
* The buffer is emptied before the loop, so a second read cannot re-register what the
* registrar already holds and trip its duplicate-slug guard. It is emptied *after* the
* registrar resolves, so a container binding that throws leaves the registrations buffered
* for the next read rather than dropping them on the floor.
*
* @since 1.0.0
*
* @throws Config_Exception When no container has been set, or two sub-plugins were registered
* under one slug.
*
* @return void
*/
private static function flush(): void {
if ( self::$pending === [] ) {
return;
}

$registrar = self::registrar();
$pending = self::$pending;

self::$pending = [];

foreach ( $pending as $sub_plugin ) {
$registrar->register( $sub_plugin );
}
}
}
21 changes: 15 additions & 6 deletions src/Conflict/Detector.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

namespace Nexcess\PluginAbsorber\Conflict;

use Nexcess\PluginAbsorber\Absorber;
use Nexcess\PluginAbsorber\Contracts\Plugin_Checker_Interface;
use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
use Nexcess\PluginAbsorber\Registry_Reader;
use Nexcess\PluginAbsorber\Sub_Plugin;

/**
Expand All @@ -31,6 +31,13 @@
* @since 1.0.0
*/
class Detector {
/**
* @since 1.0.0
*
* @var Registry_Reader
*/
private $registry;

/**
* @since 1.0.0
*
Expand All @@ -41,9 +48,11 @@ class Detector {
/**
* @since 1.0.0
*
* @param Registry_Reader $registry Which sub-plugins are registered.
* @param Plugin_Checker_Interface $plugin_checker Whether the standalone is active.
*/
public function __construct( Plugin_Checker_Interface $plugin_checker ) {
public function __construct( Registry_Reader $registry, Plugin_Checker_Interface $plugin_checker ) {
$this->registry = $registry;
$this->plugin_checker = $plugin_checker;
}

Expand All @@ -60,10 +69,10 @@ public function __construct( Plugin_Checker_Interface $plugin_checker ) {
* @return bool
*/
public function has_conflict(): bool {
// Absorber::all() rather than a registrar of our own: it flushes the pending registrations
// before it reads, and a registrar asked directly would not see anything registered since
// the last read.
foreach ( Absorber::all() as $sub_plugin ) {
// The reader rather than a registrar of our own: it drains the registrations still buffered
// on the facade before it reads, and a registrar asked directly would miss anything
// registered since the last read.
foreach ( $this->registry->all() as $sub_plugin ) {
if ( $this->is_in_conflict( $sub_plugin ) ) {
return true;
}
Expand Down
23 changes: 16 additions & 7 deletions src/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
class Loader {
use Guards_Hook_Prefix;

/**
* @since 1.0.0
*
* @var Registry_Reader
*/
private $registry;

/**
* @since 1.0.0
*
Expand All @@ -31,10 +38,12 @@ class Loader {
/**
* @since 1.0.0
*
* @param Queue_Interface $notices Where a sub-plugin that could not load says so.
* @param Registry_Reader $registry Which sub-plugins are registered.
* @param Queue_Interface $notices Where a sub-plugin that could not load says so.
*/
public function __construct( Queue_Interface $notices ) {
$this->notices = $notices;
public function __construct( Registry_Reader $registry, Queue_Interface $notices ) {
$this->registry = $registry;
$this->notices = $notices;
}

/**
Expand All @@ -53,11 +62,11 @@ public function load_all(): void {
return;
}

// Absorber::all() rather than the registrar directly: it flushes the registrations still
// buffered on the facade before it reads, and a registrar asked on its own would miss
// anything registered since the last read.
// The reader rather than the registrar directly: it drains the registrations still buffered
// on the facade before it reads, and a registrar asked on its own would miss anything
// registered since the last read.
try {
$sub_plugins = Absorber::all();
$sub_plugins = $this->registry->all();
} catch ( Config_Exception $exception ) {
// The flush is where a duplicate slug is caught, and reading the registrar is where a
// missing container or an unusable binding is. All three are bootstrap mistakes, and
Expand Down
17 changes: 15 additions & 2 deletions src/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,30 @@ static function () use ( $container ): Queue {
}
);

$this->bind_once(
Registry_Reader::class,
static function () use ( $container ): Registry_Reader {
return new Registry_Reader( $container->get( Registrar_Interface::class ) );
}
);

$this->bind_once(
Detector::class,
static function () use ( $container ): Detector {
return new Detector( $container->get( Plugin_Checker_Interface::class ) );
return new Detector(
$container->get( Registry_Reader::class ),
$container->get( Plugin_Checker_Interface::class )
);
}
);

$this->bind_once(
Loader::class,
static function () use ( $container ): Loader {
return new Loader( $container->get( Queue_Interface::class ) );
return new Loader(
$container->get( Registry_Reader::class ),
$container->get( Queue_Interface::class )
);
}
);

Expand Down
Loading
Loading