Skip to content
Closed
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
100 changes: 79 additions & 21 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ 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.

`Loader` 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
`Loader` keeps the public surface. `registrar()`, `notices()` and `resolver()` 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.

`Sub_Plugin` is a value object answering the per-sub-plugin questions it can answer **without a
Expand All @@ -133,23 +133,25 @@ the plugin to ask about, and the collaborator does the asking.

### What exists today

`src/Conflict/` — `Resolver`, `Gatekeeper`, `Redirector` — and `Activator` are not built yet.
Currently:
Every behaviour described above is built; nothing in `src/` is still owed. What is left in the plan
is the end-to-end suite and the release pass.

| Path | What |
|---|---|
| `src/Config.php` | Static facade: hook prefix + container. |
| `src/Loader.php` | Static facade: the registration buffer, `boot()`, and the accessors. |
| `src/Loader.php` | Static facade: the registration buffer, `boot()`, the accessors, and the two notice trampolines. |
| `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/Load/Runner.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/Activator.php` | Runs a sub-plugin's activation callback once ever, recorded in one option. |
| `src/Conflict/` | `Resolver` (which policy branch to take), `Gatekeeper` (which requests may take one), `Redirector` (where the user lands afterwards), `Contracts\Resolver_Interface`. |
| `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`. |
| `src/Notices/` | `Queue` (what a notice says, who may consume it, and the activation-error rewrite), `Store` (keeps it), `Renderer` (draws it), `Contracts\Queue_Interface`. |
| `src/Contracts/`, `src/Exceptions/` | `Provider_Interface`, `Registrar_Interface`, `Plugin_Deactivator_Interface`, `Plugin_Checker_Interface`, `Activator_Interface`, `Config_Exception`. |

### Boot lifecycle

Expand All @@ -161,7 +163,7 @@ Loader::boot(); // idempotent
→ Provider::register() // every binding
→ Boot\Scheduler // every hook, as a closure over the container

plugins_loaded @1 → Conflict\Resolver::resolve_all() [gated by Conflict\Gatekeeper]
plugins_loaded @1 → Conflict\Gatekeeper, then Conflict\Resolver::resolve_all()
plugins_loaded @2 → Load\Runner::load_all()
all_admin_notices → Loader::render_notices() [is_admin() only]
wp_admin_notice_markup → Loader::filter_activation_error_markup() [is_admin() only]
Expand All @@ -177,39 +179,95 @@ earlier holds an orphan whose bindings are discarded. This is also why `Loader::
and resolves nothing — registration at plugin-file scope, which the spec sanctions, would otherwise
register into the throwaway.

**The too-late barrier measures against the first step in the sequence, not the last.**
`Boot\Scheduler` compares the priority `plugins_loaded` is already dispatching against the lowest
priority it has to wire — conflict resolution at 1, not the load at 2 — and over that line it runs
the whole sequence inline in hook order rather than wiring any of it. Measuring against the load
would let a host booting at priority 1 wire the load and silently lose the conflict pass, which is
the half of the sequence a fatal depends on. The comparison is inclusive, because a callback added
at the priority currently being dispatched is accepted and never reached.

`load_all()` gates each sub-plugin in order, skipping on the first failure: enabled → not already
loaded → dependencies met → file exists → `should_load` filter → `require_once` → activation
callback (only after a *successful* require).

The activation callback is the last of those and runs through `Activator`, which `Load\Runner` takes
as a constructor argument like the notice queue. Last, because a bundled plugin is included rather
than activated: `register_activation_hook()` never fires for it, so the callback stands in for
whatever that hook would have done, and it has to run with the plugin's own code already in memory.
Only after a require that happened, because creating tables and seeding options for a sub-plugin
whose code is *not* loaded is worse than not creating them — and the once-ever record would then
stand the callback down for good, the first time the sub-plugin really did load. The record is
written after the callback returns, never before, so a callback that throws is retried next request
rather than marked done.

The guard constant is checked **before** the dependency check, not after. It is one `defined()`, it
carries the whole re-declaration guarantee, and it is the only gate meaning "this plugin is already
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.

`Loader::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 interface signature — so it is filtered once where the untrusted value enters. All three readers
— the load pass, the conflict pass and the activation-error rewrite — go through `Loader::all()`
rather than through the registrar they could resolve for themselves, because it flushes the pending
registrations before it reads and a registrar asked directly would miss anything registered since
the last flush.

`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.
`Conflict\Redirector` decides where to; it returns `false` when the referrer is already `plugins.php`,
so an inline update is never interrupted. It decides and never navigates — `wp_safe_redirect()` and
`exit` stay in the resolver, so the policy action and the admin-URL knowledge change for separate
reasons.
`DEACTIVATE` (the default) deactivates network-aware, queues a merge notice, and redirects. It is
the worked example of required injection — `Plugin_Checker_Interface` to detect the standalone,
`Plugin_Deactivator_Interface` to turn it off, `Queue_Interface` for the notice and
`Conflict\Redirector` for the destination, all four constructor arguments with no default — so the
object a test builds is the object the provider builds, and a host's rebinding of either plugin seam
reaches it without the resolver knowing a container exists.

`Conflict\Redirector::after_deactivation( $referrer )` decides where the user lands and never goes
there: `wp_safe_redirect()` and the `exit` after it stay in the resolver, so the policy action and
the admin-URL knowledge change for separate reasons, and every destination is assertable without a
test standing in for the end of a request. It returns `false` — stay put — when the referrer is
already `plugins.php`, since that list is about to render the deactivation anyway; it sends
`update.php` and `update-core.php` to `plugins.php`, since reloading either re-runs an update; with
no usable referrer, `plugins.php`. It matches on the screen basename, not on a substring of an
absolute URL: `wp_get_referer()` prefers the bare `_wp_http_referer` path that every nonce-bearing
admin form carries, so comparing against `admin_url()` would miss the network admin and every site
behind a TLS-terminating proxy.

**Who may have a conflict resolved is `Conflict\Gatekeeper`'s business, not the resolver's.** It
gates on an interactive admin `GET` (`plugins_loaded` fires on every request) *and* on
`current_user_can( 'activate_plugins' )` (`plugins_loaded` runs before `auth_redirect()`, so an
unauthenticated GET of an admin URL gets that far). The hook resolves the gatekeeper rather than the
resolver, so a host binding its own `Resolver_Interface` cannot drop either gate by omission. The
capability gate covers every policy, not just the destructive one, and that is free: the other
branches only queue a notice, and `Notices\Queue::render()` refuses to render *or clear* for a user
without the same capability, so queuing earlier would only park it until a capable admin arrives.
gates on an interactive admin `GET` (`plugins_loaded` fires on every request, including cron, CLI
and a visitor's POST) *and* on `current_user_can( 'activate_plugins' )` (`plugins_loaded` runs
before `auth_redirect()`, so an unauthenticated GET of an admin URL gets that far). The
`plugins_loaded` step asks the gatekeeper *before* it resolves `Resolver_Interface` at all, so a
host binding its own resolver cannot drop either gate by omission — and a request that fails one
never builds a resolver. The capability gate covers every policy, not just the destructive one, and
that is free: the other branches only queue a notice, and `Notices\Queue::render()` refuses to
render *or clear* for a user without the same capability, so queuing earlier would only park it
until a capable admin arrives.

An unknown policy must be handled as its own case via `Conflict_Policy::is_valid()`, never left to
a `default:` fallthrough — a typo like `'defered'` would otherwise deactivate a plugin the site
owner deliberately turned on.

**The activation-error rewrite lives on `Notices\Queue`, not on a class of its own.** It is the one
conflict the load guard cannot prevent — core includes the plugin being activated *after* the
bundled copy is in memory, so the re-declaration really does fatal — and all this library gets to do
about it is reword the sentence core's sandbox prints. That sentence is
`conflict_notice_message`, the same message the merge notice carries, so wording it belongs where
every other notice is worded; a host that binds its own `Queue_Interface` owns the error screen
along with the rest. `Loader::filter_activation_error_markup()` is the trampoline, and it takes an
**untyped** argument: a filter receives whatever the filter before it returned, and a `string`
declaration would turn another plugin's sloppy return into a TypeError raised from here, on the
screen least able to afford a second one. The rewrite refuses unless the screen is `plugins`, the
`plugin` query arg names a registered standalone and `_error_nonce` verifies — and it sanitises with
`wp_kses_post()` *before* testing for emptiness, since a message that filters down to nothing must
leave core's wording standing rather than blank the notice box.

**`Boot\Scheduler` wires `wp_admin_notice_markup` as a named static callback, not a closure.** Both
admin-only hooks are `[ Loader::class, … ]` pairs that resolve the queue when they fire, so neither
builds anything at boot; what the name buys on this one is `remove_filter()`, which a host wanting
core's wording back has no other way to reach. The `plugins_loaded` steps are closures for a reason
these two do not share — that sequence has to be runnable inline as well as wirable.

### Keys

- Filters: `{$hook_prefix}/plugin_absorber/should_load`, `{$hook_prefix}/plugin_absorber/conflict_policy`
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ add_action( 'plugins_loaded', function () {
The container is required — any StellarWP `ContainerInterface` implementation, the one you already
hand to Telemetry or Uplink. Every collaborator comes from it.

Keep the `, 0`. `boot()` wires the load at `plugins_loaded` priority 2, and WordPress silently
ignores a callback added at or past the priority it is already dispatching — so configuring the
library from a provider that itself runs at priority 2 or later races the library it is configuring.
Booting later is reported through `_doing_it_wrong()` and loaded inline, but the ordering guarantees
are weaker.
Keep the `, 0`. `boot()` wires conflict resolution at `plugins_loaded` priority 1 and the load at
priority 2, and WordPress silently ignores a callback added at or past the priority it is already
dispatching — so configuring the library from a provider that itself runs at priority 1, which is
where several hosts wire their container today, races the library it is configuring. Booting later is
reported through `_doing_it_wrong()` and loaded inline, but the ordering guarantees are weaker.

Put this in the block that owns your container, not in a service provider, and pass the container you
intend to keep: a host that builds one lazily and replaces it later leaves us holding an orphan whose
Expand All @@ -53,7 +53,7 @@ bindings were discarded.

- [Installing](docs/installing.md) — Composer, Strauss, and the constants Strauss must leave alone.
- [Configuration](docs/configuration.md) — the hook prefix, the container, every sub-plugin key.
- [Conflict handling](docs/conflict-handling.md) — the policies, the load guard, and its limits.
- [Conflict handling](docs/conflict-handling.md) — the policies, when they run, and the guard's limits.
- [Filters](docs/filters.md) — the runtime overrides for policies and notice text.
- [Notices](docs/notices.md) — where the queue lives, who may see it, and how to render it yourself.

Expand Down
32 changes: 32 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,20 @@ $container->singleton( Registrar_Interface::class, My_Registrar::class );
| `Notices\Contracts\Queue_Interface` | `Notices\Queue` | Queues and renders the admin notices. |
| `Contracts\Plugin_Deactivator_Interface` | `Plugin_Deactivator` | Deactivates the standalone. |
| `Contracts\Plugin_Checker_Interface` | `Plugin_Checker` | Answers whether a plugin is active. |
| `Conflict\Contracts\Resolver_Interface` | `Conflict\Resolver` | Detects the active standalone and applies the policy. |
| `Contracts\Activator_Interface` | `Activator` | Runs a sub-plugin's activation callback once, ever. |

`Plugin_Checker_Interface` is the seam to rebind when your plugin filters `option_active_plugins` or
`site_option_active_sitewide_plugins` — LearnDash injects and then strips a synthetic path — because
`is_plugin_active()` then does not report what is in the database.

Rebinding `Resolver_Interface` does not put you in charge of *when* resolution may run. Both gates —
[an interactive admin `GET`, and the `activate_plugins`
capability](conflict-handling.md#when-resolution-runs) — live in `Conflict\Gatekeeper`, which the
hook consults before it resolves the resolver at all, so an implementation that never thought about
either is still safe. Everything the resolver *does* — which policy branch, what the notice says,
where the user lands — is yours.

`set_container()` is a configuration call like `set_hook_prefix()`, and order does not matter among
the configuration calls: it may come before or after your `Loader::register()` calls, so long as it
comes before boot. Registering buffers the sub-plugin and resolves nothing, so nothing is decided
Expand Down Expand Up @@ -95,6 +104,29 @@ at the second `register()` call; a config array the library cannot use is still
Register unconditionally and put anything you cannot decide up front — a licence that may not be
active, a setting the site owner can change — in `enabled`, which is re-evaluated on every load.

## Activation

A bundled plugin is `require_once`d, not activated, so `register_activation_hook()` never fires for
it — whatever that hook would have done, creating a table or seeding options, would otherwise never
happen at all. [`activation_callback`](#sub-plugin-keys) fills that gap:

```php
'activation_callback' => static function ( Sub_Plugin $sub_plugin ) {
\Give\Recurring\Install::create_tables();
},
```

It runs exactly once ever per slug, is passed the `Sub_Plugin`, and runs only after a require that
actually happened — never for a sub-plugin whose load was skipped, because a schema appearing for a
plugin that is not loaded is worse than no schema at all.

The record lives in the `{option_prefix}_plugin_absorber_activations` option, a network option on
multisite for the same reason the [notice queue](notices.md) is one: `deactivate_plugins()` is
network-wide, so a merge that happened network-wide must not re-run the callback on every site. The
slug is recorded *after* the callback returns, so a callback that fails is retried on the next
request rather than marked done and silently skipped forever. Bind `Activator_Interface` to record
"once, ever" somewhere else — your own migration table, say.

## The bundled file is included from a function, not from global scope

WordPress includes plugins from `wp-settings.php` at global scope; this library includes them from
Expand Down
Loading
Loading