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
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ last, so it is only consulted for a sub-plugin that would otherwise have loaded

Keep the `, 0` on the bootstrap hook. `boot()` wires its work at `plugins_loaded` priorities 1 and 2,
and WordPress silently ignores a callback added at or past the priority the running dispatch has
reached — including the priority it is running right now. Booting that late is detected and reported
through `_doing_it_wrong()`, and the load runs inline instead, but the ordering guarantees are
weaker.
reached — including the priority it is running right now, so priority 1 is already too late. Booting
that late is detected and reported through `_doing_it_wrong()`, and both the conflict resolution and
the load run inline instead, but the ordering guarantees are weaker.

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

Expand Down Expand Up @@ -107,6 +107,32 @@ 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. |

A policy the library does not recognise is treated as `NOTICE_ONLY`, never as the default. A typo in
a stored policy should not deactivate a plugin the site owner deliberately turned on.

`DEACTIVATE` deactivates the standalone and then **ends the request with a redirect**, so conflict
resolution only runs on an admin page view: a `GET`, in `wp-admin`, outside cron, AJAX, and WP-CLI.
Ending a checkout POST or a cron run to deactivate a plugin would cost far more than the conflict
does, and a redirect turns any submitted form — including one posted to `admin-post.php`, which is
`is_admin()` — into a `GET` that arrives without its body. Whatever is skipped is picked up on the
next page view; the standalone is still there to find. The deactivation is silent: the standalone's own deactivation hook is not fired, because at
`plugins_loaded` a routine `flush_rewrite_rules()` in that callback would rebuild the rules before a
single post type is registered.

### Per-sub-plugin policy override

`conflict_policy` accepts a `callable( Sub_Plugin ): string`, so one sub-plugin can decide at
runtime without a container and without touching the library:

```php
'conflict_policy' => static function ( Sub_Plugin $sub_plugin ) {
// Stand down if a newer standalone supersedes the bundled copy.
return my_standalone_version_at_least( $sub_plugin, '3.0.0' )
? Conflict_Policy::DEFER
: Conflict_Policy::DEACTIVATE;
},
```

### Sub-plugin configuration

| Key | Type | Required | Meaning |
Expand Down Expand Up @@ -159,6 +185,13 @@ Config::set_container( $container );
|---|---|---|
| `Contracts\Registrar_Interface` | `Registrar` | Holds the registered sub-plugins. |
| `Contracts\Notices_Interface` | `Notices` | Notice queue and rendering. |
| `Contracts\Plugin_State_Interface` | `Plugin_State` | Whether a plugin is active, and turning one off. |
| `Conflict\Resolver_Interface` | `Conflict\Resolver` | Which sub-plugins conflict, the policy branch, the redirect. |

`Plugin_State` is the only place the library calls WordPress's plugin functions, so binding it is
how you keep the absorber away from `is_plugin_active()` and `deactivate_plugins()` entirely —
useful where plugin state is managed outside WordPress, and where a test wants the decisions
without the side effects.

The default notices queue into the option `{prefix}_plugin_absorber_notices` — a network option on
multisite — and render for users who can `activate_plugins`. Read `Notices::option_name()` if you
Expand Down
85 changes: 79 additions & 6 deletions docs/superpowers/plans/2026-07-31-plugin-absorber.md
Original file line number Diff line number Diff line change
Expand Up @@ -1557,10 +1557,13 @@ git checkout 06-conflict-policy && git checkout -b 07-sub-plugin
> 3. **`is_standalone_plugin_active()` no longer ORs in `is_plugin_active_for_network()`.** Verified
> against core: `is_plugin_active()` already does, so the OR was dead code costing a second
> `get_site_option()` per sub-plugin per request. The test that pinned it described a state
> WordPress cannot produce.
> WordPress cannot produce. *(The reasoning stands; the method has since moved off `Sub_Plugin`
> onto `Plugin_State::is_active()` — see the PR 7 follow-up block under Task 12.)*
> 4. **`load_plugin_functions()` guards on `is_plugin_active_for_network`,** not `is_plugin_active`.
> Both live in the same file, but a third party defining an `is_plugin_active` shim — a known WP
> idiom — would short-circuit the require and leave the network predicate undefined.
> *(Superseded: the guard is now `deactivate_plugins`, and the include lives on `Plugin_State` —
> same reasoning, a function the library still calls. See the PR 7 follow-up block under Task 12.)*
> 5. **`get_conflict_notice_message()` takes a `$default`.** Task 14 has no fallback of its own, so
> an unconfigured host would have been shown WordPress's raw fatal-error screen.
> 6. **The filter result is `is_scalar()`-guarded.** A filter returning `WP_Error` would otherwise
Expand Down Expand Up @@ -3850,12 +3853,77 @@ loading anything.'
- Create: `src/Conflict/Resolver_Interface.php`, `src/Conflict/Resolver.php`, `tests/unit/Conflict/ResolverTest.php`
- Modify: `src/Loader.php` (add `resolver()` and the @1 hook), `README.md`

> **Deviations, deliberate (added 2026-08-03, from the PR 12 review):**
>
> 1. **`deactivate_plugins()` is called with no `$network_wide` argument, and the plan's reasoning
> for passing one is factually wrong.** The plan says omitting it makes the call "a silent no-op
> for a network-activated plugin", producing an endless redirect. Verified against core: the
> default is `null`, not `false`. Core enters the network branch on `false !== $network_wide` and
> the blog branch on `true !== $network_wide`, so `null` takes **both**. The infinite loop never
> existed. Worse, passing a computed `true` *skips* the blog branch, so a plugin that is both
> network-active and listed in a blog's `active_plugins` keeps that entry and needs a second
> request — and a second deactivation hook — to clear. Two tests now exercise real core rather
> than a stub, since this claim is the only thing the argument rested on.
> 2. **The deactivation is silent.** With `$silent = false` core fires `deactivate_plugin` and the
> standalone's own `register_deactivation_hook()` callback — at `plugins_loaded`, before `init`.
> A routine `flush_rewrite_rules()` there rebuilds the rules with no post type or taxonomy
> registered, and every custom permalink on the site 404s. Core's own automatic deactivations,
> `validate_active_plugins()` and the plugin upgrader, both pass `true`; only its interactive
> admin paths are noisy.
> 3. **`redirect_destination()` matches the screen, not a substring of an absolute URL.**
> `wp_get_referer()` prefers the `_wp_http_referer` field that every nonce-bearing admin form
> carries, and that field holds a bare *path* — so comparing against `admin_url()` missed every
> admin form POST, the whole network admin, and any site behind a TLS-terminating proxy where
> `admin_url()` says http and the referrer says https. The "never interrupt an inline update"
> guard the plan is proudest of did not fire for a plugins.php bulk action.
> 4. **The destructive path is gated on the request context** — see the correction to deferred
> issue B. The gate is `GET`, in the admin, outside cron/AJAX/WP-CLI. `is_admin()` and
> `wp_doing_ajax()` between them do not cover `admin-post.php` or `options.php`: both define
> `WP_ADMIN`, neither defines `DOING_AJAX`, and a redirect turns the form POST into a bodyless
> `GET`. Requiring a `GET` covers every admin form submission at once, which is also what core
> does in `wp_cron()`. Nothing is lost — the standalone is still active on the next page view.
> 5. **`boot()`'s too-late guard measures against priority 1, not 2.** Task 11 fixed the comparison
> to be inclusive; adding the @1 hook here moved the boundary with it. Left at 2, booting from
> `plugins_loaded` at priority 1 would wire both hooks, run the load loop, and silently drop
> conflict resolution — the standalone stays active, the bundled copy stands down behind its
> guard constant, and nothing is reported. Wiring is all-or-nothing, so the guard now trips at
> the earlier of the two priorities and does both inline.

> **Deviations, deliberate (added 2026-08-07, from the PR 7 follow-up review):**
>
> 1. **`Conflict_Policy` owns the default policy.** `Sub_Plugin::get_conflict_policy()` used to
> name `Conflict_Policy::DEACTIVATE` as its fallback, which put "which policy applies when none
> is configured" in the object that merely holds one sub-plugin's config. It now asks
> `Conflict_Policy::default()`. The two fallbacks are deliberately different and now documented
> as such: *unconfigured* means the sub-plugin accepted the default, whereas *unrecognised* —
> still `NOTICE_ONLY`, still decided in `Resolver::resolve()` — is a value nobody chose, and
> reading a typo as consent to deactivate is the one outcome worth refusing.
> 2. **`Plugin_State_Interface` is now the library's only route to WordPress's plugin functions.**
> `Sub_Plugin` was a config value object that also queried global plugin state and
> `require_once`'d `wp-admin/includes/plugin.php`; `Resolver` did its own `require_once` of the
> same file guarded on a *different* function. One gateway, one include, one guard. It is the
> fourth container-bindable collaborator, resolved through the existing `Loader::resolve()`.
> 3. **`Sub_Plugin::is_standalone_plugin_active()` is gone, not delegated.** Its only production
> caller was `Resolver::resolve_all()`, which already reaches collaborators through `Loader`, so
> delegating would have bought `Sub_Plugin` a dependency on `Loader` — a cycle — to answer a
> question that was never about its configuration. The resolver now pairs
> `has_standalone_plugin()` with `Loader::plugin_state()->is_active( … )`.
> 4. **`is_standalone_plugin_network_active()` is deleted rather than ported.** Zero production
> callers; its own docblock conceded it was informational. Deviation 1 of the PR 12 block is why
> nothing needs it — core's `null` default already covers both scopes.
> 5. **The include guards on `deactivate_plugins`,** superseding deviation 4 of the PR 7 block,
> which named `is_plugin_active_for_network` — a function the library no longer calls. The
> reasoning is unchanged and still applies: not `is_plugin_active`, because a third-party shim
> of it is a known WP idiom and would short-circuit the require.

**Interfaces:**
- Consumes: `Loader::all()` (Task 9), `Loader::notices()` (Task 10), `Sub_Plugin::is_standalone_plugin_active()` / `is_standalone_plugin_network_active()` / `get_conflict_policy()` (Task 7), `Conflict_Policy::*` (Task 6).
- Consumes: `Loader::all()` (Task 9), `Loader::notices()` (Task 10), `Loader::plugin_state()`, `Sub_Plugin::has_standalone_plugin()` / `get_standalone_plugin_basename()` / `get_conflict_policy()` (Task 7), `Conflict_Policy::*` (Task 6).
- Produces:
- `Conflict\Resolver_Interface` with `resolve_all(): void`
- `Conflict\Resolver::redirect_destination( $referrer )` — `protected`, returns `string|false`
- `Contracts\Plugin_State_Interface` with `is_active( string ): bool` and `deactivate( string ): void`
- `Loader::resolver(): Resolver_Interface`
- `Loader::plugin_state(): Plugin_State_Interface`
- `Loader::run_conflict_resolution(): void`

- [ ] **Step 1: Cut the branch**
Expand Down Expand Up @@ -5789,10 +5857,15 @@ Submit `https://github.com/stellarwp/plugin-absorber` at <https://packagist.org/

Recorded in the spec, deliberately not fixed in 1.0.0:

- **B** — `resolve_all()` runs on front-end requests. With no referrer it redirects to
`admin_url( 'plugins.php' )`, bouncing a logged-out visitor to the login screen. Wrapping it in
`is_admin()` fixes it and is safe, since the load guard already prevents any front-end fatal.
Matches both reference implementations as-is.
- ~~**B**~~ — **fixed in PR 12, not deferred (2026-08-03).** The issue was understated. Bouncing a
logged-out visitor to the login screen is the mildest case, not the representative one: the same
code path turns a visitor's checkout POST into a 302 that silently drops the order, bounces a
login POST back to a blank form, aborts `wp-cron.php` before its event loop, and ends a WP-CLI
command with status 0 and no output, because `header()` does nothing under the CLI SAPI. Losing
an order to avoid a conflict that cannot cause a front-end fatal is not a trade worth deferring.
`is_admin()` alone is also not the fix the entry claims — `admin-ajax.php` and `admin-post.php`
both define `WP_ADMIN`. `Loader::run_conflict_resolution()` now gates on `is_admin()` plus not
cron, not AJAX, and not WP-CLI.
- **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.
Expand Down
137 changes: 137 additions & 0 deletions src/Conflict/Resolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php
/**
* @package Nexcess\PluginAbsorber
*/

namespace Nexcess\PluginAbsorber\Conflict;

use Nexcess\PluginAbsorber\Conflict_Policy;
use Nexcess\PluginAbsorber\Loader;
use Nexcess\PluginAbsorber\Sub_Plugin;

/**
* Default conflict resolution: detect the active standalone and act per policy.
*
* @since 1.0.0
*/
class Resolver implements Resolver_Interface {
/**
* @since 1.0.0
*
* @return void
*/
public function resolve_all(): void {
foreach ( Loader::all() as $sub_plugin ) {
// Registrar_Interface::all() only declares `array`, so a host-bound registrar can
// return anything. Calling a predicate on it would fatal inside plugins_loaded.
if ( ! $sub_plugin instanceof Sub_Plugin ) {
continue;
}

if ( ! $sub_plugin->is_enabled() || ! $sub_plugin->has_standalone_plugin() ) {
continue;
}

if ( ! Loader::plugin_state()->is_active( $sub_plugin->get_standalone_plugin_basename() ) ) {
continue;
}

$this->resolve( $sub_plugin );
}
}

/**
* @since 1.0.0
*
* @param Sub_Plugin $sub_plugin Sub-plugin whose standalone is active.
*
* @return void
*/
protected function resolve( Sub_Plugin $sub_plugin ): void {
$policy = $sub_plugin->get_conflict_policy();

// A host may persist a policy in an option and a filter may return anything. Falling
// through to deactivate() would turn off a plugin the site owner deliberately activated
// on the strength of a typo, so an unrecognised policy takes the conservative branch.
if ( ! Conflict_Policy::is_valid( $policy ) ) {
$policy = Conflict_Policy::NOTICE_ONLY;
}

switch ( $policy ) {
case Conflict_Policy::DEFER:
// The standalone wins. Its own constant makes the load path skip the bundled copy.
return;

case Conflict_Policy::NOTICE_ONLY:
Loader::notices()->queue_conflict_notice( $sub_plugin );

return;

case Conflict_Policy::DEACTIVATE:
default:
$this->deactivate( $sub_plugin );
}
}

/**
* @since 1.0.0
*
* @param Sub_Plugin $sub_plugin Sub-plugin whose standalone is active.
*
* @return void
*/
protected function deactivate( Sub_Plugin $sub_plugin ): void {
Loader::plugin_state()->deactivate( $sub_plugin->get_standalone_plugin_basename() );

// Queued after the deactivation but before the redirect, so the explanation is durable
// whether or not the request goes on to end here.
Loader::notices()->queue_merge_notice( $sub_plugin );

$destination = $this->redirect_destination( wp_get_referer() );

if ( $destination !== false ) {
wp_safe_redirect( $destination );

exit;
}
}

/**
* Where to send the user after deactivating, or false to stay put.
*
* The point of the redirect is to re-render whatever the user was looking at now that the
* standalone is gone. Two referrers are handled specially: the update screens, where reloading
* would re-run an update, and the plugins list, which already reads plugin state fresh — so
* sending them back there would only cost a round trip.
*
* @since 1.0.0
*
* @param string|false $referrer Result of wp_get_referer().
*
* @return string|false
*/
protected function redirect_destination( $referrer ) {
if ( ! is_string( $referrer ) || $referrer === '' ) {
return admin_url( 'plugins.php' );
}

// Match on the screen, not on a substring of an absolute URL. wp_get_referer() prefers
// the _wp_http_referer field that every nonce-bearing admin form carries, and that field
// holds a bare path -- so comparing against admin_url() misses every admin form POST,
// misses the network admin entirely, and misses any site behind a TLS-terminating proxy
// where admin_url() says http and the referrer says https.
$screen = basename( (string) wp_parse_url( $referrer, PHP_URL_PATH ) );

if ( $screen === 'update.php' || $screen === 'update-core.php' ) {
return admin_url( 'plugins.php' );
}

// Staying put. Core lands here after a bulk action, and the list is about to render the
// deactivation we just made anyway.
if ( $screen === 'plugins.php' ) {
return false;
}

return $referrer;
}
}
28 changes: 28 additions & 0 deletions src/Conflict/Resolver_Interface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php
/**
* @package Nexcess\PluginAbsorber
*/

namespace Nexcess\PluginAbsorber\Conflict;

/**
* Decides what happens when a sub-plugin's standalone counterpart is still active.
*
* Bind a replacement to change conflict handling globally.
*
* @since 1.0.0
*/
interface Resolver_Interface {
/**
* Act on every registered sub-plugin whose standalone is active.
*
* Runs at plugins_loaded priority 1, before the load loop, and an implementation that
* deactivates is expected to end the request — so anything after it in that dispatch will
* not run.
*
* @since 1.0.0
*
* @return void
*/
public function resolve_all(): void;
}
21 changes: 19 additions & 2 deletions src/Conflict_Policy.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ final class Conflict_Policy {
* Deactivate the standalone, notify, and redirect. The bundled copy loads on the next
* request, since the standalone has already defined the guard constant on this one.
*
* The default.
*
* @since 1.0.0
*
* @var string
Expand All @@ -41,6 +39,25 @@ final class Conflict_Policy {
*/
public const NOTICE_ONLY = 'notice_only';

/**
* The policy that applies when a sub-plugin configures none.
*
* Deactivating is the default because two copies of the same plugin are the failure this
* library exists to prevent, and a sub-plugin that has not thought about the question wants
* the outcome where its bundled copy ends up running.
*
* Distinct from the branch a caller takes for a policy it does not recognise: not configuring
* one is a choice to accept the default, whereas an unrecognised value is a value nobody
* chose, and reading it as consent to deactivate would act on a typo.
*
* @since 1.0.0
*
* @return string
*/
public static function default(): string {
return self::DEACTIVATE;
}

/**
* Every policy this library understands.
*
Expand Down
Loading
Loading