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
61 changes: 43 additions & 18 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,8 +133,7 @@ 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:
`Activator` is not built yet. Currently:

| Path | What |
|---|---|
Expand All @@ -147,6 +146,7 @@ Currently:
| `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/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`. |
Expand All @@ -161,7 +161,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,6 +177,14 @@ 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).
Expand All @@ -188,23 +196,40 @@ 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. Both passes read
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
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
8 changes: 8 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,19 @@ $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. |

`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
57 changes: 55 additions & 2 deletions docs/conflict-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,61 @@ When a sub-plugin's standalone counterpart is still active:
| `Conflict_Policy::DEFER` | Leave the standalone active; the load guard stands the bundled copy down. |
| `Conflict_Policy::NOTICE_ONLY` | Leave it active and ask the user to deactivate it. |

Set one per sub-plugin with the `conflict_policy` key, or decide it at runtime with the
`conflict_policy` [filter](filters.md), which has the final say.
Set one per sub-plugin with the `conflict_policy` key — a constant, or a `callable( Sub_Plugin ):
string`. The `conflict_policy` [filter](filters.md) runs after that and has the final say:

```php
// In the config: stand down when a newer standalone supersedes the bundled copy.
'conflict_policy' => static fn( Sub_Plugin $sub ) => give_standalone_is_newer( $sub )
? Conflict_Policy::DEFER
: Conflict_Policy::DEACTIVATE,

// Anywhere, and last:
add_filter( 'give/plugin_absorber/conflict_policy', static function ( $policy, $sub ) {
return $sub->get_slug() === 'give-recurring' ? Conflict_Policy::NOTICE_ONLY : $policy;
}, 10, 2 );
```

**An unrecognised policy is treated as `NOTICE_ONLY`**, never as consent to deactivate.
`Conflict_Policy::is_valid()` decides, so a typo like `'defered'` — in a policy a host persisted in
an option, or in whatever that filter returned — only produces a notice. A value nobody chose must
not turn off a plugin somebody chose.

A policy is only reached for a sub-plugin that is enabled, names a `standalone_plugin_basename`, and
whose standalone is active right now; everything else is skipped before any policy is read.

## When resolution runs

At `plugins_loaded` priority 1, one ahead of the load pass at 2: a standalone that survives the
conflict defines the guard constant as it loads, and the load pass has to see that.

It runs **only on an interactive admin `GET`** — not WP-CLI, not cron, not ajax, not a form POST —
because resolving can deactivate a plugin and end the request with a redirect. Ungated, a visitor's
checkout POST would come back as a 302 that discards what was submitted and drops the order, and a
WP-CLI command would exit having printed nothing, because `header()` is a no-op under the CLI SAPI.
Waiting costs nothing: the standalone is still there to detect on the next page view.

It also requires `current_user_can( 'activate_plugins' )`: `plugins_loaded` fires well before
`auth_redirect()`, so an unauthenticated GET of an admin URL reaches this code on its way to the
login screen, and whoever cannot activate a plugin must not be able to deactivate one. This applies
to every policy rather than only to `deactivate`, which costs nothing — the other policies just
queue a notice, and a notice is neither shown nor cleared for a user without that same capability,
so nothing is consumed by waiting for one who has it.

Both gates live in `Conflict\Gatekeeper`, and the hook asks it *before* it resolves
`Conflict\Contracts\Resolver_Interface` at all. So binding your own resolver cannot drop them by
omission: on a request that fails either gate your implementation is never built, let alone called.

## The redirect

After deactivating, the user goes back to whatever they were looking at, so it re-renders without the
standalone. Two referrers differ: `plugins.php` stays put, since the list is about to show the change
anyway, and the update screens (`update.php`, `update-core.php`) go to `plugins.php` instead, because
reloading one of those would re-run an update. With no referrer at all, `plugins.php`.

`Conflict\Redirector` makes that decision and returns it; the redirect itself is the resolver's. The
merge notice is queued before either, so the explanation survives whether or not the request ends in
a redirect.

## The load guard

Expand Down
4 changes: 3 additions & 1 deletion docs/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ Each runs last, after the configured value and any fallback. Because they fire w
asked for rather than when the sub-plugin is registered, they are also the place to call `__()`
by then the textdomain is loaded.

A filter returning a non-scalar yields an empty string rather than a fatal cast.
A filter returning a non-scalar yields an empty string rather than a fatal cast. A `conflict_policy`
return that is not one of the three constants is treated as [`NOTICE_ONLY`, never as consent to
deactivate](conflict-handling.md#policies).

## The load gate

Expand Down
Loading
Loading