diff --git a/CLAUDE.md b/CLAUDE.md index 96b48be..3c3e58a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ seams a host may rebind: | `Notices\Contracts\Writer_Interface` | `Notices\Writer` | what each notice says | | `Conflict\Contracts\Resolver_Interface` | `Conflict\Resolver` | one method: which policy branch a conflict takes | | `Plugin\Contracts\Deactivator_Interface` | `Plugin\Deactivator` | deactivates the standalone, network-aware | -| `Plugin\Contracts\Checker_Interface` | `Plugin\Checker` | answers whether a plugin is active | +| `Plugin\Contracts\Checker_Interface` | `Plugin\Checker` | answers whether a plugin is active (either scope), and whether it is network-active | | `Contracts\Activator_Interface` | `Activator` | run-once activation-callback tracking | The rest — `Boot\Scheduler`, `Loader`, `Registry\Reader`, `Conflict\Detector`, `Conflict\Gatekeeper`, @@ -195,7 +195,7 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`. | `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` (turns the standalone off), `Checker` (answers whether a plugin is active), `Loads_Plugin_Functions` (pulls in `wp-admin/includes/plugin.php`), `Contracts\Deactivator_Interface`, `Contracts\Checker_Interface`. The only files that touch WordPress plugin functions. | +| `src/Plugin/` | `Deactivator` (turns the standalone off), `Checker` (answers whether a plugin is active in either scope, and whether it is network-active — `is_plugin_active_for_network()`, which is `false` off a network so no caller needs an `is_multisite()` guard), `Loads_Plugin_Functions` (pulls in `wp-admin/includes/plugin.php`), `Contracts\Deactivator_Interface`, `Contracts\Checker_Interface`. The only files that touch WordPress plugin functions. | | `src/Registry/` | `Registrar` (holds registered `Sub_Plugin` objects), `Reader` (the registration buffer, drained into the registrar on the way past; the object every pass reads the registry through), `Contracts\Registrar_Interface`. | | `src/Activator.php` | Runs a sub-plugin's activation callback once ever, recorded in one option. | | `src/Conflict/` | `Detector` (whether a standalone is in the way), `Resolver` (which policy branch to take), `Gatekeeper` (which requests, and which users, may have one resolved), `Redirector` (where the user lands afterwards), `Rewriter` (rewrites the activation-error screen for a registered standalone), `Contracts\Resolver_Interface`. | diff --git a/src/Plugin/Checker.php b/src/Plugin/Checker.php index 0ea75ac..db136f8 100644 --- a/src/Plugin/Checker.php +++ b/src/Plugin/Checker.php @@ -32,4 +32,19 @@ public function is_active( string $basename ): bool { // sub-plugin per request. return is_plugin_active( $basename ); } + + /** + * @since 1.0.0 + * + * @param string $basename Plugin basename. + * + * @return bool + */ + public function is_network_active( string $basename ): bool { + $this->load_plugin_functions(); + + // is_plugin_active_for_network() checks is_multisite() itself and returns false off a + // network, so the stranding guard that reads this needs no is_multisite() test of its own. + return is_plugin_active_for_network( $basename ); + } } diff --git a/src/Plugin/Contracts/Checker_Interface.php b/src/Plugin/Contracts/Checker_Interface.php index 7a7c5b1..14ce389 100644 --- a/src/Plugin/Contracts/Checker_Interface.php +++ b/src/Plugin/Contracts/Checker_Interface.php @@ -36,4 +36,22 @@ interface Checker_Interface { * @return bool */ public function is_active( string $basename ): bool; + + /** + * Whether the plugin is active across the whole network. + * + * Network scope only, unlike `is_active()`, which counts either scope. The two are asked for + * different reasons: `is_active()` answers "the standalone's code is going to run this request", + * while this answers "deactivating it reaches every site" -- the question the conflict resolver's + * stranding guard turns on. Returns `false` whenever the site is not multisite, because + * WordPress's own `is_plugin_active_for_network()` does, so a caller needs no `is_multisite()` + * guard of its own and the stranding guard is a no-op off a network. + * + * @since 1.0.0 + * + * @param string $basename Plugin basename. + * + * @return bool + */ + public function is_network_active( string $basename ): bool; } diff --git a/tests/unit/Boot/SchedulerTest.php b/tests/unit/Boot/SchedulerTest.php index 3ec0821..a6274cf 100644 --- a/tests/unit/Boot/SchedulerTest.php +++ b/tests/unit/Boot/SchedulerTest.php @@ -1298,6 +1298,15 @@ static function (): Checker_Interface { public function is_active( string $basename ): bool { return true; } + + /** + * @param string $basename Plugin basename. + * + * @return bool + */ + public function is_network_active( string $basename ): bool { + return false; + } }; } ); diff --git a/tests/unit/Conflict/DetectorTest.php b/tests/unit/Conflict/DetectorTest.php index 8d8fc45..fa9841d 100644 --- a/tests/unit/Conflict/DetectorTest.php +++ b/tests/unit/Conflict/DetectorTest.php @@ -481,6 +481,15 @@ public function is_active( string $basename ): bool { return $this->answer; } + + /** + * @param string $basename Plugin basename. + * + * @return bool + */ + public function is_network_active( string $basename ): bool { + return false; + } }; } @@ -531,6 +540,15 @@ public function is_active( string $basename ): bool { return in_array( $basename, $this->active, true ); } + + /** + * @param string $basename Plugin basename. + * + * @return bool + */ + public function is_network_active( string $basename ): bool { + return false; + } }; } diff --git a/tests/unit/Plugin/CheckerTest.php b/tests/unit/Plugin/CheckerTest.php index 815131a..77d738d 100644 --- a/tests/unit/Plugin/CheckerTest.php +++ b/tests/unit/Plugin/CheckerTest.php @@ -128,6 +128,59 @@ static function () { $this->assertSame( 1, $calls ); } + public function test_it_reports_a_network_active_plugin(): void { + $this->setFunctionReturn( 'is_plugin_active_for_network', true ); + + $this->assertTrue( $this->checker->is_network_active( 'give-recurring/give-recurring.php' ) ); + } + + public function test_it_reports_a_plugin_that_is_not_network_active(): void { + $this->setFunctionReturn( 'is_plugin_active_for_network', false ); + + $this->assertFalse( $this->checker->is_network_active( 'give-recurring/give-recurring.php' ) ); + } + + /** + * The basename reaches the stranding guard's comparison next, so asserting only the return value + * would let the wrong plugin decide whether a network-wide deactivation is safe. + */ + public function test_it_passes_the_basename_through_to_the_network_check(): void { + $received = null; + + $this->setFunctionReturn( + 'is_plugin_active_for_network', + static function ( $basename ) use ( &$received ) { + $received = $basename; + + return true; + }, + true + ); + + $this->checker->is_network_active( 'give-recurring/give-recurring.php' ); + + $this->assertSame( 'give-recurring/give-recurring.php', $received ); + } + + /** + * The mirror of the single-scope check: is_network_active() asks the network function and only + * that one. is_plugin_active() ORs the network check into its answer, so reaching for it here + * would report a merely site-active standalone as network-active and wave through a network-wide + * deactivation the stranding guard exists to refuse. + */ + public function test_the_network_check_asks_only_the_network_function(): void { + $this->setFunctionReturn( + 'is_plugin_active', + static function () { + throw new LogicException( 'is_network_active() must not ask the site-scope function.' ); + }, + true + ); + $this->setFunctionReturn( 'is_plugin_active_for_network', true ); + + $this->assertTrue( $this->checker->is_network_active( 'give-recurring/give-recurring.php' ) ); + } + /** * `Plugin\Loads_Plugin_Functions` guards on `deactivate_plugins()`, and it has to keep doing so. * `is_plugin_active()` is a common third-party shim: guarded on that name, something else defining diff --git a/tests/unit/Scenario/HostTest.php b/tests/unit/Scenario/HostTest.php index 56a24e4..3aac440 100644 --- a/tests/unit/Scenario/HostTest.php +++ b/tests/unit/Scenario/HostTest.php @@ -106,6 +106,15 @@ public function is_active( string $basename ): bool { return in_array( $basename, $this->active, true ); } + + /** + * @param string $basename Plugin basename. + * + * @return bool + */ + public function is_network_active( string $basename ): bool { + return false; + } }; $deactivator = new class() implements Deactivator_Interface {