refactor: ReleaseGate::invalidate() als einzige Stelle zum Zurücksetzen des Freigabe-Zustands; CompatibilityMatrix::locked() und ReleaseGate::lockOf() ersetzen die drei Kopien der Sperrprüfung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 22:57:35 +02:00
co-authored by Claude Opus 5.5
parent 7f9a087b8b
commit 63389f35a8
12 changed files with 129 additions and 68 deletions
+2 -2
View File
@@ -160,7 +160,7 @@ final class DiviTab implements AdminTab
} }
($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id); ($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id);
ReleaseGate::forget(); ReleaseGate::invalidate();
$this->log?->record( $this->log?->record(
'lit/divi-release', 'lit/divi-release',
'divi', 'divi',
@@ -183,7 +183,7 @@ final class DiviTab implements AdminTab
return; return;
} }
ReleaseGate::forget(); ReleaseGate::invalidate();
$this->log?->record( $this->log?->record(
'lit/divi-release', 'lit/divi-release',
'divi', 'divi',
@@ -15,7 +15,6 @@ use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\DiviReference; use LIT\MCP\Integrations\Divi\Release\DiviReference;
use LIT\MCP\Integrations\Divi\Release\LineDiff; use LIT\MCP\Integrations\Divi\Release\LineDiff;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\SourceFingerprint; use LIT\MCP\Integrations\Divi\Release\SourceFingerprint;
use RuntimeException; use RuntimeException;
@@ -173,8 +172,7 @@ final class DiviCheckCommand
return ($this->matrix)(); return ($this->matrix)();
} }
delete_transient(ReleaseResolver::CACHE); ReleaseGate::invalidate(mirrors: true);
ReleaseGate::forget();
return ReleaseGate::status()->matrix; return ReleaseGate::status()->matrix;
} }
@@ -279,8 +277,7 @@ final class DiviCheckCommand
return 1; return 1;
} }
delete_transient(ReleaseResolver::CACHE); ReleaseGate::invalidate(mirrors: true);
ReleaseGate::forget();
($this->out)(sprintf( ($this->out)(sprintf(
'Referenz aktualisiert (%d Fingerabdrücke, %d Signaturen). Smoke-Skripte laufen lassen, dann committen.', 'Referenz aktualisiert (%d Fingerabdrücke, %d Signaturen). Smoke-Skripte laufen lassen, dann committen.',
count($prints), count($prints),
+2 -2
View File
@@ -55,8 +55,8 @@ use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview; use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
@@ -212,7 +212,7 @@ final class DiviIntegration implements Integration, Bootable
{ {
// Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too). // Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too).
add_action('upgrader_process_complete', static function (): void { add_action('upgrader_process_complete', static function (): void {
delete_transient(ReleaseResolver::CACHE); ReleaseGate::invalidate(mirrors: true);
}); });
// Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no // Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no
@@ -11,7 +11,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
@@ -81,7 +81,7 @@ final class DiviContentComposer implements ContentComposer
public function refusal(): ?WP_Error public function refusal(): ?WP_Error
{ {
return DiviFeatureGate::lockFor(self::FEATURES)?->toError(); return ReleaseGate::lockOf(self::FEATURES)?->toError();
} }
public function check(string $postType, array $sections): ?WP_Error public function check(string $postType, array $sections): ?WP_Error
@@ -76,6 +76,27 @@ final class CompatibilityMatrix
return true; return true;
} }
/**
* The statuses of those of $features that are not usable – each once, in the order of $features. The one answer
* to "what locks this call" for the gate (DiviFeatureGate), the adapters (ReleaseGate::assertFeatures()) and the
* refusal (ReleaseStatus::refusalFor()).
*
* @param list<DiviFeature> $features
* @return list<FeatureStatus>
*/
public function locked(array $features): array
{
$locked = [];
foreach ($features as $feature) {
$status = $this->of($feature);
if (! $status->usable()) {
$locked[$feature->value] = $status;
}
}
return array_values($locked);
}
/** @return list<FeatureStatus> */ /** @return list<FeatureStatus> */
public function unusable(): array public function unusable(): array
{ {
@@ -63,7 +63,7 @@ final class DiviCheckRoute
try { try {
$report = ($this->check)()->run(get_current_user_id()); $report = ($this->check)()->run(get_current_user_id());
($this->reports ?? new CheckReportStore())->save($report); ($this->reports ?? new CheckReportStore())->save($report);
ReleaseGate::forget(); ReleaseGate::invalidate();
$notice = 'divi_checked'; $notice = 'divi_checked';
} catch (Throwable $e) { } catch (Throwable $e) {
Log::failure('divi check', $e); Log::failure('divi check', $e);
@@ -25,27 +25,9 @@ final class DiviFeatureGate implements AbilityGuard
{ {
} }
/**
* The lock for the usable-ness of $features, or null when every one is usable. Public: the Divi short form of
* lit/content-create asks the same question for its own capabilities (DiviContentComposer::refusal()).
*
* @param list<DiviFeature> $features
*/
public static function lockFor(array $features): ?DiviLocked
{
$unusable = [];
foreach ($features as $feature) {
if (! ReleaseGate::feature($feature)->usable()) {
$unusable[$feature->value] = $feature;
}
}
return $unusable === [] ? null : new DiviLocked(ReleaseGate::status(), array_values($unusable));
}
public function refusal(Ability $ability, array $input): ?WP_Error public function refusal(Ability $ability, array $input): ?WP_Error
{ {
$lock = self::lockFor(FeatureSets::declaredBy($ability, $input)); $lock = ReleaseGate::lockOf(FeatureSets::declaredBy($ability, $input));
if ($lock === null) { if ($lock === null) {
return null; return null;
} }
+34 -12
View File
@@ -51,15 +51,25 @@ final class ReleaseGate
self::$probe = null; self::$probe = null;
} }
/** The next status() computes again (after a check, a release or a withdrawal). */ /** The next status() of this request computes again; the cached mirror results stay (see invalidate()). */
public static function forget(): void public static function forget(): void
{ {
self::$status = null; self::$status = null;
} }
public static function feature(DiviFeature $feature): FeatureStatus /**
* The single way to make the release state current again: forgets the status of this request and, with
* $mirrors, also deletes the cached mirror results (transient ReleaseResolver::CACHE) so the next status()
* fingerprints Divi's code again. Without $mirrors: after a release or a withdrawal and after the admin check
* (only the source of the state changed). With $mirrors: after a theme update, a CLI check or a new reference
* (the code or the reference may have changed).
*/
public static function invalidate(bool $mirrors = false): void
{ {
return self::status()->matrix->of($feature); if ($mirrors) {
delete_transient(ReleaseResolver::CACHE);
}
self::forget();
} }
/** @throws DiviLocked When $feature is not usable – before anything is written. */ /** @throws DiviLocked When $feature is not usable – before anything is written. */
@@ -80,16 +90,28 @@ final class ReleaseGate
return; return;
} }
$lock = self::lockOf($features);
if ($lock !== null) {
throw $lock;
}
}
/**
* The lock of this request's status for the locked ones of $features, or null when every one is usable. Asked by
* DiviFeatureGate before a tool runs and by the Divi short form of lit/content-create (DiviContentComposer::
* refusal()); no probe exception – tool calls never write the probe page.
*
* @param list<DiviFeature> $features
*/
public static function lockOf(array $features): ?DiviLocked
{
$status = self::status(); $status = self::status();
$locked = []; $locked = $status->matrix->locked($features);
foreach ($features as $feature) {
if (! $status->matrix->of($feature)->usable()) { return $locked === [] ? null : new DiviLocked(
$locked[$feature->value] = $feature; $status,
} array_map(static fn (FeatureStatus $feature): DiviFeature => $feature->feature, $locked)
} );
if ($locked !== []) {
throw new DiviLocked($status, array_values($locked));
}
} }
public static function whileProbing(int $postId, callable $write): mixed public static function whileProbing(int $postId, callable $write): mixed
@@ -77,18 +77,11 @@ final class ReleaseStatus
*/ */
public function refusalFor(array $features): WP_Error public function refusalFor(array $features): WP_Error
{ {
$locked = []; $locked = $this->matrix->locked($features);
foreach ($features as $feature) {
$status = $this->matrix->of($feature);
if (! $status->usable()) {
$locked[$feature->value] = $status;
}
}
if ($locked === []) { if ($locked === []) {
throw new LogicException('usable features refuse nothing'); throw new LogicException('usable features refuse nothing');
} }
$locked = array_values($locked);
$incompatible = array_values(array_filter( $incompatible = array_values(array_filter(
$locked, $locked,
static fn (FeatureStatus $status): bool => $status->state === ReleaseState::Incompatible static fn (FeatureStatus $status): bool => $status->state === ReleaseState::Incompatible
@@ -8,6 +8,7 @@ use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureStatus; use LIT\MCP\Integrations\Divi\Release\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseState; use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class CompatibilityMatrixTest extends TestCase final class CompatibilityMatrixTest extends TestCase
@@ -84,6 +85,29 @@ final class CompatibilityMatrixTest extends TestCase
); );
} }
public function testLockedNamesEveryUnusableFeatureOnceInTheGivenOrder(): void
{
$matrix = ReleaseMatrices::matrix(
[DiviFeature::LibraryWrite, DiviFeature::ThemeBuilderWrite, DiviFeature::Catalog],
ReleaseState::Incompatible
);
$locked = $matrix->locked([
DiviFeature::PageWrite,
DiviFeature::ThemeBuilderWrite,
DiviFeature::LibraryWrite,
DiviFeature::ThemeBuilderWrite,
]);
self::assertSame(
[DiviFeature::ThemeBuilderWrite, DiviFeature::LibraryWrite],
array_map(static fn (FeatureStatus $status): DiviFeature => $status->feature, $locked)
);
self::assertSame(ReleaseState::Incompatible, $locked[0]->state);
self::assertSame([], $matrix->locked([DiviFeature::PageWrite, DiviFeature::PageRead]));
self::assertSame([], $matrix->locked([]));
}
public function testToArrayListsEveryFeatureInCaseOrder(): void public function testToArrayListsEveryFeatureInCaseOrder(): void
{ {
$rows = self::matrix(['tb-write' => [ReleaseState::Incompatible, ['b']]])->toArray(); $rows = self::matrix(['tb-write' => [ReleaseState::Incompatible, ['b']]])->toArray();
@@ -317,19 +317,6 @@ final class DiviFeatureGateTest extends TestCase
); );
} }
public function testLockForIsNullForUsableFeaturesAndNamesTheUnusableOnesOtherwise(): void
{
self::assertNull(DiviFeatureGate::lockFor([]));
self::assertNull(DiviFeatureGate::lockFor([DiviFeature::PageWrite]));
DiviFeatureFixtures::lock(DiviFeature::Catalog);
$lock = DiviFeatureGate::lockFor([DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::Catalog]);
self::assertNotNull($lock);
self::assertSame([DiviFeature::Catalog], $lock->features);
self::assertSame('5.3.0', $lock->status->version);
}
/** /**
* The real tools of the three Divi groups: locking one capability refuses exactly the tools that declare it * The real tools of the three Divi groups: locking one capability refuses exactly the tools that declare it
* (reading and writing alike) and no other. * (reading and writing alike) and no other.
@@ -4,12 +4,15 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseState; use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\DiviFeatureFixtures;
use LIT\MCP\Tests\Support\ReleaseMatrices; use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException; use RuntimeException;
@@ -27,12 +30,17 @@ final class ReleaseGateTest extends TestCase
ReleaseGate::assertFeatures(DiviFeature::cases(), 7); ReleaseGate::assertFeatures(DiviFeature::cases(), 7);
} }
public function testFeatureReadsTheMatrixOfThisRequest(): void public function testLockOfIsNullForUsableFeaturesAndNamesTheUnusableOnesOtherwise(): void
{ {
ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::Catalog])); self::assertNull(ReleaseGate::lockOf([]));
self::assertNull(ReleaseGate::lockOf([DiviFeature::PageWrite]));
self::assertSame(ReleaseState::Untested, ReleaseGate::feature(DiviFeature::Catalog)->state); DiviFeatureFixtures::lock(DiviFeature::Catalog);
self::assertTrue(ReleaseGate::feature(DiviFeature::PageRead)->usable()); $lock = ReleaseGate::lockOf([DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::Catalog]);
self::assertNotNull($lock);
self::assertSame([DiviFeature::Catalog], $lock->features);
self::assertSame('5.3.0', $lock->status->version);
} }
public function testAssertFeatureRefusesOnlyALockedFeature(): void public function testAssertFeatureRefusesOnlyALockedFeature(): void
@@ -149,6 +157,33 @@ final class ReleaseGateTest extends TestCase
self::assertSame(2, $calls); self::assertSame(2, $calls);
} }
public function testInvalidateForgetsTheStatusAndClearsTheMirrorCacheOnlyWhenAsked(): void
{
$calls = 0;
$deleted = [];
ReleaseGate::useResolver(static function () use (&$calls): ReleaseStatus {
++$calls;
return ReleaseMatrices::released('5.2.1');
});
Functions\when('delete_transient')->alias(static function (string $key) use (&$deleted): bool {
$deleted[] = $key;
return true;
});
ReleaseGate::status();
ReleaseGate::invalidate();
ReleaseGate::status();
self::assertSame(2, $calls);
self::assertSame([], $deleted, 'a release or withdrawal leaves the mirror results alone');
ReleaseGate::invalidate(mirrors: true);
ReleaseGate::status();
self::assertSame(3, $calls);
self::assertSame([ReleaseResolver::CACHE], $deleted);
}
public function testAFailingResolverLocksWrites(): void public function testAFailingResolverLocksWrites(): void
{ {
ReleaseGate::useResolver(static function (): never { ReleaseGate::useResolver(static function (): never {