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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,44 @@ 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. |

### Sub-plugin configuration

| Key | Type | Required | Meaning |
|---|---|:--:|---|
| `slug` | `string` | ✔ | Unique id — registry key, notice id, activation-tracking key. |
| `bundled_plugin_file` | `string` | ✔ | Absolute path to the **bundled** plugin's main file. This is what gets `require_once`d. |
| `plugin_loaded_constant` | `string` | ✔ | A constant the plugin defines when it loads. Both copies must define the *same* name, **at file scope**. `defined()` ⇒ skip, which is what prevents re-declaration fatals. **Load guard only.** |
| `standalone_plugin_basename` | `string` | | The standalone's `dir/file.php` basename. Used for `is_plugin_active()` and `deactivate_plugins()`. Omit when there is no standalone. **Detection only.** |
| `enabled` | `bool\|callable` | | `true` by default. A `callable( Sub_Plugin ): bool` is re-evaluated on every call, not cached. |
| `conflict_policy` | `string\|callable` | | `Conflict_Policy::DEACTIVATE` by default. A `callable( Sub_Plugin ): string` decides at runtime. The `conflict_policy` filter below overrides both. |
| `conflict_notice_message` | `string\|callable` | | Shown on auto-deactivation and on a re-activation attempt. Empty by default. Use a callable to defer `__()` past `init`. |
| `dependency_notice_message` | `string\|callable` | | Shown when `dependency_check` fails. Defaults to a generic, untranslated sentence naming the raw slug. |
| `activation_callback` | `callable( Sub_Plugin )` | | Runs **exactly once, ever**, per slug. |
| `dependency_check` | `callable( Sub_Plugin ): bool` | | Skips the load and queues a notice when it returns false. |

The load guard and the standalone basename are deliberately **two separate keys**. No constant does
double duty as both a guard and a path resolver.

A value that is a `string` is always used as a value, never called — even when a function of that
name exists. `dependency_check` and `activation_callback` are the two keys that are only ever
callables, and a value under either that cannot be called is rejected when the sub-plugin is
registered rather than ignored at the point of use.

The guard constant must be defined at **file scope**. A standalone that defines it from a bootstrap
hooked at `plugins_loaded` or later has not defined it yet at the moment the guard is read, and the
bundled copy would load on top of it.

The guard also cannot help on the request that *activates* the standalone: WordPress includes it
after the bundled copy has already loaded, so that re-declaration is a real fatal. WordPress catches
it in its activation sandbox, and this library rewrites the resulting error screen into an
explanation.

### Filters

| Filter | Arguments | Purpose |
|---|---|---|
| `{prefix}/plugin_absorber/conflict_policy` | `string $policy`, `Sub_Plugin $sub_plugin` | Final say over the conflict policy, after the config value and any callable. |

## License

This program is free software; you can redistribute it and/or modify it under the terms of the
Expand Down
43 changes: 42 additions & 1 deletion docs/superpowers/plans/2026-07-31-plugin-absorber.md
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,39 @@ git checkout 06-conflict-policy && git checkout -b 07-sub-plugin

`wp-admin/includes/plugin.php` is required in `setUp()` because uopz cannot stub a function that does not yet exist.

> **Deviations, deliberate (added 2026-08-03, from the PR 7 review):**
>
> 1. **`is_callable()` is no longer the type discriminator.** It returns true for *any* string
> naming an existing function, and `conflict_policy` is explicitly designed to be readable from
> an option. A stored value of `date` or `flush` would have been invoked rather than used —
> a `TypeError` at `plugins_loaded` on PHP 8, a silent `''` on 7.4. Strings and bools are now
> always values; only real callables are called.
> 2. **The constructor type-checks.** Required keys must be non-empty *strings*: an array survived
> the old `empty()` check and then cast to `"Array"`, which every misconfigured sub-plugin would
> have collided on as its registry key, activation key, and notice id. `dependency_check` and
> `activation_callback` must be callable when present — `is_callable()` at read time conflated
> "not configured" with "configured but uncallable", so a `dependency_check` pointing at a
> private method reported dependencies *met* and let the load proceed into the fatal it exists
> to prevent.
> 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.
> 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.
> 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
> be a fatal on cast; `''` is simply not a valid policy and routes to the conservative branch.

> **CORRECTION (2026-08-03, hit while implementing):** the fixture helper below is named `make()`,
> which collides with `Codeception\Test\Unit::make()` — a public method on `WPTestCase`'s ancestor.
> Declaring it `private` is a fatal at class-compile time: *"Access level to
> SubPluginTest::make() must be public"*. The suite does not fail, it fails to start. Renamed to
> `make_sub_plugin()` throughout. Any later task adding a fixture helper must avoid Codeception's
> own `Unit` API — `make`, `makeEmpty`, `construct`, and `constructEmpty` are all taken.

```php
<?php
/**
Expand Down Expand Up @@ -4942,7 +4975,15 @@ Expected: FAIL — `Call to undefined method Nexcess\PluginAbsorber\Notices::fil
return $markup;
}

$message = $sub_plugin->get_conflict_notice_message();
// The default matters: without one, a host that never configured a message gets
// WordPress's raw "triggered a fatal error" screen -- the exact outcome this rewrite
// exists to prevent -- and the rewrite would silently do nothing.
$message = $sub_plugin->get_conflict_notice_message(
sprintf(
'%s is bundled with this plugin and loads automatically. The standalone copy cannot be activated alongside it.',
$sub_plugin->get_slug()
)
);

if ( $message === '' ) {
return $markup;
Expand Down
Loading
Loading