diff --git a/src/Integrations/Divi/Admin/DiviTab.php b/src/Integrations/Divi/Admin/DiviTab.php index c785718..efe5536 100644 --- a/src/Integrations/Divi/Admin/DiviTab.php +++ b/src/Integrations/Divi/Admin/DiviTab.php @@ -160,7 +160,7 @@ final class DiviTab implements AdminTab } ($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id); - ReleaseGate::forget(); + ReleaseGate::invalidate(); $this->log?->record( 'lit/divi-release', 'divi', @@ -183,7 +183,7 @@ final class DiviTab implements AdminTab return; } - ReleaseGate::forget(); + ReleaseGate::invalidate(); $this->log?->record( 'lit/divi-release', 'divi', diff --git a/src/Integrations/Divi/Cli/DiviCheckCommand.php b/src/Integrations/Divi/Cli/DiviCheckCommand.php index 6f0774a..405e296 100644 --- a/src/Integrations/Divi/Cli/DiviCheckCommand.php +++ b/src/Integrations/Divi/Cli/DiviCheckCommand.php @@ -15,7 +15,6 @@ use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; use LIT\MCP\Integrations\Divi\Release\DiviReference; use LIT\MCP\Integrations\Divi\Release\LineDiff; use LIT\MCP\Integrations\Divi\Release\ReleaseGate; -use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Divi\Release\SourceFingerprint; use RuntimeException; @@ -173,8 +172,7 @@ final class DiviCheckCommand return ($this->matrix)(); } - delete_transient(ReleaseResolver::CACHE); - ReleaseGate::forget(); + ReleaseGate::invalidate(mirrors: true); return ReleaseGate::status()->matrix; } @@ -279,8 +277,7 @@ final class DiviCheckCommand return 1; } - delete_transient(ReleaseResolver::CACHE); - ReleaseGate::forget(); + ReleaseGate::invalidate(mirrors: true); ($this->out)(sprintf( 'Referenz aktualisiert (%d Fingerabdrücke, %d Signaturen). Smoke-Skripte laufen lassen, dann committen.', count($prints), diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index e80e35b..cea55e0 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -55,8 +55,8 @@ use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; 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\ReleaseGate; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; 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). 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 diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php index 13b7d67..b03868a 100644 --- a/src/Integrations/Divi/Pages/DiviContentComposer.php +++ b/src/Integrations/Divi/Pages/DiviContentComposer.php @@ -11,7 +11,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; 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\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -81,7 +81,7 @@ final class DiviContentComposer implements ContentComposer 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 diff --git a/src/Integrations/Divi/Release/CompatibilityMatrix.php b/src/Integrations/Divi/Release/CompatibilityMatrix.php index 893a4a7..4641243 100644 --- a/src/Integrations/Divi/Release/CompatibilityMatrix.php +++ b/src/Integrations/Divi/Release/CompatibilityMatrix.php @@ -76,6 +76,27 @@ final class CompatibilityMatrix 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 $features + * @return list + */ + 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 */ public function unusable(): array { diff --git a/src/Integrations/Divi/Release/DiviCheckRoute.php b/src/Integrations/Divi/Release/DiviCheckRoute.php index 71433f7..17513ac 100644 --- a/src/Integrations/Divi/Release/DiviCheckRoute.php +++ b/src/Integrations/Divi/Release/DiviCheckRoute.php @@ -63,7 +63,7 @@ final class DiviCheckRoute try { $report = ($this->check)()->run(get_current_user_id()); ($this->reports ?? new CheckReportStore())->save($report); - ReleaseGate::forget(); + ReleaseGate::invalidate(); $notice = 'divi_checked'; } catch (Throwable $e) { Log::failure('divi check', $e); diff --git a/src/Integrations/Divi/Release/DiviFeatureGate.php b/src/Integrations/Divi/Release/DiviFeatureGate.php index fb7483a..6620a80 100644 --- a/src/Integrations/Divi/Release/DiviFeatureGate.php +++ b/src/Integrations/Divi/Release/DiviFeatureGate.php @@ -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 $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 { - $lock = self::lockFor(FeatureSets::declaredBy($ability, $input)); + $lock = ReleaseGate::lockOf(FeatureSets::declaredBy($ability, $input)); if ($lock === null) { return null; } diff --git a/src/Integrations/Divi/Release/ReleaseGate.php b/src/Integrations/Divi/Release/ReleaseGate.php index 715ec62..14a276b 100644 --- a/src/Integrations/Divi/Release/ReleaseGate.php +++ b/src/Integrations/Divi/Release/ReleaseGate.php @@ -51,15 +51,25 @@ final class ReleaseGate 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 { 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. */ @@ -80,16 +90,28 @@ final class ReleaseGate 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 $features + */ + public static function lockOf(array $features): ?DiviLocked + { $status = self::status(); - $locked = []; - foreach ($features as $feature) { - if (! $status->matrix->of($feature)->usable()) { - $locked[$feature->value] = $feature; - } - } - if ($locked !== []) { - throw new DiviLocked($status, array_values($locked)); - } + $locked = $status->matrix->locked($features); + + return $locked === [] ? null : new DiviLocked( + $status, + array_map(static fn (FeatureStatus $feature): DiviFeature => $feature->feature, $locked) + ); } public static function whileProbing(int $postId, callable $write): mixed diff --git a/src/Integrations/Divi/Release/ReleaseStatus.php b/src/Integrations/Divi/Release/ReleaseStatus.php index 5186db1..86f0ebe 100644 --- a/src/Integrations/Divi/Release/ReleaseStatus.php +++ b/src/Integrations/Divi/Release/ReleaseStatus.php @@ -77,18 +77,11 @@ final class ReleaseStatus */ public function refusalFor(array $features): WP_Error { - $locked = []; - foreach ($features as $feature) { - $status = $this->matrix->of($feature); - if (! $status->usable()) { - $locked[$feature->value] = $status; - } - } + $locked = $this->matrix->locked($features); if ($locked === []) { throw new LogicException('usable features refuse nothing'); } - $locked = array_values($locked); $incompatible = array_values(array_filter( $locked, static fn (FeatureStatus $status): bool => $status->state === ReleaseState::Incompatible diff --git a/tests/Unit/Integrations/Divi/Release/CompatibilityMatrixTest.php b/tests/Unit/Integrations/Divi/Release/CompatibilityMatrixTest.php index 43508be..a622402 100644 --- a/tests/Unit/Integrations/Divi/Release/CompatibilityMatrixTest.php +++ b/tests/Unit/Integrations/Divi/Release/CompatibilityMatrixTest.php @@ -8,6 +8,7 @@ use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\FeatureStatus; use LIT\MCP\Integrations\Divi\Release\ReleaseState; +use LIT\MCP\Tests\Support\ReleaseMatrices; use PHPUnit\Framework\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 { $rows = self::matrix(['tb-write' => [ReleaseState::Incompatible, ['b']]])->toArray(); diff --git a/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php b/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php index 8e66b36..676de7e 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php @@ -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 * (reading and writing alike) and no other. diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php index 71e7b8c..fe5e79e 100644 --- a/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php +++ b/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php @@ -4,12 +4,15 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; +use Brain\Monkey\Functions; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviLocked; 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\ReleaseStatus; +use LIT\MCP\Tests\Support\DiviFeatureFixtures; use LIT\MCP\Tests\Support\ReleaseMatrices; use LIT\MCP\Tests\Unit\TestCase; use RuntimeException; @@ -27,12 +30,17 @@ final class ReleaseGateTest extends TestCase 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); - self::assertTrue(ReleaseGate::feature(DiviFeature::PageRead)->usable()); + DiviFeatureFixtures::lock(DiviFeature::Catalog); + $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 @@ -149,6 +157,33 @@ final class ReleaseGateTest extends TestCase 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 { ReleaseGate::useResolver(static function (): never {