Skip to content
Merged
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ Config::set_container( give()->container ); // optional — lets you rebind coll
The hook prefix accepts letters, numbers, hyphens, and underscores. Anything else throws
`Config_Exception`, as does reading it before it is set.

### Conflict policies

When a sub-plugin's standalone counterpart is still active:

| Policy | Behavior |
|---|---|
| `Conflict_Policy::DEACTIVATE` | Deactivate the standalone, notify, and redirect; the bundled copy loads on the next request. **Default.** |
| `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. |

## License

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

The values are asserted literally because they are a public contract — a host may store one in an option, and changing a value later would silently break it.

> **Deviation, deliberate (added 2026-08-03, from PR 6 review):** the class also ships
> `is_valid( string ): bool`, over a private `all(): string[]`. Without it nothing rejects an unknown policy:
> `Sub_Plugin::get_conflict_policy()` returns whatever the config or the filter hands back, and
> `Conflict\Resolver::resolve()` switches on it with `default:` falling into `deactivate()`. A typo
> like `'defered'`, or a stale filter return, would therefore deactivate a plugin the site owner
> deliberately turned on — the most surprising and least recoverable of the three outcomes, reached
> by accident. Task 12 must call `is_valid()` and treat an unknown policy as its own case rather
> than relying on the fallthrough. The reflection test pins the constant set so a fourth policy
> cannot be added without that switch being revisited, and the valid-policy provider reads the
> constants too, so a policy declared but never taught to `is_valid()` fails as well.
>
> `all()` is private: nothing in this plan reads the set, only `is_valid()` does. Widen it if a
> host ever needs to enumerate the policies.

```php
<?php
/**
Expand Down Expand Up @@ -4116,7 +4130,16 @@ class Resolver implements Resolver_Interface {
* @return void
*/
protected function resolve( Sub_Plugin $sub_plugin ): void {
switch ( $sub_plugin->get_conflict_policy() ) {
$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;
Expand Down
76 changes: 76 additions & 0 deletions src/Conflict_Policy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php
/**
* @package Nexcess\PluginAbsorber
*/

namespace Nexcess\PluginAbsorber;

/**
* What to do when a sub-plugin's standalone counterpart is still active.
*
* @since 1.0.0
*/
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
*/
public const DEACTIVATE = 'deactivate';

/**
* Leave the standalone alone and let it win. The load guard stands the bundled copy down.
*
* @since 1.0.0
*
* @var string
*/
public const DEFER = 'defer';

/**
* Leave the standalone active but ask the user to deactivate it.
*
* @since 1.0.0
*
* @var string
*/
public const NOTICE_ONLY = 'notice_only';

/**
* Whether a policy string is one this library understands.
*
* Hosts may persist a policy in an option and filters may return anything, so callers that
* dispatch on a policy should reject unknown values here rather than letting them fall
* through to a default branch — deactivating a plugin the site owner deliberately turned on
* is the most surprising of the three outcomes to arrive at by accident.
*
* @since 1.0.0
*
* @param string $policy Policy to check.
*
* @return bool
*/
public static function is_valid( string $policy ): bool {
return in_array( $policy, self::all(), true );
}

/**
* Every policy this library understands.
*
* @since 1.0.0
*
* @return string[]
*/
private static function all(): array {
return [
self::DEACTIVATE,
self::DEFER,
self::NOTICE_ONLY,
];
}
}
81 changes: 81 additions & 0 deletions tests/unit/ConflictPolicyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php
/**
* @package Nexcess\PluginAbsorber
*/

namespace Nexcess\PluginAbsorber\Tests\Unit;

use Codeception\TestCase\WPTestCase;
use Generator;
use Nexcess\PluginAbsorber\Conflict_Policy;
use ReflectionClass;

/**
* @since 1.0.0
*/
class ConflictPolicyTest extends WPTestCase {
public function test_the_policy_values_are_stable(): void {
$this->assertSame( 'deactivate', Conflict_Policy::DEACTIVATE );
$this->assertSame( 'defer', Conflict_Policy::DEFER );
$this->assertSame( 'notice_only', Conflict_Policy::NOTICE_ONLY );
}

/**
* Pins the whole set, not just the three names. A fourth policy added without teaching
* the resolver about it would otherwise be swallowed by that switch's default branch.
*/
public function test_no_policy_is_added_or_removed_unnoticed(): void {
$constants = ( new ReflectionClass( Conflict_Policy::class ) )->getConstants();

$this->assertSame(
[
'DEACTIVATE' => 'deactivate',
'DEFER' => 'defer',
'NOTICE_ONLY' => 'notice_only',
],
$constants
);
}

/**
* @dataProvider valid_policies
*
* @param string $policy Policy under test.
*/
public function test_it_accepts_a_known_policy( string $policy ): void {
$this->assertTrue( Conflict_Policy::is_valid( $policy ) );
}

/**
* Drawn from the constants rather than a hand-written list, so a fourth policy declared
* without being added to the set behind is_valid() fails here instead of passing unnoticed.
*
* @return Generator<string,array{0:string}>
*/
public static function valid_policies(): Generator {
$constants = ( new ReflectionClass( Conflict_Policy::class ) )->getConstants();

foreach ( $constants as $name => $value ) {
yield $name => [ (string) $value ];
}
}
Comment on lines +49 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generator would be better :)


/**
* @dataProvider invalid_policies
*
* @param string $policy Policy under test.
*/
public function test_it_rejects_an_unknown_policy( string $policy ): void {
$this->assertFalse( Conflict_Policy::is_valid( $policy ) );
}

/**
* @return Generator<string,array{0:string}>
*/
public static function invalid_policies(): Generator {
yield 'typo' => [ 'defered' ];
yield 'empty' => [ '' ];
yield 'wrong case' => [ 'DEACTIVATE' ];
yield 'constant' => [ 'Conflict_Policy::DEFER' ];
}
Comment on lines +72 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generator would be better :)

}
Loading