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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,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
21 changes: 20 additions & 1 deletion docs/superpowers/plans/2026-07-31-plugin-absorber.md
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,16 @@ 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
> `all(): string[]` and `is_valid( string ): bool`. Without them 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.

```php
<?php
/**
Expand Down Expand Up @@ -4097,7 +4107,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';

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

namespace Nexcess\PluginAbsorber\Tests\Unit;

use Codeception\TestCase\WPTestCase;
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
);
}

public function test_all_returns_every_policy(): void {
$this->assertSame(
[ 'deactivate', 'defer', 'notice_only' ],
Conflict_Policy::all()
);
}

/**
* @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 ) );
}

/**
* @return array<string,array{0:string}>
*/
public function valid_policies(): array {
return [
'deactivate' => [ Conflict_Policy::DEACTIVATE ],
'defer' => [ Conflict_Policy::DEFER ],
'notice_only' => [ Conflict_Policy::NOTICE_ONLY ],
];
}

/**
* @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 array<string,array{0:string}>
*/
public function invalid_policies(): array {
return [
'typo' => [ 'defered' ],
'empty' => [ '' ],
'wrong case' => [ 'DEACTIVATE' ],
'constant' => [ 'Conflict_Policy::DEFER' ],
];
}
}
Loading