Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
276 KiB
Divi-Absicherung – Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Nur fünf Adapterbereiche sprechen Divi an, jeder mit Manifest und Fingerabdrücken der nachgebauten Divi-Funktionen; eine ungetestete oder veränderte Divi-Version sperrt alle schreibenden Divi-Wege mit klarer Antwort (divi_untested/divi_incompatible), lesende Divi-Tools liefern einen Hinweis; Freigabe über eine Versionsliste im Code oder – nach grüner Admin-Prüfung mit Wegwerfseite – über einen Datenbankeintrag; dieselbe Prüfung als wp lit-mcp divi-check mit --diff und --update-reference.
Architecture: Neuer Namensraum LIT\MCP\Integrations\Divi\Release: SourceFingerprint (Tokenizer, liest Divi-Dateien ohne sie zu laden), Manifest/Call/Mirror, DiviAdapter-Schnittstelle und Register DiviAdapters, DiviTouches (Scanner für den Architekturtest), ManifestCheck (Prüfungen aus den Manifesten), ReleaseResolver/ReleaseStatus/ReleaseGate (Zustand, Zwischenspeicher, Sperre), ReleaseStore (DB-Freigaben), CompatibilityCheck mit FormatCheck/WriteProbe/CheckReport (Admin-Prüfung), DiviToolGate (zentrale Sperre in AbilityRegistrar), DiviCheckRoute (REST-Route der Prüfung), Admin-Reiter DiviTab, WP-CLI DiviCheckCommand. Sieben Adapterklassen in fünf Bereichen: DiviPages (neu, ersetzt DiviFlags), DiviLibraryStore, DiviRoles, DiviThemeBuilder, DesignSystemReader, DiviModules, DynamicContentMap.
Tech Stack: PHP ≥ 8.1 (token_get_all, T_NAME_*-Tokens, Enums), WordPress 7.x (Abilities API, REST API mit Cookie-Nonce, Options, Transients, wp_delete_post), Divi 5.2.1, WP-CLI (WP_CLI::add_command), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq/curl-Smokes.
Spec: docs/superpowers/specs/2026-09-29-divi-absicherung-design.md (freigegeben 2026-09-30). Hintergrund: docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md (Divi-Speicherformate), tests/Fixtures/divi/stage3/README.md (Befunde, v. a. Nr. 6: Taxonomien nicht in jedem Kontext registriert).
Voraussetzung: Branch divi-absicherung ab main (903246d, 0.5.0) mit Spec-Commit 94b8c7b; composer test grün (2202 Tests). DDEV unter /home/janniclas/Projekte/WordPress mit aktivem Divi 5.2.1.
Entscheidungen dieses Plans
Die Spec überlässt die Klassenaufteilung dem Plan (§3). Diese Punkte präzisieren sie; wo der Plan von der Spec-Formulierung abweicht, steht es hier und wird dem Benutzer bei der Übergabe genannt.
- Sieben Adapterklassen, fünf Bereiche.
Manifest::$areaordnet jede Klasse einem der fünf Bereiche der Spec zu: Seiten =DiviPages(neu; übernimmtDiviFlags, CSS-Cache, Builder-Kennzeichen lesen, Builder-URL, Divis Link-Typen); Bibliothek =DiviLibraryStore(übernimmt die Meta-Schreibweise ausLibraryItemSpecund das Lesen von_et_pb_built_for_post_typeausNewLibraryEntry) +DiviRoles; Theme Builder =DiviThemeBuilder; Designsystem =DesignSystemReader(übernimmt das Divi-Logo fürMediaUsageFinder); Katalog =DiviModules(übernimmt Divis interne Post-Typen undet_builder_enabled_for_post_typefürContentSchema, den Block-Typ-Test ausDiviCompatibility) +DynamicContentMap.DiviSaver,LibraryItemSpec,NewLibraryEntry,WpLinkTargets,PostPresenter,ContentSchema,MediaUsageFinder,DiviPageundDiviCompatibilityberühren Divi danach nicht mehr selbst. - Fingerabdrücke per Tokenizer statt Reflection. Divi lädt
theme-builder/api.phpnur in admin-ajax (Etappe-3-Befund F20); Reflection fändeet_theme_builder_api_save()in MCP-Anfragen nicht.SourceFingerprintliest die Divi-Datei mittoken_get_all()und schneidet die Funktion bzw. Methode heraus. Gehasht werden die Tokens ohne Kommentare und Leerraum. - Nachgebaute Divi-Funktionen (Mirrors), fünf Stück:
et_theme_builder_api_save(Vorlagenliste, „ungenutzt“-Markierung),et_theme_builder_store_template(Bedingungen, Aktiv-Schalter),et_theme_builder_load_template_setting_options(Zerlegen der Bedingungs-IDs inTemplateConditions),et_pb_submit_layout(Terme und Meta neuer Bibliothekseinträge),SyncToServerController::update(Builder-Kennzeichen). Große Funktionen wieSyncToServerController::updatemelden auch Änderungen, die uns nicht betreffen – gewollt: lieber einmal zu oft prüfen als still falsch schreiben;--diffzeigt die Stelle. - Die Sperre zur Laufzeit prüft nur Code, also Funktionen und Methoden samt Argumentzahl, Konstanten und Fingerabdrücke. Registrierungen (Post-Typen, Taxonomien, Block-Typen) hängen vom Anfragekontext ab (Befund 6) und würden sonst fälschlich sperren. Sie stehen in
lit-site-statusund im Prüfbericht (Schritt „Manifeste“) und müssen für eine Freigabe grün sein. Präzisiert Spec §5.2 „Live-Prüfung“. - „Prüfung starten“ läuft über eine REST-Route (
POST /wp-json/lit-mcp/v1/divi-check, Cookie-Anmeldung mitwp_rest-Nonce als Formularfeld,manage_options, Antwort 303 zurück zum Reiter). Grund: Im Admin-Kontext (is_admin()) ist nicht belegt, dass Divi Blöcke überthe_contentrendert; in REST-Anfragen ist es belegt (lit-page-render-summary), und die Codeprüfung läuft so im selben Kontext wie MCP-Aufrufe. Freigeben und Zurückziehen bleibenadmin-post-Aktionen. Präzisiert Spec §6.1. - Prüfsperre als Option
lit_mcp_divi_check_lock(add_optionist atomar, ein Transient nicht); älter als 10 Minuten gilt sie als verwaist. Präzisiert Spec §8 („Prüfsperre“). - Prüfschritte im Bericht: fünf statt vier – „Manifeste“ (Code und Registrierungen) und „Fingerabdrücke“ getrennt, damit
--update-referenceerkennt, dass nur Fingerabdrücke rot sind. Der Schreibtest entfällt nur, wenn „Manifeste“ rot ist. - Wegwerfseite: Post-Typ
page, Statusdraft, Titel „[LIT MCP Prüfung] “, Marker-Meta_lit_mcp_check_page=1(für das Aufräumen verwaister Seiten). Endgültig gelöscht mitwp_delete_post($id, true). - Version 0.6.0. DB-Freigaben hängen an der Plugin-Version; die Etappe ändert, wie das Plugin Divi nutzt.
- Unit-Tests:
LIT\MCP\Tests\Unit\TestCasesetzt insetUp()einen Resolver, der „freigegeben“ meldet (ReleaseGate::useResolver()), und setzt ihn intearDown()zurück. Bestehende Tests bleiben so unverändert; Tests der Sperre setzen ihren eigenen Status.
Global Constraints
- Dateien nur im Plugin-Verzeichnis
/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCPanlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und räumen Testseiten, Optionen (lit_mcp_divi_releases,lit_mcp_divi_check_report,lit_mcp_divi_check_lock) und den Transientlit_mcp_divi_statewieder auf. - PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.x); Divi 5.2.1. Namespace
LIT\MCP\→src/, PSR-4, eine Klasse pro Datei, jede Datei mitdefined('ABSPATH') || exit;, höchstens 120 Zeichen pro Zeile. Nach jedem Task:composer testundcomposer lintsauber. - Divi-Grenze (Spec §3): Nach Task 7 berührt außerhalb der Klassen in
DiviAdapters::CLASSESkein Code Divi (et_*(-Aufrufe,ET\…/ET_Core…/ET_Builder…-Klassen,ET_*-Konstanten, Zeichenketten'et_…'/'_et_…'); jede Fundstelle in einem Adapter steht in dessen Manifest.DiviBoundaryTesterzwingt beides. - Sperre (Spec §5): Zustand
releasednur, wenn die Codeprüfung grün ist und die Divi-Version inDiviReleases::TESTEDsteht oder einen DB-Eintrag für genau diese Plugin-Version hat. Schreibende Divi-Tools liefern sonstdivi_untestedbzw.divi_incompatibleund schreiben nichts; lesende Divi-Tools laufen und bekommen den Hinweis inwarnings. Jede schreibende Adapter-Methode prüft zusätzlich selbst (ReleaseGate::assertWritable()); Ausnahme nur für die ID der Wegwerfseite während der Prüfung (ReleaseGate::whileProbing()), nie von außen setzbar. - Freigabe per Datenbank nur nach frischem grünem Bericht: gleiche Divi-Version, gleiche Plugin-Version, gleicher Zustandsschlüssel (
ReleaseResolver::stateKey()), alle Schritte grün. Nurmanage_options, mit Nonce. - Wegwerfseite immer löschen:
wp_delete_post($id, true)imfinally; schlägt es fehl, ist der Bericht rot und nennt die ID; verwaiste Seiten (Marker-Meta) räumt der Reiter beim Öffnen auf. - Umbau ohne Verhaltensänderung: Alle bestehenden Unit-Tests und Smoke-Skripte bleiben grün; geänderte Tests nur dort, wo eine Klasse umzieht oder ein Test Divi-Namen direkt prüft.
- Sprache und Wortwahl: Oberfläche, Fehlermeldungen, Hinweise, Guides, Readme auf Deutsch mit echten Umlauten. Neutral formulieren: „KI-Assistent“, „MCP-Client“, „Verbindung“. Tool-Namen in Texten: direkte
lit-<name>, indirektelit/<name>. - Tests: Unit-Tests mit WordPress-Funktionen über
LIT\MCP\Tests\Unit\TestCase(Brain Monkey + Mockery); vorbelegte Stubs (wp_json_encode,is_wp_error,absint,wp_slash,wp_unslash,sanitize_text_field) nie mitFunctions\expect()prüfen. Reine PHP-Tests erben vonPHPUnit\Framework\TestCase. Einzelne Tests:vendor/bin/phpunit --filter <Name>. Integrationstests:cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh. WP-CLI in DDEV:cd /home/janniclas/Projekte/WordPress && ddev wp <befehl>. - Commits:
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "<Nachricht>" -m "Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>"(im Folgenden „Commit wie in den Global Constraints“).
Review Focus
- Sperre umgangen über einen Nebenweg:
lit-content-createmitdivi,lit-library-insert,lit/library-make-global,lit/tb-create-template,lit/tb-assign,lit/tb-activatebei ungetesteter Version. Erwartet:divi_untested, kein Entwurf, kein Bibliothekseintrag, keine Vorlage bleibt zurück → TestsDiviToolGateTest::testWriteToolOfEveryDiviGroupIsRefused(Task 10),ContentCreateDiviTest::testLockedDiviRefusesBeforeTheDraftExists(Task 11),ReleaseGuardsTest(Task 11: Saver, Bibliothek, Theme Builder werfenDiviLockedvor dem ersten Schreibaufruf). - Hinweis bricht lesende Tools: Ein lesendes Divi-Tool liefert eine Liste, einen
WP_Erroroder schon einwarnings-Feld; das Ausgabeschema erlaubt keinwarnings. Erwartet: Fehler und Listen unverändert, vorhandene Warnungen bleiben erhalten, Schema erlaubtwarnings→ TestsDiviToolGateTest::testReadResultsKeepTheirShapeAndGetTheNotice,::testOutputSchemaAllowsWarnings(Task 10). - Veralteter oder fremder Bericht gibt frei: Bericht grün, aber Divi inzwischen aktualisiert, Plugin aktualisiert oder eine Divi-Datei per Hotfix geändert; Freigabe-Formular ohne Nonce oder von einem Redakteur. Erwartet: keine Freigabe, Hinweis „Prüfung erneut ausführen“ bzw. 403 → Tests
CheckReportTest::testFreshOnlyForTheSameVersionsAndStateKey(Task 12),DiviTabTest::testReleaseNeedsAFreshGreenReport,::testActionsNeedNonceAndManageOptions(Task 13). - Wegwerfseite bleibt stehen: Composer wirft, Rendern wirft,
wp_delete_postliefertfalse, der Prozess stirbt. Erwartet: Seite gelöscht bzw. ID im roten Bericht; beim nächsten Öffnen des Reiters aufgeräumt → TestsWriteProbeTest::testDeletesThePageEvenWhenWritingThrows,::testReportsAPageThatCouldNotBeDeleted,::testRemovesOrphanedCheckPages(Task 12). - Fingerabdruck falsch-positiv oder falsch-negativ: Kommentar oder Einrückung in Divi geändert (darf nicht sperren), eine Zeile Code geändert (muss sperren), Funktion in einem
if ( ! function_exists() )-Block, gleichnamige Methode in einer anderen Klasse derselben Datei,Foo::classim Rumpf, Datei fehlt. Erwartet: Hash stabil bzw. geändert, richtige Funktion, fehlende Datei = rot statt Ausnahme → TestsSourceFingerprintTest(Task 1),ManifestCheckTest::testUnreadableMirrorIsAFailedCheck(Task 8).
File Structure
wp-lit-mcp.php, src/Plugin.php (geändert) Version 0.6.0; Verdrahtung Gate, Route, Reiter, CLI, Cache-Reset
readme.txt (geändert) Abschnitt Divi-Freigabe, Changelog 0.6.0
docs/guides/overview.md (geändert) Hinweis auf gesperrtes Schreiben bei ungetesteter Divi-Version
docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md (neu) manuelle Abnahme
src/
Uninstaller.php, uninstall.php (geändert) neue Optionen und Transient
Abilities/
AbilityRegistrar.php (geändert) DiviToolGate: Sperre, Hinweis, Ausgabeschema, DiviLocked
Errors.php (unverändert; Fehler entstehen in ReleaseStatus über Errors::make)
Admin/
DiviTab.php (neu) Reiter „Divi“
LogListTable.php (geändert) Ergebnis „refused“ = „abgelehnt“
SettingsPage.php (geändert) Hinweise divi_*
Cli/DiviCheckCommand.php (neu) wp lit-mcp divi-check
Integrations/
Core/EnvironmentProbe.php (geändert) DiviEnvironment, Freigabe-Zustand in components
Core/SiteStatusAbility.php (geändert) Beschreibung
Media/MediaUsageFinder.php (geändert) DesignSystemReader::diviLogo()
WordPress/Content/PostPresenter.php (geändert) DiviPages::usesBuilder()
WordPress/Content/ContentComposer.php (geändert) refusal()
WordPress/Content/ContentCreateAbility.php (geändert) Sperre vor dem Anlegen
WordPress/Schema/ContentSchema.php (geändert) DiviModules::INTERNAL_POST_TYPES, builderEnabledFor()
Divi/
DiviCompatibility.php (geändert) Prüfungen aus den Manifesten
Release/ (neu)
SourceFingerprint.php Manifest.php Call.php Mirror.php DiviAdapter.php DiviAdapters.php
DiviTouches.php ManifestCheck.php DiviEnvironment.php DiviReference.php fingerprints.json
DiviReleases.php ReleaseState.php ReleaseStatus.php ReleaseResolver.php ReleaseGate.php DiviLocked.php
ReleaseStore.php DiviToolGate.php CheckStep.php CheckReport.php CheckReportStore.php FormatCheck.php
WriteProbe.php CompatibilityCheck.php DiviCheckRoute.php LineDiff.php reference-page.html
Pages/DiviPages.php (neu, aus DiviFlags.php) Adapter „Seiten“
Pages/DiviFlags.php (entfällt)
Pages/DiviSaver.php, DiviPage.php, WpLinkTargets.php, DiviContentComposer.php (geändert)
Library/DiviLibraryStore.php, DiviRoles.php, LibraryItemSpec.php, NewLibraryEntry.php (geändert)
ThemeBuilder/DiviThemeBuilder.php, TemplateConditions.php, Template.php (geändert)
Design/DesignSystemReader.php (geändert)
Schema/DiviModules.php, ShortformCheck.php (neu), Pages/DynamicContentMap.php (geändert)
tests/
Unit/TestCase.php (geändert) ReleaseGate: freigegeben als Vorgabe
Unit/Integrations/Divi/Release/*Test.php (neu)
Unit/Integrations/Divi/Pages/DiviPagesTest.php (neu, aus DiviFlagsTest.php)
Unit/Admin/DiviTabTest.php, Unit/Cli/DiviCheckCommandTest.php (neu)
Fixtures/divi/reference/*.txt (neu, erzeugt) Soll-Quelltexte der Mirrors
Integration/divi-shortform-check.php (geändert) nutzt ShortformCheck
Integration/divi-release-smoke.sh (neu)
Task 1: SourceFingerprint
Files:
- Create:
src/Integrations/Divi/Release/SourceFingerprint.php - Test:
tests/Unit/Integrations/Divi/Release/SourceFingerprintTest.php
Interfaces:
-
Consumes: nichts.
-
Produces:
SourceFingerprint::of(string $file, string $function, ?string $class = null): SourceFingerprint(wirftRuntimeException),SourceFingerprint::fromSource(string $source, string $function, ?string $class = null): ?SourceFingerprint, Eigenschaftenstring $hash(sha256, 64 Hex-Zeichen),string $source(wörtlicher Text vonfunctionbis zur schließenden Klammer),int $startLine,int $endLine. -
Step 1: Write the failing test
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\SourceFingerprint;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class SourceFingerprintTest extends TestCase
{
/** Shaped like Divi's files: a guarded function, two classes with a method of the same name. */
private const SOURCE = <<<'PHP'
<?php
use function et_demo_save;
if ( ! function_exists( 'et_demo_save' ) ) {
/**
* Saves.
*/
function et_demo_save( $id, $data = array() ) {
$value = "{$data['x']}";
if ( $id ) {
update_post_meta( $id, '_et_demo', $value );
}
return Foo::class;
}
}
class Controller {
public static function update( $request ) {
return array( 'a' => 1 );
}
}
class Other {
public static function update( $request ) {
return array( 'b' => 2 );
}
}
PHP;
public function testFindsAFunctionInsideAFunctionExistsGuard(): void
{
$print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save');
self::assertNotNull($print);
self::assertSame(7, $print->startLine);
self::assertSame(13, $print->endLine);
self::assertStringStartsWith('function et_demo_save(', $print->source);
self::assertStringEndsWith('}', $print->source);
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $print->hash);
}
public function testClassConstantsAndInterpolationDoNotConfuseTheBraceCount(): void
{
$print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save');
self::assertNotNull($print);
self::assertStringContainsString('return Foo::class;', $print->source);
self::assertStringNotContainsString('class Controller', $print->source);
}
public function testMethodIsFoundInItsOwnClassOnly(): void
{
$controller = SourceFingerprint::fromSource(self::SOURCE, 'update', 'ET\Builder\VisualBuilder\Controller');
$other = SourceFingerprint::fromSource(self::SOURCE, 'update', 'Other');
self::assertNotNull($controller);
self::assertNotNull($other);
self::assertStringContainsString("'a' => 1", $controller->source);
self::assertStringContainsString("'b' => 2", $other->source);
self::assertNotSame($controller->hash, $other->hash);
self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'update'), 'a method is no top-level function');
self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save', 'Controller'));
self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'missing'));
}
public function testCommentsAndWhitespaceDoNotChangeTheHash(): void
{
$reformatted = str_replace(
["\t\t\$value = ", "if ( \$id ) {"],
["\t\t// Why: demo.\n\t\t\$value = ", "if (\$id)\n\t\t{"],
self::SOURCE
);
self::assertSame(
SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
SourceFingerprint::fromSource($reformatted, 'et_demo_save')?->hash
);
}
public function testACodeChangeChangesTheHash(): void
{
$changed = str_replace("'_et_demo'", "'_et_demo_v2'", self::SOURCE);
self::assertNotSame(
SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
SourceFingerprint::fromSource($changed, 'et_demo_save')?->hash
);
}
public function testOfReadsTheFileAndThrowsForMissingFunctions(): void
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit');
file_put_contents($file, self::SOURCE);
try {
self::assertSame(
SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
SourceFingerprint::of($file, 'et_demo_save')->hash
);
try {
SourceFingerprint::of($file, 'update', 'Missing');
self::fail('a missing method must throw');
} catch (RuntimeException $e) {
self::assertStringContainsString('Missing::update() not found', $e->getMessage());
}
} finally {
unlink($file);
}
}
public function testOfThrowsForAnUnreadableFile(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('is not readable');
SourceFingerprint::of('/nonexistent/lit-divi.php', 'et_demo_save');
}
}
- Step 2: Run test to verify it fails
Run: vendor/bin/phpunit --filter SourceFingerprintTest
Expected: FAIL with „Class "LIT\MCP\Integrations\Divi\Release\SourceFingerprint" not found“.
- Step 3: Write minimal implementation
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* Fingerprint of one Divi function or method (spec §4.2), read with token_get_all(): the file is never included,
* so functions Divi loads only in some requests (theme-builder/api.php, admin-ajax only) are readable in every
* request. The hash covers the tokens from "function" to the closing brace without comments and whitespace –
* new comments or reformatting keep it, any code change changes it. $source is the verbatim text of those
* tokens for the human diff (wp lit-mcp divi-check --diff).
*/
final class SourceFingerprint
{
private const IGNORED = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
private const TYPES = [T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM];
private function __construct(
public readonly string $hash,
public readonly string $source,
public readonly int $startLine,
public readonly int $endLine
) {
}
/** @throws RuntimeException When the file is unreadable or holds no such function. */
public static function of(string $file, string $function, ?string $class = null): self
{
$source = is_file($file) && is_readable($file) ? file_get_contents($file) : false;
if (! is_string($source)) {
throw new RuntimeException(sprintf('%s is not readable', $file));
}
return self::fromSource($source, $function, $class) ?? throw new RuntimeException(sprintf(
'%s not found in %s',
$class === null ? $function . '()' : self::shortName($class) . '::' . $function . '()',
$file
));
}
/**
* The function $function outside every class body, or – with $class – the method $function declared directly
* in the class (trait, interface, enum) named like $class's last segment. The namespace is not compared: the
* file path of the manifest fixes where to look.
*/
public static function fromSource(string $source, string $function, ?string $class = null): ?self
{
$tokens = token_get_all($source);
$wanted = $class === null ? null : self::shortName($class);
$depth = 0;
/** @var list<array{0: int, 1: string}> $bodies Open class bodies: [brace depth, name]. */
$bodies = [];
$pending = null;
foreach ($tokens as $i => $token) {
$id = is_array($token) ? $token[0] : null;
$text = is_array($token) ? $token[1] : $token;
if ($id !== null && in_array($id, self::TYPES, true) && self::previousId($tokens, $i) !== T_DOUBLE_COLON) {
$pending = self::nextName($tokens, $i) ?? '';
continue;
}
if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
++$depth;
if ($pending !== null && $text === '{') {
$bodies[] = [$depth, $pending];
$pending = null;
}
continue;
}
if ($text === '}') {
if ($bodies !== [] && $bodies[count($bodies) - 1][0] === $depth) {
array_pop($bodies);
}
--$depth;
continue;
}
if ($id !== T_FUNCTION || self::previousId($tokens, $i) === T_USE) {
continue;
}
$name = self::nextName($tokens, $i);
if ($name === null || strcasecmp($name, $function) !== 0) {
continue;
}
$body = $bodies === [] ? null : $bodies[count($bodies) - 1];
$matches = $wanted === null
? $body === null
: $body !== null && $body[0] === $depth && strcasecmp($body[1], $wanted) === 0;
if ($matches) {
return self::capture($tokens, $i);
}
}
return null;
}
/** @param array<int, mixed> $tokens */
private static function capture(array $tokens, int $start): ?self
{
$startLine = (int) $tokens[$start][2];
$parts = [];
$verbatim = '';
$depth = 0;
$opened = false;
$count = count($tokens);
for ($i = $start; $i < $count; ++$i) {
$token = $tokens[$i];
$id = is_array($token) ? $token[0] : null;
$text = is_array($token) ? $token[1] : $token;
$verbatim .= $text;
if ($id === null || ! in_array($id, self::IGNORED, true)) {
$parts[] = $text;
}
if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
++$depth;
$opened = true;
} elseif ($text === '}') {
--$depth;
if ($opened && $depth === 0) {
return new self(
hash('sha256', implode(' ', $parts)),
$verbatim,
$startLine,
$startLine + substr_count($verbatim, "\n")
);
}
} elseif ($text === ';' && ! $opened) {
return null;
}
}
return null;
}
/** The next name after $i (skipping whitespace, comments and "&"), null when something else follows. */
private static function nextName(array $tokens, int $i): ?string
{
$count = count($tokens);
for ($j = $i + 1; $j < $count; ++$j) {
$token = $tokens[$j];
if (is_array($token) && in_array($token[0], self::IGNORED, true)) {
continue;
}
if ($token === '&' || (is_array($token) && $token[1] === '&')) {
continue;
}
return is_array($token) && $token[0] === T_STRING ? $token[1] : null;
}
return null;
}
/** Token ID of the previous significant token, null for a single-character token or none. */
private static function previousId(array $tokens, int $i): ?int
{
for ($j = $i - 1; $j >= 0; --$j) {
$token = $tokens[$j];
if (is_array($token) && in_array($token[0], self::IGNORED, true)) {
continue;
}
return is_array($token) ? $token[0] : null;
}
return null;
}
public static function shortName(string $class): string
{
$position = strrpos($class, '\\');
return $position === false ? $class : substr($class, $position + 1);
}
}
- Step 4: Run test to verify it passes
Run: vendor/bin/phpunit --filter SourceFingerprintTest
Expected: PASS (7 Tests).
- Step 5: Lint und Commit
Run: composer lint (bei Befunden in der neuen Datei vendor/bin/phpcbf src/Integrations/Divi/Release/SourceFingerprint.php und erneut composer lint).
git add src/Integrations/Divi/Release/SourceFingerprint.php tests/Unit/Integrations/Divi/Release/SourceFingerprintTest.php
Commit wie in den Global Constraints mit Nachricht feat: fingerprint Divi functions from source tokens.
Task 2: Manifest, Adapter-Register, Scanner und Architekturtest
Files:
- Create:
src/Integrations/Divi/Release/Call.php,Mirror.php,Manifest.php,DiviAdapter.php,DiviAdapters.php,DiviTouches.php - Test:
tests/Unit/Integrations/Divi/Release/ManifestTest.php,tests/Unit/Integrations/Divi/Release/DiviTouchesTest.php,tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php
Interfaces:
-
Consumes:
SourceFingerprint::shortName()(Task 1). -
Produces:
Call::__construct(string $symbol, ?int $args = null);className(): ?string(voll qualifiziert, ohne führenden\),member(): string,label(): string(z. B.et_pb_is_allowed(),GlobalData::get_global_colors()).Mirror::__construct(string $id, string $file, string $function, ?string $class, string $what);label(): string.Manifest::__construct(string $area, array $calls = [], array $constants = [], array $postTypes = [], array $taxonomies = [], array $blockTypes = [], array $keys = [], array $mirrors = []), KonstantenPAGES,LIBRARY,THEME_BUILDER,DESIGN,CATALOG;names(): list<string>(Funktionen, Klassen, Konstanten, Post-Typen, Taxonomien, Schlüssel).interface DiviAdapter { public static function manifest(): Manifest; }DiviAdapters::CLASSES(list),DiviAdapters::manifests(): array<class-string, Manifest>,DiviAdapters::mirrors(): list<Mirror>.DiviTouches::inSource(string $source): list<array{kind: string, name: string, line: int}>,DiviTouches::inFile(string $path),DiviTouches::detects(string $name): bool(kind∈function,class,constant,string).
-
Step 1: Write the failing tests
tests/Unit/Integrations/Divi/Release/ManifestTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use PHPUnit\Framework\TestCase;
final class ManifestTest extends TestCase
{
public function testCallsKnowTheirClassMemberAndLabel(): void
{
$function = new Call('et_pb_is_allowed', 1);
$method = new Call('ET\Builder\Packages\GlobalData\GlobalData::get_global_colors', 0);
self::assertNull($function->className());
self::assertSame('et_pb_is_allowed', $function->member());
self::assertSame('et_pb_is_allowed()', $function->label());
self::assertSame('ET\Builder\Packages\GlobalData\GlobalData', $method->className());
self::assertSame('get_global_colors', $method->member());
self::assertSame('GlobalData::get_global_colors()', $method->label());
self::assertSame('ET_Core_PageResource', (new Call('\ET_Core_PageResource::remove_static_resources', 3))->className());
}
public function testMirrorLabel(): void
{
$function = new Mirror('tb-api-save', 'includes/api.php', 'et_theme_builder_api_save', null, 'Vorlagenliste');
$method = new Mirror('vb-flags', 'x.php', 'update', 'SyncToServerController', 'Kennzeichen');
self::assertSame('et_theme_builder_api_save()', $function->label());
self::assertSame('SyncToServerController::update()', $method->label());
}
public function testNamesListEveryDeclaredDiviName(): void
{
$manifest = new Manifest(
Manifest::LIBRARY,
calls: [new Call('et_pb_is_allowed', 1), new Call('ET\Builder\GlobalLayout::is_global_layout_template', 1)],
constants: ['ET_BUILDER_PRODUCT_VERSION'],
postTypes: ['et_pb_layout'],
taxonomies: ['scope'],
blockTypes: ['divi/global-layout'],
keys: ['_et_pb_module_type']
);
self::assertSame(
['et_pb_is_allowed', 'ET\Builder\GlobalLayout', 'ET_BUILDER_PRODUCT_VERSION', 'et_pb_layout', 'scope',
'_et_pb_module_type'],
$manifest->names()
);
self::assertSame('Bibliothek', $manifest->area);
}
}
tests/Unit/Integrations/Divi/Release/DiviTouchesTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\DiviTouches;
use PHPUnit\Framework\TestCase;
final class DiviTouchesTest extends TestCase
{
public function testFindsCallsClassesConstantsAndNames(): void
{
$source = <<<'PHP'
<?php
use ET\Builder\Packages\GlobalData\GlobalData;
// et_comment_only() and 'et_in_a_comment' are ignored.
/** @see et_theme_builder_get_template() */
final class X {
public const REGISTRY = 'ET\Builder\Packages\Module\DynamicContentOptions';
public function a(): void {
et_pb_is_allowed('theme_builder');
\et_theme_builder_clear_wp_cache('all');
\ET_Core_PageResource::remove_static_resources(1, 'all', true);
$v = defined('ET_BUILDER_PRODUCT_VERSION') ? ET_BUILDER_PRODUCT_VERSION : '';
get_post_meta(1, '_et_pb_use_builder', true);
function_exists('et_');
$this->et_method();
Foo::et_static();
get_option('lit_mcp_limits');
}
}
PHP;
$touches = array_map(
static fn (array $t): string => $t['kind'] . ':' . $t['name'],
DiviTouches::inSource($source)
);
self::assertSame(
[
'class:ET\Builder\Packages\GlobalData\GlobalData',
'class:ET\Builder\Packages\Module\DynamicContentOptions',
'function:et_pb_is_allowed',
'function:et_theme_builder_clear_wp_cache',
'class:ET_Core_PageResource',
'constant:ET_BUILDER_PRODUCT_VERSION',
'constant:ET_BUILDER_PRODUCT_VERSION',
'string:_et_pb_use_builder',
'string:et_',
],
$touches
);
}
public function testDetectsOnlyNamesTheScannerReports(): void
{
self::assertTrue(DiviTouches::detects('et_pb_layout'));
self::assertTrue(DiviTouches::detects('_et_template'));
self::assertTrue(DiviTouches::detects('ET\Builder\Packages\GlobalData\GlobalData'));
self::assertTrue(DiviTouches::detects('ET_Core_PageResource'));
self::assertTrue(DiviTouches::detects('ET_BUILDER_PRODUCT_VERSION'));
self::assertFalse(DiviTouches::detects('scope'));
self::assertFalse(DiviTouches::detects('divi/global-layout'));
}
}
tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use FilesystemIterator;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviTouches;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
/**
* Spec §3: only the adapters in DiviAdapters::CLASSES touch Divi, and every Divi name an adapter uses stands in its
* manifest (and nothing more).
*/
final class DiviBoundaryTest extends TestCase
{
/**
* Files that still touch Divi outside an adapter (paths below src/). Tasks 3–7 shrink this list to
* DiviCompatibility.php; Task 8 removes it together with testLegacyFilesStillTouchDivi.
*/
private const LEGACY = [
'Integrations/Divi/Design/DesignSystemReader.php',
'Integrations/Divi/DiviCompatibility.php',
'Integrations/Divi/Library/DiviLibraryStore.php',
'Integrations/Divi/Library/DiviRoles.php',
'Integrations/Divi/Library/LibraryItemSpec.php',
'Integrations/Divi/Library/NewLibraryEntry.php',
'Integrations/Divi/Pages/DiviFlags.php',
'Integrations/Divi/Pages/DiviPage.php',
'Integrations/Divi/Pages/DiviSaver.php',
'Integrations/Divi/Pages/DynamicContentMap.php',
'Integrations/Divi/Pages/WpLinkTargets.php',
'Integrations/Divi/Schema/DiviModules.php',
'Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php',
'Integrations/Media/MediaUsageFinder.php',
'Integrations/WordPress/Content/PostPresenter.php',
'Integrations/WordPress/Schema/ContentSchema.php',
];
public function testOnlyAdaptersTouchDivi(): void
{
$offenders = [];
foreach (self::sourceFiles() as $relative => $path) {
$touches = DiviTouches::inFile($path);
if ($touches === [] || in_array($relative, self::LEGACY, true)) {
continue;
}
if (! in_array(self::classOf($relative), DiviAdapters::CLASSES, true)) {
$offenders[] = $relative . ': ' . implode(', ', array_unique(array_column($touches, 'name')));
}
}
self::assertSame([], $offenders, "Divi darf nur ein Adapter aus DiviAdapters::CLASSES berühren:\n"
. implode("\n", $offenders));
}
public function testLegacyFilesStillTouchDivi(): void
{
$files = self::sourceFiles();
foreach (self::LEGACY as $relative) {
self::assertArrayHasKey($relative, $files, $relative . ' gibt es nicht mehr – aus LEGACY streichen');
self::assertNotSame([], DiviTouches::inFile($files[$relative]), $relative . ' ist sauber – aus LEGACY streichen');
self::assertNotContains(self::classOf($relative), DiviAdapters::CLASSES, $relative . ' ist ein Adapter');
}
}
public function testAdaptersDeclareExactlyTheDiviNamesTheyUse(): void
{
foreach (DiviAdapters::CLASSES as $class) {
self::assertTrue(is_subclass_of($class, DiviAdapter::class), $class . ' implementiert DiviAdapter nicht');
$declared = $class::manifest()->names();
$used = array_values(array_unique(array_column(
DiviTouches::inFile((string) (new ReflectionClass($class))->getFileName()),
'name'
)));
self::assertSame([], array_values(array_diff($used, $declared)), $class . ': nicht im Manifest');
self::assertSame(
[],
array_values(array_diff(array_filter($declared, [DiviTouches::class, 'detects']), $used)),
$class . ': im Manifest, aber nicht benutzt'
);
}
}
public function testMirrorIdsAreUniqueAndFilesRelative(): void
{
$ids = [];
foreach (DiviAdapters::mirrors() as $mirror) {
self::assertNotContains($mirror->id, $ids, 'doppelte Mirror-ID ' . $mirror->id);
self::assertStringStartsNotWith('/', $mirror->file);
$ids[] = $mirror->id;
}
}
/** @return array<string, string> Path below src/ => absolute path. */
private static function sourceFiles(): array
{
$root = dirname(__DIR__, 5) . '/src/';
$files = [];
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->getExtension() === 'php') {
$files[substr((string) $file->getPathname(), strlen($root))] = (string) $file->getPathname();
}
}
ksort($files);
return $files;
}
private static function classOf(string $relative): string
{
return 'LIT\\MCP\\' . str_replace(['/', '.php'], ['\\', ''], $relative);
}
}
- Step 2: Run tests to verify they fail
Run: vendor/bin/phpunit --filter 'ManifestTest|DiviTouchesTest|DiviBoundaryTest'
Expected: FAIL („Class … Call not found“, „… DiviTouches not found“, „… DiviAdapters not found“).
- Step 3: Write the implementation
src/Integrations/Divi/Release/Call.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* One Divi function or static method an adapter calls (spec §4.1), with the number of arguments the plugin passes.
* $args null: only its existence matters (a function Divi itself needs, e.g. et_() inside store_template).
*/
final class Call
{
/** @param string $symbol "function_name" or "Fully\Qualified\Class::method". */
public function __construct(public readonly string $symbol, public readonly ?int $args = null)
{
}
public function className(): ?string
{
return str_contains($this->symbol, '::') ? ltrim(explode('::', $this->symbol, 2)[0], '\\') : null;
}
public function member(): string
{
return str_contains($this->symbol, '::') ? explode('::', $this->symbol, 2)[1] : $this->symbol;
}
public function label(): string
{
$class = $this->className();
return ($class === null ? '' : SourceFingerprint::shortName($class) . '::') . $this->member() . '()';
}
}
src/Integrations/Divi/Release/Mirror.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* A Divi function whose storage logic an adapter repeats instead of calling it (spec §4.1 "mirrors"). Its
* fingerprint (SourceFingerprint) must match the one in fingerprints.json, else Divi changed what we copy.
*/
final class Mirror
{
/**
* @param string $id Key in fingerprints.json and name of tests/Fixtures/divi/reference/<id>.txt.
* @param string $file Divi file, relative to the Divi theme directory.
* @param string $function Function or method name.
* @param string|null $class Class of a method – the short name is enough (the file fixes where it
* lives, and a short name is no Divi name for DiviBoundaryTest); null for a function.
* @param string $what What the plugin repeats (German, for reports).
*/
public function __construct(
public readonly string $id,
public readonly string $file,
public readonly string $function,
public readonly ?string $class,
public readonly string $what
) {
}
public function label(): string
{
return ($this->class === null ? '' : SourceFingerprint::shortName($this->class) . '::') . $this->function . '()';
}
}
src/Integrations/Divi/Release/Manifest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* The Divi dependencies of one adapter (spec §4.1) – the single source for DiviCompatibility, the release gate,
* the admin check and DiviBoundaryTest. Pure data, no WordPress calls.
*/
final class Manifest
{
public const PAGES = 'Seiten';
public const LIBRARY = 'Bibliothek';
public const THEME_BUILDER = 'Theme Builder';
public const DESIGN = 'Designsystem';
public const CATALOG = 'Katalog';
/**
* @param string $area One of the constants above (the five areas of spec §3).
* @param list<Call> $calls Divi functions and static methods the adapter calls.
* @param list<string> $constants Divi constants it reads.
* @param list<string> $postTypes Divi post types it needs registered.
* @param list<string> $taxonomies Divi taxonomies it needs registered.
* @param list<string> $blockTypes Divi block types it needs (registered or in Divi's module metadata).
* @param list<string> $keys Other Divi names it uses as strings: meta keys, options, internal post
* types that need no registration check, query arguments.
* @param list<Mirror> $mirrors Divi functions whose storage logic it repeats.
*/
public function __construct(
public readonly string $area,
public readonly array $calls = [],
public readonly array $constants = [],
public readonly array $postTypes = [],
public readonly array $taxonomies = [],
public readonly array $blockTypes = [],
public readonly array $keys = [],
public readonly array $mirrors = []
) {
}
/**
* @return list<string> Every Divi name the adapter's code may contain (DiviBoundaryTest): called functions and
* classes, constants, post types, taxonomies, keys and the names of mirrored functions.
*/
public function names(): array
{
$names = [];
foreach ($this->calls as $call) {
$names[] = $call->className() ?? $call->member();
}
$mirrored = array_map(static fn (Mirror $mirror): string => $mirror->function, $this->mirrors);
return array_values(array_unique(array_merge(
$names,
$this->constants,
$this->postTypes,
$this->taxonomies,
$this->keys,
$mirrored
)));
}
}
src/Integrations/Divi/Release/DiviAdapter.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** A class that may touch Divi (spec §3); it lists every Divi dependency in its manifest. */
interface DiviAdapter
{
public static function manifest(): Manifest;
}
src/Integrations/Divi/Release/DiviAdapters.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** The Divi boundary (spec §3): the only classes that touch Divi, in the order reports list them. */
final class DiviAdapters
{
/** @var list<class-string<DiviAdapter>> */
public const CLASSES = [];
/** @return array<class-string<DiviAdapter>, Manifest> */
public static function manifests(): array
{
$manifests = [];
foreach (self::CLASSES as $class) {
$manifests[$class] = $class::manifest();
}
return $manifests;
}
/** @return list<Mirror> */
public static function mirrors(): array
{
$mirrors = [];
foreach (self::manifests() as $manifest) {
array_push($mirrors, ...$manifest->mirrors);
}
return $mirrors;
}
}
src/Integrations/Divi/Release/DiviTouches.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* Finds where PHP code touches Divi (DiviBoundaryTest, spec §3): calls of et_* functions, ET\… / ET_Core… /
* ET_Builder… classes (also as strings), ET_* constants (also as strings) and the strings 'et_…'/'_et_…' (post
* types, meta keys, options, function names for function_exists()). Comments are ignored; method calls named
* et_* ($x->et_a(), Foo::et_b()) are not Divi's.
*/
final class DiviTouches
{
private const IGNORED = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
private const NOT_A_CALL = [
T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_CONST,
];
/** @return list<array{kind: string, name: string, line: int}> */
public static function inFile(string $path): array
{
$source = file_get_contents($path);
return is_string($source) ? self::inSource($source) : [];
}
/** @return list<array{kind: string, name: string, line: int}> */
public static function inSource(string $source): array
{
$tokens = array_values(array_filter(
token_get_all($source),
static fn (mixed $token): bool => ! is_array($token) || ! in_array($token[0], self::IGNORED, true)
));
$touches = [];
foreach ($tokens as $i => $token) {
if (! is_array($token)) {
continue;
}
$touch = match ($token[0]) {
T_STRING, T_NAME_FULLY_QUALIFIED => self::name($tokens, $i, ltrim($token[1], '\\')),
T_NAME_QUALIFIED => str_starts_with($token[1], 'ET\\') ? ['class', $token[1]] : null,
T_CONSTANT_ENCAPSED_STRING => self::literal($token[1]),
default => null,
};
if ($touch !== null) {
$touches[] = ['kind' => $touch[0], 'name' => $touch[1], 'line' => (int) $token[2]];
}
}
return $touches;
}
/** Whether inSource() reports $name when the code uses it (DiviBoundaryTest's unused-entry check). */
public static function detects(string $name): bool
{
return self::classify($name) !== null || preg_match('/^_?et_[a-z0-9_]*$/', $name) === 1;
}
/**
* @param array<int, mixed> $tokens
* @return array{0: string, 1: string}|null
*/
private static function name(array $tokens, int $i, string $name): ?array
{
$classified = self::classify($name);
if ($classified !== null) {
return $classified;
}
if (preg_match('/^et_[a-z0-9_]*$/', $name) !== 1 || ($tokens[$i + 1] ?? null) !== '(') {
return null;
}
$previous = $tokens[$i - 1] ?? null;
return is_array($previous) && in_array($previous[0], self::NOT_A_CALL, true) ? null : ['function', $name];
}
/** @return array{0: string, 1: string}|null */
private static function literal(string $text): ?array
{
$value = substr($text, 1, -1);
if ($text[0] === "'") {
$value = str_replace(['\\\\', "\\'"], ['\\', "'"], $value);
}
$value = ltrim($value, '\\');
$classified = self::classify($value);
if ($classified !== null) {
return $classified;
}
return preg_match('/^_?et_[a-z0-9_]*$/', $value) === 1 ? ['string', $value] : null;
}
/** @return array{0: string, 1: string}|null A Divi class or constant name. */
private static function classify(string $name): ?array
{
if (preg_match('/^ET\\\\[A-Za-z0-9_\\\\]+$/', $name) === 1
|| preg_match('/^ET_(?:Core|Builder)[A-Za-z0-9_]*$/', $name) === 1) {
return ['class', $name];
}
return preg_match('/^ET_[A-Z0-9_]+$/', $name) === 1 ? ['constant', $name] : null;
}
}
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'ManifestTest|DiviTouchesTest|DiviBoundaryTest'
Expected: PASS. Meldet testOnlyAdaptersTouchDivi weitere Dateien, stimmt die Liste LEGACY nicht mit dem Code überein: die gemeldeten Dateien prüfen – echte Divi-Berührungen in LEGACY aufnehmen (und im Bericht an den Controller nennen), Fehlalarme des Scanners als Test in DiviTouchesTest festhalten und DiviTouches korrigieren. Meldet testLegacyFilesStillTouchDivi eine Datei als sauber, sie aus LEGACY streichen.
- Step 5: Gesamtlauf, Lint, Commit
Run: composer test && composer lint
Expected: alle Tests grün (2202 + neue), Lint sauber.
git add src/Integrations/Divi/Release tests/Unit/Integrations/Divi/Release
Commit wie in den Global Constraints mit Nachricht feat: Divi manifests, adapter register and boundary test.
Task 3: Adapter „Katalog“ (DiviModules, DynamicContentMap, ContentSchema)
Files:
- Modify:
src/Integrations/Divi/Schema/DiviModules.php,src/Integrations/Divi/Pages/DynamicContentMap.php,src/Integrations/WordPress/Schema/ContentSchema.php,src/Integrations/Divi/DiviCompatibility.php(nurknowsGlobalLayout()),src/Integrations/Divi/Release/DiviAdapters.php - Test:
tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php(neu),tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php,tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php(LEGACY)
Interfaces:
-
Consumes:
Manifest,Call,DiviAdapter,DiviAdapters(Task 2);GlobalInstance::BLOCK('divi/global-layout'),DiviLibraryStore::POST_TYPE,DiviThemeBuilder::THEME_BUILDER,::TEMPLATE,::LAYOUT_TYPES(bestehend). -
Produces:
DiviModules implements DiviAdapter;DiviModules::INTERNAL_POST_TYPES(list, Reihenfolge wie bisher inContentSchema::EXCLUDED),DiviModules::builderEnabledFor(string $postType): bool,DiviModules::knowsBlock(string $name): bool;DynamicContentMap implements DiviAdapter. -
Step 1: Write the failing tests
tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Schema;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\TestCase;
final class DiviModulesTest extends TestCase
{
public function testCatalogAdaptersAreRegistered(): void
{
self::assertContains(DiviModules::class, DiviAdapters::CLASSES);
self::assertContains(DynamicContentMap::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::CATALOG, DiviModules::manifest()->area);
self::assertSame(Manifest::CATALOG, DynamicContentMap::manifest()->area);
}
public function testManifestListsModuleRegistrationAndTheBuilderSwitch(): void
{
$manifest = DiviModules::manifest();
self::assertSame(
[
'ModuleRegistration::get_all_core_modules_metadata()',
'ModuleRegistration::get_default_attrs()',
'et_builder_enabled_for_post_type()',
],
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
);
self::assertSame(['divi/global-layout'], $manifest->blockTypes);
self::assertSame(['ET_BUILDER_PRODUCT_VERSION'], $manifest->constants);
}
public function testInternalPostTypesAreDivisOwn(): void
{
self::assertSame(
[
'et_pb_layout', 'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout',
'et_footer_layout', 'et_pb_canvas', '_et_pb_speculation',
],
DiviModules::INTERNAL_POST_TYPES
);
}
public function testBuilderEnabledForAsksDivi(): void
{
Functions\when('et_builder_enabled_for_post_type')->alias(static fn (string $type): bool => $type === 'page');
self::assertTrue(DiviModules::builderEnabledFor('page'));
self::assertFalse(DiviModules::builderEnabledFor('post'));
}
}
In tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php ergänzen (Import LIT\MCP\Integrations\WordPress\Schema\ContentSchema ist vorhanden):
/** Spec §3: Divi's post types come from the Katalog adapter – the list itself stays the same. */
public function testExcludedPostTypesStayTheSame(): void
{
self::assertSame(
[
'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'et_pb_layout',
'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout',
'et_pb_canvas', '_et_pb_speculation',
],
ContentSchema::EXCLUDED
);
}
- Step 2: Run tests to verify they fail
Run: vendor/bin/phpunit --filter 'DiviModulesTest|ContentSchemaTest'
Expected: DiviModulesTest FAIL („Call to undefined method … DiviModules::manifest()“); testExcludedPostTypesStayTheSame PASS (Schutz für den Umbau).
- Step 3: Implementation
src/Integrations/Divi/Schema/DiviModules.php – Klassenkopf, Konstante, Manifest und zwei neue Methoden; isAvailable(), catalog(), builderVersion() bleiben unverändert:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Schema;
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
defined('ABSPATH') || exit;
/**
* Adapter "Katalog" (spec §3): Divi's module metadata once per request (Divi 5 only), Divi's own post types and
* its per-type builder switch for the content tools, and whether Divi knows a block type. Not final so abilities
* can be unit-tested with a mock.
*/
class DiviModules implements DiviAdapter
{
/** Divi's own post types the content tools leave alone (library, Theme Builder, canvases, speculation). */
public const INTERNAL_POST_TYPES = [
DiviLibraryStore::POST_TYPE,
DiviThemeBuilder::THEME_BUILDER,
DiviThemeBuilder::TEMPLATE,
DiviThemeBuilder::LAYOUT_TYPES['header'],
DiviThemeBuilder::LAYOUT_TYPES['body'],
DiviThemeBuilder::LAYOUT_TYPES['footer'],
'et_pb_canvas',
'_et_pb_speculation',
];
private ?ModuleCatalog $catalog = null;
public static function manifest(): Manifest
{
return new Manifest(
Manifest::CATALOG,
calls: [
new Call(ModuleRegistration::class . '::get_all_core_modules_metadata', 0),
new Call(ModuleRegistration::class . '::get_default_attrs', 2),
new Call('et_builder_enabled_for_post_type', 1),
],
constants: ['ET_BUILDER_PRODUCT_VERSION'],
blockTypes: [GlobalInstance::BLOCK],
keys: ['et_pb_canvas', '_et_pb_speculation']
);
}
// isAvailable(), catalog(), builderVersion(): unverändert.
/** Whether Divi's builder is switched on for $postType (Divi → Theme-Optionen → Builder); false without Divi. */
public static function builderEnabledFor(string $postType): bool
{
return function_exists('et_builder_enabled_for_post_type')
&& (bool) et_builder_enabled_for_post_type($postType);
}
/**
* Whether Divi still has block type $name: registered with WordPress, or in Divi's core module metadata (Divi
* renders global instances itself and does not always register the type with WordPress).
*/
public static function knowsBlock(string $name): bool
{
if (class_exists('WP_Block_Type_Registry') && \WP_Block_Type_Registry::get_instance()->is_registered($name)) {
return true;
}
if (! self::isAvailable()) {
return false;
}
foreach ((array) ModuleRegistration::get_all_core_modules_metadata() as $module) {
if (is_array($module) && ($module['name'] ?? null) === $name) {
return true;
}
}
return false;
}
}
src/Integrations/Divi/Pages/DynamicContentMap.php: class DynamicContentMap implements DiviAdapter, Importe LIT\MCP\Integrations\Divi\Release\Call, …\DiviAdapter, …\Manifest, im Klassen-Docblock den ersten Satz mit „Adapter "Katalog" (spec §3):“ beginnen, am Docblock von REGISTRY „also checked by DiviCompatibility“ durch „listed in manifest()“ ersetzen, und:
public static function manifest(): Manifest
{
return new Manifest(Manifest::CATALOG, calls: [new Call(self::REGISTRY . '::get_options', 2)]);
}
src/Integrations/WordPress/Schema/ContentSchema.php: Import use LIT\MCP\Integrations\Divi\Schema\DiviModules;, dann:
/** Post types handled elsewhere (media tools, Divi tools, WordPress internals). */
public const EXCLUDED = [
'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face',
...DiviModules::INTERNAL_POST_TYPES,
];
und im Konstruktor:
public function __construct(private array $sources, ?callable $diviEnabled = null)
{
$this->diviEnabled = $diviEnabled ?? DiviModules::builderEnabledFor(...);
}
src/Integrations/Divi/DiviCompatibility.php: Die Prüfung 'Block-Typ divi/global-layout (Instanzen globaler Module)' ruft DiviModules::knowsBlock(GlobalInstance::BLOCK) auf; die private Methode knowsGlobalLayout() entfällt (Import DiviModules ist vorhanden).
src/Integrations/Divi/Release/DiviAdapters.php: CLASSES wird
public const CLASSES = [
\LIT\MCP\Integrations\Divi\Schema\DiviModules::class,
\LIT\MCP\Integrations\Divi\Pages\DynamicContentMap::class,
];
(mit use-Importen statt voll qualifizierter Namen, wie im übrigen Code). In DiviBoundaryTest::LEGACY die Zeilen Integrations/Divi/Schema/DiviModules.php, Integrations/Divi/Pages/DynamicContentMap.php und Integrations/WordPress/Schema/ContentSchema.php streichen.
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'DiviModulesTest|ContentSchemaTest|DiviBoundaryTest|DiviCompatibilityTest'
Expected: PASS. Meldet testAdaptersDeclareExactlyTheDiviNamesTheyUse einen Namen, fehlt er im Manifest (ergänzen) oder ist dort überflüssig (streichen).
- Step 5: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
Commit wie in den Global Constraints mit Nachricht refactor: Divi catalog adapter with manifest.
Task 4: Adapter „Seiten“ (DiviPages statt DiviFlags)
Files:
- Create (per
git mv):src/Integrations/Divi/Pages/DiviPages.phpausDiviFlags.php;tests/Unit/Integrations/Divi/Pages/DiviPagesTest.phpausDiviFlagsTest.php - Modify:
src/Integrations/Divi/Pages/DiviSaver.php,src/Integrations/Divi/Pages/DiviPage.php,src/Integrations/Divi/Pages/WpLinkTargets.php,src/Integrations/WordPress/Content/PostPresenter.php,src/Integrations/Divi/Release/DiviAdapters.php,tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php
Interfaces:
-
Consumes:
Manifest,Call,Mirror,DiviAdapter(Task 2);DiviLibraryStore::POST_TYPE. -
Produces:
final class DiviPages implements DiviAdaptermitFLAGS(wie bisherDiviFlags::FLAGS),setFlags(int $postId): void(wirftRuntimeException, wieDiviFlags::set()),usesBuilder(int $postId): bool,builderUrl(WP_Post|int $post): string,clearPageCss(int $postId): void,isLinkable(string $postType): bool– alle statisch. Mirror-IDvb-sync-flags. -
Step 1: Klasse und Test umbenennen, Tests schreiben
git mv src/Integrations/Divi/Pages/DiviFlags.php src/Integrations/Divi/Pages/DiviPages.php
git mv tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php tests/Unit/Integrations/Divi/Pages/DiviPagesTest.php
In DiviPagesTest.php: Klasse DiviPagesTest, Import LIT\MCP\Integrations\Divi\Pages\DiviPages, jedes DiviFlags::set(7) → DiviPages::setFlags(7), im Kommentar des ersten Tests „(SyncToServerController.php:382-387)“ → „(SyncToServerController::update, Mirror vb-sync-flags)“. Dazu diese Tests (Importe LIT\MCP\Integrations\Divi\Release\DiviAdapters, LIT\MCP\Integrations\Divi\Release\Manifest, WP_Post):
public function testIsTheRegisteredPagesAdapterWithTheFlagMirror(): void
{
$manifest = DiviPages::manifest();
self::assertContains(DiviPages::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::PAGES, $manifest->area);
self::assertSame(['vb-sync-flags'], array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors));
self::assertSame('SyncToServerController::update()', $manifest->mirrors[0]->label());
}
public function testUsesBuilderReadsDivisFlag(): void
{
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $id === 7 && $key === '_et_pb_use_builder' ? 'on' : ''
);
self::assertTrue(DiviPages::usesBuilder(7));
self::assertFalse(DiviPages::usesBuilder(8));
}
public function testBuilderUrlOpensTheVisualBuilder(): void
{
Functions\when('get_permalink')->justReturn('https://k.de/start/');
Functions\when('add_query_arg')->alias(
static fn (string $key, string $value, string $url): string => $url . '?' . $key . '=' . $value
);
self::assertSame('https://k.de/start/?et_fb=1', DiviPages::builderUrl(new WP_Post(['ID' => 7])));
}
public function testClearPageCssRemovesThePagesStaticFiles(): void
{
\ET_Core_PageResource::$removed = [];
DiviPages::clearPageCss(7);
self::assertSame([[7, 'all', true]], \ET_Core_PageResource::$removed);
}
public function testIsLinkableFollowsDivisPublicPostTypes(): void
{
Functions\when('et_builder_get_public_post_types')->justReturn(['page' => 'Seiten', 'post' => 'Beiträge']);
self::assertTrue(DiviPages::isLinkable('page'));
self::assertFalse(DiviPages::isLinkable('product'));
}
- Step 2: Run tests to verify they fail
Run: vendor/bin/phpunit --filter DiviPagesTest
Expected: FAIL („Class … DiviPages not found“ bzw. „undefined method setFlags“).
- Step 3: Implementation
src/Integrations/Divi/Pages/DiviPages.php (vollständig):
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use RuntimeException;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Adapter "Seiten" (spec §3): the Divi 5 builder flags of an entry as the Visual Builder's own save sets them
* (mirror vb-sync-flags: _et_pb_use_divi_5 = "on", legacy _et_pb_use_builder = "on", _et_pb_show_page_creation =
* "off" so the builder skips its page creation flow), whether an entry uses the builder, its builder URL, the
* static CSS of one page and Divi's list of linkable post types. Ruling S11: update_post_meta() returns false for an
* unchanged value too, so setFlags() checks by reading the flags back. Used by DiviSaver, the Divi content creation,
* WpLinkTargets and PostPresenter.
*/
final class DiviPages implements DiviAdapter
{
public const FLAGS = [
'_et_pb_use_divi_5' => 'on',
'_et_pb_use_builder' => 'on',
'_et_pb_show_page_creation' => 'off',
];
public static function manifest(): Manifest
{
return new Manifest(
Manifest::PAGES,
calls: [
new Call('ET_Core_PageResource::remove_static_resources', 3),
new Call('et_builder_get_public_post_types', 0),
],
keys: ['_et_pb_use_divi_5', '_et_pb_use_builder', '_et_pb_show_page_creation', 'et_fb'],
mirrors: [
new Mirror(
'vb-sync-flags',
'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php',
'update',
'SyncToServerController',
'Builder-Kennzeichen einer Seite (_et_pb_use_divi_5, _et_pb_use_builder, _et_pb_show_page_creation)'
),
]
);
}
/** @throws RuntimeException When a flag does not read back as set. */
public static function setFlags(int $postId): void
{
foreach (self::FLAGS as $key => $value) {
update_post_meta($postId, $key, $value);
}
foreach (self::FLAGS as $key => $value) {
if (get_post_meta($postId, $key, true) !== $value) {
throw new RuntimeException(sprintf('Divi flag %s of post %d is not "%s".', $key, $postId, $value));
}
}
}
/** Whether Divi's builder flag marks $postId as a builder layout. */
public static function usesBuilder(int $postId): bool
{
return get_post_meta($postId, '_et_pb_use_builder', true) === 'on';
}
/** The entry opened in the Divi Visual Builder: its permalink with et_fb=1. */
public static function builderUrl(WP_Post|int $post): string
{
return (string) add_query_arg('et_fb', '1', (string) get_permalink($post));
}
/** Removes the static CSS files Divi generated for $postId (spec §5.6 step 5). */
public static function clearPageCss(int $postId): void
{
if (class_exists('ET_Core_PageResource')) {
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
}
}
/** Whether Divi offers a post_link_url_<type> option for $postType. Fail closed: without Divi's list, none. */
public static function isLinkable(string $postType): bool
{
if (! function_exists('et_builder_get_public_post_types')) {
return false;
}
$types = et_builder_get_public_post_types();
return is_array($types) && array_key_exists($postType, $types);
}
}
src/Integrations/Divi/Pages/DiviSaver.php:
- Klassen-Docblock: „Divi 5 flags (DiviFlags), Divi's static CSS cache“ → „Divi 5 flags and the page's static CSS (DiviPages)“; „Security::initialize() (Divi 5.2.1 Security.php:74-81)“ → „Security::initialize() (Divi's Security.php)“.
- In
save():static fn () => DiviFlags::set($postId)→static fn () => DiviPages::setFlags($postId); der CSS-Block wird
static function () use ($postId, $scope): void {
DiviPages::clearPageCss($postId);
// A global library entry is part of every page that shows it (spec §5.6 step 5).
if ($scope->clearsSiteCss()) {
DiviThemeBuilder::clearSiteCaches();
}
},
- In
saveNew():DiviFlags::set($postId);→DiviPages::setFlags($postId);.
src/Integrations/Divi/Pages/DiviPage.php, Methode builderUrl():
/** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */
public static function builderUrl(WP_Post|int $post): string
{
return DiviPages::builderUrl($post);
}
src/Integrations/Divi/Pages/WpLinkTargets.php: Import LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; NEVER = ['attachment', DiviLibraryStore::POST_TYPE]; im Konstruktor $this->linkable = $linkable ?? DiviPages::isLinkable(...);; die private Methode diviLinkable() entfällt; Docblock des Konstruktors: „null = DiviPages::isLinkable() (Divi's et_builder_get_public_post_types(), the list its link option uses)“.
src/Integrations/WordPress/Content/PostPresenter.php: Import LIT\MCP\Integrations\Divi\Pages\DiviPages;
public static function usesDivi(WP_Post $post): bool
{
return DiviPages::usesBuilder((int) $post->ID)
|| str_contains((string) $post->post_content, '<!-- wp:divi/');
}
DiviAdapters::CLASSES um DiviPages::class ergänzen (als erstes Element: Reihenfolge Seiten, Bibliothek, Theme Builder, Designsystem, Katalog). Aus DiviBoundaryTest::LEGACY streichen: Pages/DiviFlags.php, Pages/DiviPage.php, Pages/DiviSaver.php, Pages/WpLinkTargets.php, WordPress/Content/PostPresenter.php.
Restliche Verweise suchen und ersetzen: grep -rn "DiviFlags" src tests muss leer sein.
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'DiviPagesTest|DiviSaverTest|WpLinkTargetsTest|PostPresenterTest|DiviBoundaryTest'
Expected: PASS.
- Step 5: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
Commit wie in den Global Constraints mit Nachricht refactor: Divi pages adapter replaces DiviFlags.
Task 5: Adapter „Bibliothek“ (DiviLibraryStore, DiviRoles)
Files:
- Modify:
src/Integrations/Divi/Library/DiviLibraryStore.php,DiviRoles.php,LibraryItemSpec.php,NewLibraryEntry.php,src/Integrations/Divi/Release/DiviAdapters.php - Test:
tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php,LibraryItemSpecTest.php,DiviRolesTest.php,tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php
Interfaces:
-
Consumes: Task 2.
-
Produces:
DiviLibraryStore implements DiviAdaptermit KonstantenMODULE_TYPE = '_et_pb_module_type',EXCLUDED_OPTIONS = '_et_pb_excluded_global_options',BUILT_FOR = '_et_pb_built_for_post_type',TEMPLATE_TYPE = '_et_pb_template_type',DiviLibraryStore::metaFor(LibraryItemSpec $spec): array<string, string>(statisch; ersetztLibraryItemSpec::meta()),DiviLibraryStore::builtFor(int $id): string(statisch,''ohne Meta);DiviRoles implements DiviAdapter. Mirror-IDlibrary-submit-layout. -
Step 1: Write the failing tests
In LibraryItemSpecTest::testTermsAndMetaMatchTheBuilderEntries die Zeile self::assertEquals($expectedMeta, $spec->meta()); ersetzen durch self::assertEquals($expectedMeta, DiviLibraryStore::metaFor($spec)); (Import LIT\MCP\Integrations\Divi\Library\DiviLibraryStore).
In DiviLibraryStoreTest ergänzen (Importe LIT\MCP\Integrations\Divi\Release\DiviAdapters, …\Manifest):
public function testIsTheRegisteredLibraryAdapterWithTheSubmitLayoutMirror(): void
{
$manifest = DiviLibraryStore::manifest();
self::assertContains(DiviLibraryStore::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::LIBRARY, $manifest->area);
self::assertSame(['et_pb_layout'], $manifest->postTypes);
self::assertSame(['scope', 'layout_type', 'module_width', 'layout_category', 'layout_tag'], $manifest->taxonomies);
self::assertSame(['library-submit-layout'], array_map(static fn ($m): string => $m->id, $manifest->mirrors));
self::assertSame('et_pb_submit_layout()', $manifest->mirrors[0]->label());
}
public function testBuiltForReadsTheSourceType(): void
{
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $id === 1054 && $key === '_et_pb_built_for_post_type' ? 'post' : ''
);
self::assertSame('post', DiviLibraryStore::builtFor(1054));
self::assertSame('', DiviLibraryStore::builtFor(1055));
}
In DiviRolesTest ergänzen:
public function testIsTheRegisteredRolesAdapter(): void
{
self::assertContains(DiviRoles::class, \LIT\MCP\Integrations\Divi\Release\DiviAdapters::CLASSES);
self::assertSame('et_pb_is_allowed()', DiviRoles::manifest()->calls[0]->label());
}
- Step 2: Run tests to verify they fail
Run: vendor/bin/phpunit --filter 'DiviLibraryStoreTest|LibraryItemSpecTest|DiviRolesTest'
Expected: FAIL („undefined method metaFor/manifest/builtFor“).
- Step 3: Implementation
src/Integrations/Divi/Library/DiviLibraryStore.php:
class DiviLibraryStore implements DiviAdapter; ImporteLIT\MCP\Integrations\Divi\Release\Call,…\DiviAdapter,…\Manifest,…\Mirror.- Klassen-Docblock beginnt mit „Adapter "Bibliothek" (spec §3) for the Divi Library (E4, E15):“ und nennt statt „the meta Divi's et_pb_submit_layout() writes“ „the terms and meta Divi's et_pb_submit_layout() writes (mirror library-submit-layout: metaFor())“.
- Neue Konstanten und Methoden:
public const MODULE_TYPE = '_et_pb_module_type';
public const EXCLUDED_OPTIONS = '_et_pb_excluded_global_options';
public const BUILT_FOR = '_et_pb_built_for_post_type';
public const TEMPLATE_TYPE = '_et_pb_template_type';
public static function manifest(): Manifest
{
return new Manifest(
Manifest::LIBRARY,
calls: [new Call(self::GLOBAL_LAYOUT . '::is_global_layout_template', 1)],
postTypes: [self::POST_TYPE],
taxonomies: [...self::TAXONOMIES, 'layout_category', 'layout_tag'],
keys: [self::MODULE_TYPE, self::EXCLUDED_OPTIONS, self::BUILT_FOR, self::TEMPLATE_TYPE],
mirrors: [
new Mirror(
'library-submit-layout',
'includes/builder/shortcode-core.php',
'et_pb_submit_layout',
null,
'Terme und Meta neuer Bibliothekseinträge (scope, layout_type, module_width, _et_pb_module_type, '
. '_et_pb_excluded_global_options, _et_pb_built_for_post_type, _et_pb_template_type)'
),
]
);
}
/**
* The meta of a new entry as et_pb_submit_layout() writes it (compared with the real builder entries, fixtures
* stage3): _et_pb_module_type (modules), _et_pb_excluded_global_options = "[]" (global modules),
* _et_pb_built_for_post_type, _et_pb_template_type. _et_pb_row_layout is left out – Divi 5.2.1 stores
* "[object Object]" there (finding 5).
*
* @return array<string, string>
*/
public static function metaFor(LibraryItemSpec $spec): array
{
$meta = [];
if ($spec->moduleType !== null) {
$meta[self::MODULE_TYPE] = $spec->moduleType;
}
if ($spec->global && $spec->layoutType === 'module') {
$meta[self::EXCLUDED_OPTIONS] = '[]';
}
$meta[self::BUILT_FOR] = $spec->builtFor;
$meta[self::TEMPLATE_TYPE] = $spec->layoutType;
return $meta;
}
/** _et_pb_built_for_post_type of entry $id ('' when unset): the type an entry was built for. */
public static function builtFor(int $id): string
{
return (string) get_post_meta($id, self::BUILT_FOR, true);
}
- In
describe():get_post_meta($id, '_et_pb_module_type', true)→get_post_meta($id, self::MODULE_TYPE, true). - In
describeNew():foreach ($spec->meta() as $key => $value)→foreach (self::metaFor($spec) as $key => $value).
src/Integrations/Divi/Library/LibraryItemSpec.php: Methode meta() löschen; Klassen-Docblock: „Title, terms and meta of a new library entry …“ → „Title and terms of a new library entry, like Divi's et_pb_submit_layout() writes them (compared with the real builder entries, fixtures stage3): terms scope (global / non_global), layout_type, module_width, optional layout_category/layout_tag. The meta follows from DiviLibraryStore::metaFor(). Pure PHP.“
src/Integrations/Divi/Library/NewLibraryEntry.php, Methode builtFor():
$builtFor = DiviLibraryStore::builtFor((int) $page->post->ID);
src/Integrations/Divi/Library/DiviRoles.php: class DiviRoles implements DiviAdapter, Importe wie oben (ohne Mirror), Docblock „Adapter for Divi's role editor“ → „Adapter "Bibliothek" (spec §3) for Divi's role editor“ und „(Divi 5.2.1 builder/core.php:1128)“ → „(Divi's builder/core.php)“;
public static function manifest(): Manifest
{
return new Manifest(Manifest::LIBRARY, calls: [new Call('et_pb_is_allowed', 1)]);
}
DiviAdapters::CLASSES: nach DiviPages::class DiviLibraryStore::class, DiviRoles::class einfügen. Aus DiviBoundaryTest::LEGACY streichen: Library/DiviLibraryStore.php, Library/DiviRoles.php, Library/LibraryItemSpec.php, Library/NewLibraryEntry.php.
grep -rn "\->meta()" src tests muss leer sein.
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'DiviLibraryStoreTest|LibraryItemSpecTest|DiviRolesTest|NewLibraryEntry|LibrarySaveAbilityTest|LibraryMakeGlobalAbilityTest|DiviBoundaryTest'
Expected: PASS.
- Step 5: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
Commit wie in den Global Constraints mit Nachricht refactor: Divi library adapters with manifest and submit-layout mirror.
Task 6: Adapter „Theme Builder“ (DiviThemeBuilder)
Files:
- Modify:
src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php,TemplateConditions.php,Template.php(nur Docblocks),src/Integrations/Divi/DiviCompatibility.php(Theme-Builder-Prüfung),src/Integrations/Divi/Release/DiviAdapters.php - Test:
tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php,tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php
Interfaces:
-
Consumes: Task 2.
-
Produces:
DiviThemeBuilder implements DiviAdapter; Mirror-IDstb-api-save,tb-store-template,tb-load-setting-options. Entfällt:DiviThemeBuilder::FUNCTIONS,DiviThemeBuilder::missing(). -
Step 1: Write the failing test
In DiviThemeBuilderTest ergänzen (Importe LIT\MCP\Integrations\Divi\Release\Call, …\DiviAdapters, …\Manifest):
public function testIsTheRegisteredThemeBuilderAdapterWithItsMirrors(): void
{
$manifest = DiviThemeBuilder::manifest();
self::assertContains(DiviThemeBuilder::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::THEME_BUILDER, $manifest->area);
self::assertSame(DiviThemeBuilder::postTypes(), $manifest->postTypes);
self::assertSame(
['tb-api-save', 'tb-store-template', 'tb-load-setting-options'],
array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors)
);
self::assertContains('et_theme_builder_store_template()', array_map(
static fn (Call $call): string => $call->label(),
$manifest->calls
));
}
Tests, die missing() oder FUNCTIONS benutzen (grep -n "missing()\|FUNCTIONS" tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php), auf das beobachtbare Verhalten umstellen: Fehlt eine Divi-Funktion, wirft insertLayout()/storeTemplate() eine RuntimeException mit „Divi function missing“ – dieser Test bleibt, nur ohne Aufruf von missing().
- Step 2: Run test to verify it fails
Run: vendor/bin/phpunit --filter DiviThemeBuilderTest
Expected: FAIL („undefined method manifest()“).
- Step 3: Implementation
src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php:
class DiviThemeBuilder implements DiviAdapter; ImporteCall,DiviAdapter,Manifest,Mirror.- Klassen-Docblock beginnt mit „Adapter "Theme Builder" (spec §3; E7, E15; decisions note F14–F23; fixtures stage3/stage3b).“; der Satz über die Nachbildung lautet „the live Theme Builder post's template list, conditions and the enabled flag are mirrored from et_theme_builder_api_save() (admin-ajax only, not loaded in REST requests, F20) and et_theme_builder_store_template() – see the mirrors in manifest()“.
- Konstante
FUNCTIONSund Methodemissing()löschen;assertCanWrite():
private function assertCanWrite(string ...$functions): void
{
if (! current_user_can('edit_others_posts')) {
throw new RuntimeException('edit_others_posts missing – Divi would call wp_die()');
}
$missing = array_values(array_filter($functions, static fn (string $name): bool => ! function_exists($name)));
if ($missing !== []) {
throw new RuntimeException('Divi function missing: ' . implode(', ', $missing));
}
}
- Manifest:
public static function manifest(): Manifest
{
$dir = 'includes/builder/frontend-builder/theme-builder/';
return new Manifest(
Manifest::THEME_BUILDER,
calls: [
new Call('et_theme_builder_get_theme_builder_post_id', 2),
new Call('et_theme_builder_get_theme_builder_template_ids', 2),
new Call('et_theme_builder_get_template', 1),
new Call('et_theme_builder_insert_layout', 1),
new Call('et_theme_builder_store_template', 3),
new Call('et_theme_builder_get_flat_template_settings_options', 0),
new Call('et_theme_builder_clear_wp_cache', 1),
// store_template needs Divi's et_() helper (E15).
new Call('et_'),
new Call('ET_Core_PageResource::remove_static_resources', 3),
],
postTypes: self::postTypes(),
keys: [self::TEMPLATE_LIST, self::USE_ON, self::EXCLUDE_FROM, self::ENABLED, self::UNUSED, self::BACKUP_OPTION],
mirrors: [
new Mirror(
'tb-api-save',
$dir . 'api.php',
'et_theme_builder_api_save',
null,
'Vorlagenliste des Live-Theme-Builders (_et_template mit Sicherung et_tb_templates_backup_) und '
. '„ungenutzt“-Markierung'
),
new Mirror(
'tb-store-template',
$dir . 'theme-builder.php',
'et_theme_builder_store_template',
null,
'Bedingungen (_et_use_on, _et_exclude_from) und Aktiv-Schalter (_et_enabled) einer Vorlage'
),
new Mirror(
'tb-load-setting-options',
$dir . 'theme-builder.php',
'et_theme_builder_load_template_setting_options',
null,
'Zerlegen der Bedingungs-IDs (TemplateConditions)'
),
]
);
}
- Zeilenangaben hinter Divi-Dateinamen in den Docblocks dieser drei Dateien entfernen (die Quelle steht im Manifest):
sed -i -E 's/([A-Za-z]+\.php):[0-9]+(-[0-9]+)?(, *[0-9]+(-[0-9]+)?)*/\1/g' src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php src/Integrations/Divi/ThemeBuilder/Template.php src/Integrations/Divi/ThemeBuilder/TemplateConditions.php, danachgit difflesen: nur Kommentare dürfen sich geändert haben.
src/Integrations/Divi/ThemeBuilder/TemplateConditions.php, Klassen-Docblock: „split the way Divi reads it back (et_theme_builder_load_template_setting_options(): the last segment is the object)“ → „split the way Divi reads it back (mirror tb-load-setting-options in DiviThemeBuilder::manifest(): the last segment is the object)“.
src/Integrations/Divi/DiviCompatibility.php, Prüfung 'Theme Builder: Funktionen et_theme_builder_* und et_()' (bis Task 8, der die Datei neu schreibt):
'Theme Builder: Funktionen et_theme_builder_* und et_()' => static fn (): bool => array_filter(
DiviThemeBuilder::manifest()->calls,
static fn (Call $call): bool => $call->className() === null && ! function_exists($call->member())
) === [],
(Import LIT\MCP\Integrations\Divi\Release\Call.)
DiviAdapters::CLASSES: nach DiviRoles::class DiviThemeBuilder::class. Aus DiviBoundaryTest::LEGACY streichen: ThemeBuilder/DiviThemeBuilder.php.
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'DiviThemeBuilderTest|Tb|Template|ThemeBuilder|DiviCompatibilityTest|DiviBoundaryTest'
Expected: PASS.
- Step 5: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
Commit wie in den Global Constraints mit Nachricht refactor: Theme Builder adapter manifest with three mirrors.
Task 7: Adapter „Designsystem“ (DesignSystemReader, MediaUsageFinder)
Files:
- Modify:
src/Integrations/Divi/Design/DesignSystemReader.php,src/Integrations/Media/MediaUsageFinder.php,src/Integrations/Divi/Release/DiviAdapters.php - Test:
tests/Unit/Integrations/Divi/Design/DesignSystemReaderTest.php(neu),tests/Unit/Integrations/Media/MediaUsageFinderTest.php(unverändert grün),tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php
Interfaces:
-
Consumes: Task 2.
-
Produces:
DesignSystemReader implements DiviAdapter,DesignSystemReader::diviLogo(): string(statisch;''ohne Logo). -
Step 1: Write the failing test
tests/Unit/Integrations/Divi/Design/DesignSystemReaderTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Tests\Unit\TestCase;
final class DesignSystemReaderTest extends TestCase
{
public function testIsTheRegisteredDesignAdapter(): void
{
$manifest = DesignSystemReader::manifest();
self::assertContains(DesignSystemReader::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::DESIGN, $manifest->area);
self::assertSame(
['GlobalData::get_global_colors()', 'GlobalData::get_global_variables()', 'GlobalPreset::get_data()'],
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
);
self::assertSame(['et_divi'], $manifest->keys);
}
public function testDiviLogoReadsTheThemeOptions(): void
{
Functions\when('get_option')->alias(
static fn (string $name): mixed => $name === 'et_divi' ? ['divi_logo' => 'https://k.de/logo.svg'] : false
);
self::assertSame('https://k.de/logo.svg', DesignSystemReader::diviLogo());
}
public function testDiviLogoIsEmptyWithoutOptions(): void
{
Functions\when('get_option')->justReturn(false);
self::assertSame('', DesignSystemReader::diviLogo());
}
}
- Step 2: Run test to verify it fails
Run: vendor/bin/phpunit --filter DesignSystemReaderTest
Expected: FAIL („undefined method manifest()“).
- Step 3: Implementation
src/Integrations/Divi/Design/DesignSystemReader.php (vollständig):
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Packages\GlobalData\GlobalData;
use ET\Builder\Packages\GlobalData\GlobalPreset;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\Manifest;
defined('ABSPATH') || exit;
/**
* Adapter "Designsystem" (spec §3): reads the design system from Divi's own APIs once per request (spec §2
* principle 2 – no copy is ever stored), and the logo of Divi's theme options. Not final so abilities can be
* unit-tested with a mock.
*/
class DesignSystemReader implements DiviAdapter
{
/** Divi's theme options (one array option); customizer colors, fonts and the logo live there. */
private const OPTIONS = 'et_divi';
private ?DesignSystem $designSystem = null;
public static function manifest(): Manifest
{
return new Manifest(
Manifest::DESIGN,
calls: [
new Call(GlobalData::class . '::get_global_colors', 0),
new Call(GlobalData::class . '::get_global_variables', 0),
new Call(GlobalPreset::class . '::get_data', 0),
],
keys: [self::OPTIONS]
);
}
public function read(): DesignSystem
{
if ($this->designSystem === null) {
$colors = GlobalData::get_global_colors();
$variables = GlobalData::get_global_variables();
$presets = GlobalPreset::get_data();
$options = get_option(self::OPTIONS, []);
$this->designSystem = DesignSystem::fromDivi(
is_array($colors) ? $colors : [],
is_array($variables) ? $variables : [],
is_array($presets) ? $presets : [],
is_array($options) ? $options : []
);
}
return $this->designSystem;
}
/** The logo URL of Divi's theme options (Divi → Theme-Optionen → Logo), '' when none is set. */
public static function diviLogo(): string
{
$options = get_option(self::OPTIONS);
return is_array($options) && is_string($options['divi_logo'] ?? null) ? $options['divi_logo'] : '';
}
}
src/Integrations/Media/MediaUsageFinder.php: Import LIT\MCP\Integrations\Divi\Design\DesignSystemReader; die zwei Zeilen
$divi = get_option('et_divi');
$diviLogo = is_array($divi) && is_string($divi['divi_logo'] ?? null) ? $divi['divi_logo'] : '';
werden
$diviLogo = DesignSystemReader::diviLogo();
DiviAdapters::CLASSES: nach DiviThemeBuilder::class DesignSystemReader::class (vor den Katalog-Klassen). Aus DiviBoundaryTest::LEGACY streichen: Design/DesignSystemReader.php, Media/MediaUsageFinder.php. Übrig bleibt nur Integrations/Divi/DiviCompatibility.php (Task 8).
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'DesignSystemReaderTest|MediaUsageFinderTest|DiviDesignSystemAbilityTest|DiviBoundaryTest'
Expected: PASS.
- Step 5: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
Commit wie in den Global Constraints mit Nachricht refactor: Divi design adapter, logo lookup moves out of media.
Task 8: Manifest-Prüfung, DiviCompatibility, Soll-Fingerabdrücke
Files:
- Create:
src/Integrations/Divi/Release/ManifestCheck.php,DiviEnvironment.php,DiviReference.php,fingerprints.json(erzeugt),tests/Fixtures/divi/reference/*.txt(erzeugt) - Modify:
src/Integrations/Divi/DiviCompatibility.php(neu geschrieben),src/Integrations/Core/EnvironmentProbe.php - Test:
tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php(neu),DiviReferenceTest.php(neu),tests/Unit/Integrations/Divi/DiviCompatibilityTest.php(neu geschrieben),tests/Unit/Integrations/Core/EnvironmentProbeTest.php,DiviBoundaryTest.php(LEGACY entfällt)
Interfaces:
-
Consumes:
SourceFingerprint(Task 1),Manifest/Call/Mirror/DiviAdapters(Task 2),DiviModules::knowsBlock()(Task 3). -
Produces:
ManifestCheck::__construct(?array $manifests = null, ?array $expected = null, ?Closure $current = null, ?Closure $knowsBlock = null);codeChecks(): array<string, Closure(): bool>(Aufrufe mit Argumentzahl, Konstanten),mirrorChecks(): array<string, Closure(): bool>,registrationChecks(): array<string, Closure(): bool>; statischfailed(array $checks): list<string>,accepts(Call $call): bool. Labels:"<Bereich>: <Call-Label>","<Bereich>: Konstante <NAME>","<Bereich>: Post-Typ <name>","<Bereich>: Taxonomie <name>","<Bereich>: Block-Typ <name>","<Bereich>: Nachbau von <Mirror-Label> – <what>".DiviEnvironment::version(): ?string,DiviEnvironment::themeDir(): ?string, KonstanteTEMPLATE = 'Divi'.DiviReference::FILE,expected(?string $file = null): array<string, string>,writeExpected(array $hashes, ?string $file = null): void,sourceDir(): ?string,readSource(string $id, string $dir): ?string,writeSource(string $id, string $source, string $dir): void,current(string $themeDir): array<string, SourceFingerprint>(wirftRuntimeException).DiviCompatibility::defaults(?ManifestCheck $check = null): array= Code + Registrierungen + Fingerabdrücke.
-
Step 1: Write the failing tests
tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use ArrayObject;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
final class ManifestCheckTest extends TestCase
{
private function check(?\Closure $current = null): ManifestCheck
{
$manifests = [
'A' => new Manifest(
Manifest::PAGES,
calls: [
new Call('strlen', 1),
new Call('lit_mcp_missing_function', 0),
new Call(ArrayObject::class . '::count', 0),
new Call('Missing\Klass::run', 0),
new Call('str_replace'),
],
constants: ['PHP_VERSION', 'LIT_MCP_MISSING_CONSTANT'],
postTypes: ['et_pb_layout'],
taxonomies: ['scope'],
blockTypes: ['divi/global-layout'],
mirrors: [
new Mirror('same', 'a.php', 'a', null, 'gleich'),
new Mirror('changed', 'b.php', 'b', null, 'geändert'),
new Mirror('unreadable', 'c.php', 'c', null, 'fehlt'),
new Mirror('no-expectation', 'd.php', 'd', null, 'ohne Soll'),
]
),
];
return new ManifestCheck(
$manifests,
['same' => 'h1', 'changed' => 'h2', 'unreadable' => 'h3'],
$current ?? static fn (Mirror $mirror): ?string => ['same' => 'h1', 'changed' => 'hX', 'no-expectation' => 'h4'][$mirror->id] ?? null,
static fn (string $name): bool => false
);
}
public function testCodeChecksCoverExistenceAndArgumentCount(): void
{
self::assertSame(
[
'Seiten: strlen()',
'Seiten: lit_mcp_missing_function()',
'Seiten: ArrayObject::count()',
'Seiten: Klass::run()',
'Seiten: str_replace()',
'Seiten: Konstante PHP_VERSION',
'Seiten: Konstante LIT_MCP_MISSING_CONSTANT',
],
array_keys($this->check()->codeChecks())
);
self::assertSame(
['Seiten: lit_mcp_missing_function()', 'Seiten: Klass::run()', 'Seiten: Konstante LIT_MCP_MISSING_CONSTANT'],
ManifestCheck::failed($this->check()->codeChecks())
);
}
public function testAcceptsChecksTheArgumentCountTheAdapterPasses(): void
{
self::assertTrue(ManifestCheck::accepts(new Call('strlen', 1)));
self::assertFalse(ManifestCheck::accepts(new Call('strlen', 2)), 'strlen takes one argument');
self::assertFalse(ManifestCheck::accepts(new Call('str_replace', 2)), 'str_replace needs three');
self::assertTrue(ManifestCheck::accepts(new Call('sprintf', 5)), 'variadic');
self::assertTrue(ManifestCheck::accepts(new Call('str_replace')), 'existence only');
}
public function testMirrorChecksCompareWithTheExpectedFingerprints(): void
{
$checks = $this->check()->mirrorChecks();
self::assertSame(
[
'Seiten: Nachbau von a() – gleich',
'Seiten: Nachbau von b() – geändert',
'Seiten: Nachbau von c() – fehlt',
'Seiten: Nachbau von d() – ohne Soll',
],
array_keys($checks)
);
self::assertSame(
['Seiten: Nachbau von b() – geändert', 'Seiten: Nachbau von c() – fehlt', 'Seiten: Nachbau von d() – ohne Soll'],
ManifestCheck::failed($checks)
);
}
public function testUnreadableMirrorIsAFailedCheck(): void
{
$check = $this->check(static function (): ?string {
throw new RuntimeException('Divi file gone');
});
self::assertCount(4, ManifestCheck::failed($check->mirrorChecks()));
}
public function testRegistrationChecksAskWordPressAndDivi(): void
{
Functions\when('post_type_exists')->justReturn(true);
Functions\when('taxonomy_exists')->justReturn(false);
$checks = $this->check()->registrationChecks();
self::assertSame(
['Seiten: Post-Typ et_pb_layout', 'Seiten: Taxonomie scope', 'Seiten: Block-Typ divi/global-layout'],
array_keys($checks)
);
self::assertSame(
['Seiten: Taxonomie scope', 'Seiten: Block-Typ divi/global-layout'],
ManifestCheck::failed($checks)
);
}
}
tests/Unit/Integrations/Divi/Release/DiviReferenceTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
use PHPUnit\Framework\TestCase;
final class DiviReferenceTest extends TestCase
{
public function testEveryMirrorHasAnExpectedFingerprintAndAReferenceSource(): void
{
$ids = array_map(static fn ($mirror): string => $mirror->id, DiviAdapters::mirrors());
sort($ids);
$expected = DiviReference::expected();
self::assertSame($ids, array_keys($expected));
foreach ($expected as $id => $hash) {
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash, $id);
self::assertNotNull(DiviReference::readSource($id, (string) DiviReference::sourceDir()), $id);
}
}
public function testWritesAndReadsExpectedFingerprintsSorted(): void
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit');
try {
DiviReference::writeExpected(['b' => str_repeat('b', 64), 'a' => str_repeat('a', 64)], $file);
self::assertSame(['a' => str_repeat('a', 64), 'b' => str_repeat('b', 64)], DiviReference::expected($file));
self::assertStringEndsWith("\n", (string) file_get_contents($file));
} finally {
unlink($file);
}
}
public function testMissingOrBrokenFileMeansNoExpectations(): void
{
self::assertSame([], DiviReference::expected('/nonexistent/fingerprints.json'));
}
}
tests/Unit/Integrations/Divi/DiviCompatibilityTest.php (vollständig ersetzen):
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
use LIT\MCP\Integrations\Divi\DiviCompatibility;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class DiviCompatibilityTest extends TestCase
{
public function testListsFailedAndThrowingChecks(): void
{
$compatibility = new DiviCompatibility([
'A' => static fn (): bool => true,
'B' => static fn (): bool => false,
'C' => static function (): bool {
throw new RuntimeException('Divi changed');
},
]);
self::assertSame(['B', 'C'], $compatibility->problems());
}
public function testDefaultsAreCodeRegistrationsAndFingerprints(): void
{
$check = new ManifestCheck(
['X' => new Manifest(
Manifest::LIBRARY,
calls: [new Call('strlen', 1)],
postTypes: ['et_pb_layout'],
mirrors: [new Mirror('m', 'a.php', 'a', null, 'Terme')]
)],
[],
static fn (): ?string => null,
static fn (): bool => true
);
self::assertSame(
['Bibliothek: strlen()', 'Bibliothek: Post-Typ et_pb_layout', 'Bibliothek: Nachbau von a() – Terme'],
array_keys(DiviCompatibility::defaults($check))
);
}
public function testRealDefaultsCoverEveryAdapterArea(): void
{
$labels = implode("\n", array_keys(DiviCompatibility::defaults()));
foreach ([Manifest::PAGES, Manifest::LIBRARY, Manifest::THEME_BUILDER, Manifest::DESIGN, Manifest::CATALOG] as $area) {
self::assertStringContainsString($area . ': ', $labels);
}
self::assertStringContainsString('Theme Builder: Nachbau von et_theme_builder_api_save()', $labels);
self::assertStringContainsString('Katalog: Block-Typ divi/global-layout', $labels);
}
}
tests/Unit/Integrations/Core/EnvironmentProbeTest.php: bleibt wie er ist (der Konstruktor von DiviCompatibility nimmt weiter label => check).
In DiviBoundaryTest: Konstante LEGACY, deren Prüfung in testOnlyAdaptersTouchDivi und den Test testLegacyFilesStillTouchDivi entfernen.
- Step 2: Run tests to verify they fail
Run: vendor/bin/phpunit --filter 'ManifestCheckTest|DiviReferenceTest|DiviCompatibilityTest|DiviBoundaryTest'
Expected: FAIL (Klassen fehlen; DiviBoundaryTest meldet DiviCompatibility.php).
- Step 3: Implementation
src/Integrations/Divi/Release/DiviEnvironment.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** The installed Divi theme: version (the parent's for a child theme) and directory; null when Divi is not the template. */
final class DiviEnvironment
{
public const TEMPLATE = 'Divi';
public static function version(): ?string
{
$theme = wp_get_theme();
if ($theme->get_template() !== self::TEMPLATE) {
return null;
}
$parent = $theme->parent();
return (string) ($parent ? $parent->get('Version') : $theme->get('Version'));
}
public static function themeDir(): ?string
{
return wp_get_theme()->get_template() === self::TEMPLATE ? (string) get_template_directory() : null;
}
}
src/Integrations/Divi/Release/DiviReference.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* The expected fingerprints of the mirrors (fingerprints.json, shipped with the plugin) and their reference sources
* (tests/Fixtures/divi/reference/<id>.txt, development only – not in the plugin package). Written by
* wp lit-mcp divi-check --update-reference.
*/
final class DiviReference
{
public const FILE = __DIR__ . '/fingerprints.json';
/** @return array<string, string> Mirror ID => sha256; [] when the file is missing or broken. */
public static function expected(?string $file = null): array
{
$file ??= self::FILE;
$raw = is_file($file) && is_readable($file) ? file_get_contents($file) : false;
$data = is_string($raw) ? json_decode($raw, true) : null;
if (! is_array($data)) {
return [];
}
$expected = [];
foreach ($data as $id => $hash) {
if (is_string($id) && is_string($hash)) {
$expected[$id] = $hash;
}
}
ksort($expected);
return $expected;
}
/**
* @param array<string, string> $hashes Mirror ID => sha256.
* @throws RuntimeException
*/
public static function writeExpected(array $hashes, ?string $file = null): void
{
ksort($hashes);
$json = json_encode($hashes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (! is_string($json) || file_put_contents($file ?? self::FILE, $json . "\n") === false) {
throw new RuntimeException('fingerprints.json not written');
}
}
/** tests/Fixtures/divi/reference of a development checkout; null in an installed plugin. */
public static function sourceDir(): ?string
{
$dir = dirname(__DIR__, 4) . '/tests/Fixtures/divi/reference';
return is_dir($dir) ? $dir : null;
}
public static function readSource(string $id, string $dir): ?string
{
$file = $dir . '/' . $id . '.txt';
$source = is_file($file) ? file_get_contents($file) : false;
return is_string($source) ? $source : null;
}
/** @throws RuntimeException */
public static function writeSource(string $id, string $source, string $dir): void
{
if (file_put_contents($dir . '/' . $id . '.txt', $source . "\n") === false) {
throw new RuntimeException(sprintf('reference source %s not written', $id));
}
}
/**
* Fingerprints of every mirror in the installed Divi.
*
* @return array<string, SourceFingerprint>
* @throws RuntimeException When a mirrored function cannot be read.
*/
public static function current(string $themeDir): array
{
$prints = [];
foreach (DiviAdapters::mirrors() as $mirror) {
$prints[$mirror->id] = SourceFingerprint::of($themeDir . '/' . $mirror->file, $mirror->function, $mirror->class);
}
return $prints;
}
}
Hinweis: readSource() liefert den Text mit abschließendem Zeilenumbruch (so geschrieben); --diff (Task 14) vergleicht zeilenweise und ignoriert ihn.
src/Integrations/Divi/Release/ManifestCheck.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use ReflectionFunction;
use ReflectionMethod;
use Throwable;
defined('ABSPATH') || exit;
/**
* The checks of all adapter manifests (spec §4.3), as label => check. Code checks (calls with the argument count
* the adapter passes, constants) and mirror checks decide the release gate; registration checks (post types,
* taxonomies, block types) depend on the request context (stage3 finding 6) and only show in reports.
*/
final class ManifestCheck
{
/** @var array<string, Manifest> */
private array $manifests;
/** @var Closure(Mirror): ?string */
private Closure $current;
/** @var Closure(string): bool */
private Closure $knowsBlock;
/**
* @param array<string, Manifest>|null $manifests null = DiviAdapters::manifests().
* @param array<string, string>|null $expected Mirror ID => sha256; null = DiviReference::expected().
* @param (Closure(Mirror): ?string)|null $current Hash of the installed mirror, null = unreadable;
* null = SourceFingerprint in the Divi theme directory.
* @param (Closure(string): bool)|null $knowsBlock null = DiviModules::knowsBlock().
*/
public function __construct(
?array $manifests = null,
private ?array $expected = null,
?Closure $current = null,
?Closure $knowsBlock = null
) {
$this->manifests = $manifests ?? DiviAdapters::manifests();
$this->current = $current ?? self::installed(...);
$this->knowsBlock = $knowsBlock ?? DiviModules::knowsBlock(...);
}
/** @return array<string, Closure(): bool> */
public function codeChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->calls as $call) {
$checks[$manifest->area . ': ' . $call->label()] = static fn (): bool => self::accepts($call);
}
foreach ($manifest->constants as $constant) {
$checks[$manifest->area . ': Konstante ' . $constant] = static fn (): bool => defined($constant);
}
}
return $checks;
}
/** @return array<string, Closure(): bool> */
public function mirrorChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->mirrors as $mirror) {
$label = sprintf('%s: Nachbau von %s – %s', $manifest->area, $mirror->label(), $mirror->what);
$checks[$label] = function () use ($mirror): bool {
$expected = ($this->expected ??= DiviReference::expected())[$mirror->id] ?? null;
return $expected !== null && ($this->current)($mirror) === $expected;
};
}
}
return $checks;
}
/** @return array<string, Closure(): bool> */
public function registrationChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->postTypes as $type) {
$checks[$manifest->area . ': Post-Typ ' . $type] = static fn (): bool => post_type_exists($type);
}
foreach ($manifest->taxonomies as $taxonomy) {
$checks[$manifest->area . ': Taxonomie ' . $taxonomy] = static fn (): bool => taxonomy_exists($taxonomy);
}
foreach ($manifest->blockTypes as $block) {
$checks[$manifest->area . ': Block-Typ ' . $block] = fn (): bool => ($this->knowsBlock)($block);
}
}
return $checks;
}
/**
* @param array<string, callable(): bool> $checks
* @return list<string> Labels of the checks that failed or threw.
*/
public static function failed(array $checks): array
{
$failed = [];
foreach ($checks as $label => $check) {
try {
$ok = (bool) $check();
} catch (Throwable) {
$ok = false;
}
if (! $ok) {
$failed[] = (string) $label;
}
}
return $failed;
}
/** The function or method exists and takes the number of arguments the adapter passes. */
public static function accepts(Call $call): bool
{
$class = $call->className();
if ($class === null) {
if (! function_exists($call->member())) {
return false;
}
$reflection = new ReflectionFunction($call->member());
} else {
if (! method_exists($class, $call->member())) {
return false;
}
$reflection = new ReflectionMethod($class, $call->member());
}
return $call->args === null
|| ($reflection->getNumberOfRequiredParameters() <= $call->args
&& ($reflection->isVariadic() || $reflection->getNumberOfParameters() >= $call->args));
}
private static function installed(Mirror $mirror): ?string
{
$dir = DiviEnvironment::themeDir();
return $dir === null
? null
: SourceFingerprint::of($dir . '/' . $mirror->file, $mirror->function, $mirror->class)->hash;
}
}
(installed() wirft bei unlesbarer Datei; failed() wertet das als rot – Test testUnreadableMirrorIsAFailedCheck.)
src/Integrations/Divi/DiviCompatibility.php (vollständig ersetzen):
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
defined('ABSPATH') || exit;
/**
* Live check of the Divi internals LIT MCP relies on (spec §4.3): every check of every adapter manifest – calls
* with their argument count, constants, registrations and the fingerprints of the mirrors. lit-site-status shows
* the failed checks, so a Divi update that moves one of them is visible before a tool reports "unsupported".
*/
final class DiviCompatibility
{
/** @param array<string, callable(): bool>|null $checks Label => check; null = defaults(). */
public function __construct(private ?array $checks = null)
{
}
/** @return list<string> Labels of the checks that failed or threw. */
public function problems(): array
{
return ManifestCheck::failed($this->checks ?? self::defaults());
}
/** @return array<string, callable(): bool> */
public static function defaults(?ManifestCheck $check = null): array
{
$check ??= new ManifestCheck();
return array_merge($check->codeChecks(), $check->registrationChecks(), $check->mirrorChecks());
}
}
src/Integrations/Core/EnvironmentProbe.php: Import LIT\MCP\Integrations\Divi\Release\DiviEnvironment; in components():
$diviVersion = DiviEnvironment::version();
$isDivi = $diviVersion !== null;
(die Zeilen $theme = wp_get_theme(); $isDivi = …; $parent = …; $diviVersion = … entfallen).
- Step 4: Soll-Fingerabdrücke und Referenzquellen gegen Divi 5.2.1 erzeugen
mkdir -p tests/Fixtures/divi/reference
cd /home/janniclas/Projekte/WordPress && ddev wp eval '
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
$prints = DiviReference::current((string) DiviEnvironment::themeDir());
DiviReference::writeExpected(array_map(static fn ($p) => $p->hash, $prints));
foreach ($prints as $id => $print) {
DiviReference::writeSource($id, $print->source, (string) DiviReference::sourceDir());
echo $id, " ", $print->startLine, "-", $print->endLine, "\n";
}'
Expected: fünf Zeilen (tb-api-save, tb-store-template, tb-load-setting-options, library-submit-layout, vb-sync-flags) mit plausiblen Zeilenbereichen (z. B. et_theme_builder_api_save ab Zeile 160 in api.php, SyncToServerController::update ab Zeile 56); src/Integrations/Divi/Release/fingerprints.json enthält fünf Hashes. Jede tests/Fixtures/divi/reference/<id>.txt beginnt mit function <name>( und endet mit }.
- Step 5: Live-Prüfung in DDEV
cd /home/janniclas/Projekte/WordPress && ddev wp eval 'print_r((new LIT\MCP\Integrations\Divi\DiviCompatibility())->problems());'
Expected: Array ( ). Meldet sie einen Aufruf mit Argumentzahl (…: Klasse::methode()), die Signatur in Divi 5.2.1 nachlesen und die Argumentzahl im Manifest an den tatsächlichen Aufruf im Adapter angleichen (nicht umgekehrt). Taxonomien können unter WP-CLI fehlen (Befund 6) – dann dieselbe Prüfung über MCP bestätigen: timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/tb-smoke.sh prüft problems == [] in einer MCP-Anfrage.
- Step 6: Run tests, Lint, Commit
Run: composer test && composer lint
Expected: grün (auch DiviReferenceTest mit den erzeugten Dateien und DiviBoundaryTest ohne LEGACY).
git add src tests
Commit wie in den Global Constraints mit Nachricht feat: manifest checks, Divi compatibility from manifests, reference fingerprints.
Task 9: Freigabe-Zustand (Status, Store, Resolver, Gate)
Files:
- Create:
src/Integrations/Divi/Release/DiviReleases.php,ReleaseState.php,ReleaseStatus.php,ReleaseStore.php,ReleaseResolver.php,ReleaseGate.php,DiviLocked.php - Modify:
tests/Unit/TestCase.php - Test:
tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php,ReleaseStoreTest.php,ReleaseResolverTest.php,ReleaseGateTest.php
Interfaces:
-
Consumes:
ManifestCheck(codeChecks(),mirrorChecks(),failed()),DiviEnvironment::version()/themeDir(),DiviReference::FILE,DiviAdapters::mirrors()(Task 8);Errors::make();Plugin::VERSION. -
Produces:
DiviReleases::TESTED = ['5.2.1'].enum ReleaseState: string { Released = 'released'; Untested = 'untested'; Incompatible = 'incompatible'; }ReleaseStatus::__construct(ReleaseState $state, string $version, ?string $source = null, array $problems = [], ?array $release = null), KonstantenSOURCE_CODE = 'code',SOURCE_DATABASE = 'database';released(string $version, string $source = 'code'): self,writable(): bool,versionLabel(): string,refusal(): WP_Error(Codesdivi_untested/divi_incompatible, HTTP 423),notice(): string,toArray(): array{state: string, version: string, source: ?string, released_at: ?string, released_by: ?int, problems: list<string>}.ReleaseStore::OPTION = 'lit_mcp_divi_releases';entries(): list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string, report_id: string}>,find(string $diviVersion, string $pluginVersion): ?array,add(string $diviVersion, string $pluginVersion, int $userId, string $reportId): void,withdraw(string $diviVersion, string $pluginVersion): bool.ReleaseResolver::CACHE = 'lit_mcp_divi_state';__construct(?ManifestCheck $check = null, ?ReleaseStore $store = null, ?Closure $version = null, ?Closure $stateKey = null);resolve(): ReleaseStatus; statischstateKey(string $version, ?string $themeDir = null): string.ReleaseGate::status(): ReleaseStatus,useResolver(?Closure $resolver): void,forget(): void,assertWritable(?int $postId = null): void(wirftDiviLocked),whileProbing(int $postId, callable $write): mixed.DiviLocked extends RuntimeException:public readonly ReleaseStatus $status,toError(): WP_Error, statischin(Throwable $e): ?DiviLocked.
-
Step 1: Write the failing tests
tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;
final class ReleaseStatusTest extends TestCase
{
public function testOnlyAReleasedVersionIsWritable(): void
{
self::assertTrue(ReleaseStatus::released('5.2.1')->writable());
self::assertFalse((new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->writable());
self::assertFalse((new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['x']))->writable());
}
public function testUntestedRefusalNamesTheVersionAndTheWayOut(): void
{
$payload = self::assertToolError((new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->refusal(), 'divi_untested');
self::assertSame(
'Achtung: Divi 5.3.0 ist auf dieser Website noch nicht für LIT MCP freigegeben. Schreibende '
. 'Divi-Werkzeuge sind gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung '
. 'ausführt und die Version freigibt. Es wurde nichts geändert.',
$payload['message']
);
self::assertStringContainsString('lesende Divi-Werkzeuge funktionieren weiter', $payload['hint']);
}
public function testIncompatibleRefusalListsTheProblems(): void
{
$status = new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['Theme Builder: Nachbau von x()']);
$payload = self::assertToolError($status->refusal(), 'divi_incompatible');
self::assertSame(
'Divi 5.3.0 hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind gesperrt, bis LIT MCP '
. 'aktualisiert wurde. Es wurde nichts geändert.',
$payload['message']
);
self::assertSame(['Theme Builder: Nachbau von x()'], $payload['problems']);
}
public function testNoticesForReadingTools(): void
{
self::assertSame(
'Achtung: Divi 5.3.0 ist für LIT MCP ungetestet; Lesen funktioniert, Schreiben ist gesperrt.',
(new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->notice()
);
self::assertSame(
'Achtung: Divi (Version unbekannt) ist für LIT MCP inkompatibel; Lesen funktioniert, Schreiben ist gesperrt.',
(new ReleaseStatus(ReleaseState::Incompatible, ''))->notice()
);
}
public function testAReleasedStatusHasNoRefusal(): void
{
$this->expectException(LogicException::class);
ReleaseStatus::released('5.2.1')->refusal();
}
public function testToArrayForSiteStatus(): void
{
$status = new ReleaseStatus(
ReleaseState::Released,
'5.3.0',
ReleaseStatus::SOURCE_DATABASE,
[],
['released_at' => '2026-10-01T08:00:00+00:00', 'user_id' => 1]
);
self::assertSame(
['state' => 'released', 'version' => '5.3.0', 'source' => 'database',
'released_at' => '2026-10-01T08:00:00+00:00', 'released_by' => 1, 'problems' => []],
$status->toArray()
);
}
}
tests/Unit/Integrations/Divi/Release/ReleaseStoreTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Tests\Unit\TestCase;
final class ReleaseStoreTest extends TestCase
{
/** @var array<string, mixed> */
private array $options = [];
protected function setUp(): void
{
parent::setUp();
$this->options = [];
Functions\when('get_option')->alias(fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default);
Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool {
$this->options[$name] = $value;
self::assertFalse($autoload, 'releases are read only by the gate – no autoload');
return true;
});
}
public function testAddsFindsAndWithdrawsPerDiviAndPluginVersion(): void
{
$store = new ReleaseStore();
$store->add('5.3.0', '0.6.0', 1, 'r1');
$store->add('5.3.0', '0.6.0', 2, 'r2');
self::assertCount(1, $store->entries(), 'one entry per version pair');
self::assertSame(2, $store->find('5.3.0', '0.6.0')['user_id'] ?? null);
self::assertNull($store->find('5.3.0', '0.7.0'), 'another plugin version needs its own check');
self::assertTrue($store->withdraw('5.3.0', '0.6.0'));
self::assertFalse($store->withdraw('5.3.0', '0.6.0'));
self::assertSame([], $store->entries());
}
public function testIgnoresBrokenRows(): void
{
$this->options[ReleaseStore::OPTION] = ['x', ['divi_version' => '5.3.0'], [
'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3, 'released_at' => 'd', 'report_id' => 'r',
]];
self::assertSame(['5.2.2'], array_column((new ReleaseStore())->entries(), 'divi_version'));
}
}
tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ReleaseResolverTest extends TestCase
{
/** @var array<string, mixed> */
private array $transients = [];
protected function setUp(): void
{
parent::setUp();
$this->transients = [];
Functions\when('get_transient')->alias(fn (string $key): mixed => $this->transients[$key] ?? false);
Functions\when('set_transient')->alias(function (string $key, mixed $value): bool {
$this->transients[$key] = $value;
return true;
});
}
/**
* @param array<string, callable(): bool> $code
* @param array<string, callable(): bool> $mirrors
*/
private function resolver(string $version, array $code = [], array $mirrors = [], ?array $entry = null): ReleaseResolver
{
$check = Mockery::mock(ManifestCheck::class);
$check->allows('codeChecks')->andReturn($code);
$check->allows('mirrorChecks')->andReturn($mirrors);
$store = Mockery::mock(ReleaseStore::class);
$store->allows('find')->with($version, Plugin::VERSION)->andReturn($entry);
return new ReleaseResolver($check, $store, static fn (): ?string => $version, static fn (string $v): string => 'key-' . $v);
}
public function testATestedVersionWithGreenChecksIsReleasedFromCode(): void
{
$status = $this->resolver('5.2.1')->resolve();
self::assertSame(ReleaseState::Released, $status->state);
self::assertSame('code', $status->source);
}
public function testAnUnknownVersionIsUntestedUnlessTheDatabaseReleasedIt(): void
{
self::assertSame(ReleaseState::Untested, $this->resolver('5.3.0')->resolve()->state);
$released = $this->resolver('5.3.0', entry: ['user_id' => 1, 'released_at' => 'd'])->resolve();
self::assertSame(ReleaseState::Released, $released->state);
self::assertSame('database', $released->source);
}
public function testAFailedCheckMakesEvenATestedVersionIncompatible(): void
{
$status = $this->resolver('5.2.1', ['Seiten: et_x()' => static fn (): bool => false])->resolve();
self::assertSame(ReleaseState::Incompatible, $status->state);
self::assertSame(['Seiten: et_x()'], $status->problems);
}
public function testMirrorResultsAreCachedByStateKey(): void
{
$calls = 0;
$mirrors = ['Theme Builder: Nachbau von y()' => static function () use (&$calls): bool {
++$calls;
return false;
}];
$first = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();
$second = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();
self::assertSame(1, $calls, 'the second resolve reads the transient');
self::assertSame(ReleaseState::Incompatible, $second->state);
self::assertSame($first->problems, $second->problems);
self::assertSame('key-5.2.1', $this->transients[ReleaseResolver::CACHE]['key']);
$this->resolver('5.2.2', mirrors: $mirrors)->resolve();
self::assertSame(2, $calls, 'another state key checks again');
}
public function testWithoutDiviTheVersionIsEmptyAndUntested(): void
{
self::assertSame(ReleaseState::Untested, $this->resolver('')->resolve()->state);
}
}
(Der Test testWithoutDiviTheVersionIsEmptyAndUntested gibt '' als Version – resolver() übergibt die Closure static fn (): ?string => $version; der Resolver macht aus null ebenfalls ''.)
tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
final class ReleaseGateTest extends TestCase
{
private function untested(): void
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
}
public function testTheTestCaseDefaultIsReleased(): void
{
self::assertTrue(ReleaseGate::status()->writable());
ReleaseGate::assertWritable(7);
}
public function testAssertWritableThrowsTheLockWithTheStatus(): void
{
$this->untested();
try {
ReleaseGate::assertWritable(7);
self::fail('an untested Divi must lock writes');
} catch (DiviLocked $locked) {
self::assertSame('5.3.0', $locked->status->version);
self::assertToolError($locked->toError(), 'divi_untested');
}
}
public function testProbingAllowsOnlyThatPostAndOnlyMeanwhile(): void
{
$this->untested();
$result = ReleaseGate::whileProbing(41, static function (): string {
ReleaseGate::assertWritable(41);
return 'written';
});
self::assertSame('written', $result);
foreach ([static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertWritable(42)),
static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertWritable()),
static fn () => ReleaseGate::assertWritable(41)] as $attempt) {
try {
$attempt();
self::fail('only post 41, only while probing');
} catch (DiviLocked) {
self::addToAssertionCount(1);
}
}
}
public function testProbingEndsEvenWhenTheWriteThrows(): void
{
$this->untested();
try {
ReleaseGate::whileProbing(41, static function (): never {
throw new RuntimeException('boom');
});
} catch (RuntimeException) {
}
$this->expectException(DiviLocked::class);
ReleaseGate::assertWritable(41);
}
public function testStatusIsComputedOncePerRequestUntilForgotten(): void
{
$calls = 0;
ReleaseGate::useResolver(static function () use (&$calls): ReleaseStatus {
++$calls;
return ReleaseStatus::released('5.2.1');
});
ReleaseGate::status();
ReleaseGate::status();
self::assertSame(1, $calls);
ReleaseGate::forget();
ReleaseGate::status();
self::assertSame(2, $calls);
}
public function testAFailingResolverLocksWrites(): void
{
ReleaseGate::useResolver(static function (): never {
throw new RuntimeException('theme API gone');
});
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$status = ReleaseGate::status();
} finally {
ini_set('error_log', (string) $previous);
}
self::assertSame(ReleaseState::Incompatible, $status->state);
self::assertStringContainsString('theme API gone', (string) file_get_contents($log));
unlink($log);
$this->expectException(DiviLocked::class);
ReleaseGate::assertWritable();
}
public function testFindsTheLockInsideAWrappedFailure(): void
{
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
$wrapped = WriteFailed::rolledBack('Nicht gespeichert.', new RuntimeException('draft', 0, $locked));
self::assertSame($locked, DiviLocked::in($wrapped));
self::assertNull(DiviLocked::in(new RuntimeException('other')));
}
}
- Step 2: Run tests to verify they fail
Run: vendor/bin/phpunit --filter 'ReleaseStatusTest|ReleaseStoreTest|ReleaseResolverTest|ReleaseGateTest'
Expected: FAIL (Klassen fehlen).
- Step 3: Implementation
src/Integrations/Divi/Release/DiviReleases.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* Divi versions checked with this plugin version (spec §5.3, source "code"): add a version after
* wp lit-mcp divi-check ran green against it. Any other version needs a release in the Divi tab of each site.
*/
final class DiviReleases
{
public const TESTED = ['5.2.1'];
}
src/Integrations/Divi/Release/ReleaseState.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** Spec §5.2. */
enum ReleaseState: string
{
case Released = 'released';
case Untested = 'untested';
case Incompatible = 'incompatible';
}
src/Integrations/Divi/Release/ReleaseStatus.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\Errors;
use LogicException;
use WP_Error;
defined('ABSPATH') || exit;
/** Whether LIT MCP may write with the installed Divi (spec §5.2), with the texts of spec §5.6. */
final class ReleaseStatus
{
public const SOURCE_CODE = 'code';
public const SOURCE_DATABASE = 'database';
private const UNTESTED = 'Achtung: %s ist auf dieser Website noch nicht für LIT MCP freigegeben. Schreibende '
. 'Divi-Werkzeuge sind gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung '
. 'ausführt und die Version freigibt. Es wurde nichts geändert.';
private const UNTESTED_HINT = 'Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben; '
. 'lesende Divi-Werkzeuge funktionieren weiter.';
private const INCOMPATIBLE = '%s hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind '
. 'gesperrt, bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.';
private const INCOMPATIBLE_HINT = 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss; lesende '
. 'Divi-Werkzeuge funktionieren weiter.';
private const NOTICE = 'Achtung: %s ist für LIT MCP %s; Lesen funktioniert, Schreiben ist gesperrt.';
/**
* @param string $version Divi version, '' when unknown (Divi is not the active template).
* @param string|null $source SOURCE_CODE or SOURCE_DATABASE for a released version.
* @param list<string> $problems Failed code and mirror checks (ManifestCheck labels).
* @param array<string, mixed>|null $release The database entry of a SOURCE_DATABASE release.
*/
public function __construct(
public readonly ReleaseState $state,
public readonly string $version,
public readonly ?string $source = null,
public readonly array $problems = [],
public readonly ?array $release = null
) {
}
public static function released(string $version, string $source = self::SOURCE_CODE): self
{
return new self(ReleaseState::Released, $version, $source);
}
public function writable(): bool
{
return $this->state === ReleaseState::Released;
}
public function versionLabel(): string
{
return $this->version === '' ? 'Divi (Version unbekannt)' : 'Divi ' . $this->version;
}
/** The error of a refused write. @throws LogicException For a released status. */
public function refusal(): WP_Error
{
return match ($this->state) {
ReleaseState::Untested => Errors::make(
'divi_untested',
sprintf(self::UNTESTED, $this->versionLabel()),
self::UNTESTED_HINT,
423
),
ReleaseState::Incompatible => Errors::make(
'divi_incompatible',
sprintf(self::INCOMPATIBLE, $this->versionLabel()),
self::INCOMPATIBLE_HINT,
423,
['problems' => $this->problems]
),
ReleaseState::Released => throw new LogicException('a released Divi refuses nothing'),
};
}
/** The warning a reading Divi tool carries while writes are locked. */
public function notice(): string
{
return sprintf(
self::NOTICE,
$this->versionLabel(),
$this->state === ReleaseState::Incompatible ? 'inkompatibel' : 'ungetestet'
);
}
/**
* @return array{state: string, version: string, source: ?string, released_at: ?string, released_by: ?int,
* problems: list<string>}
*/
public function toArray(): array
{
return [
'state' => $this->state->value,
'version' => $this->version,
'source' => $this->source,
'released_at' => is_string($this->release['released_at'] ?? null) ? $this->release['released_at'] : null,
'released_by' => is_int($this->release['user_id'] ?? null) ? $this->release['user_id'] : null,
'problems' => $this->problems,
];
}
}
src/Integrations/Divi/Release/ReleaseStore.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* Divi versions released on this site after a green admin check (spec §5.3, source "database"). An entry holds for
* the plugin version that ran the check only.
*/
class ReleaseStore
{
public const OPTION = 'lit_mcp_divi_releases';
/** @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string, report_id: string}> */
public function entries(): array
{
$raw = get_option(self::OPTION, []);
$entries = [];
foreach (is_array($raw) ? $raw : [] as $row) {
if (
is_array($row)
&& is_string($row['divi_version'] ?? null)
&& is_string($row['plugin_version'] ?? null)
&& is_int($row['user_id'] ?? null)
&& is_string($row['released_at'] ?? null)
&& is_string($row['report_id'] ?? null)
) {
$entries[] = [
'divi_version' => $row['divi_version'],
'plugin_version' => $row['plugin_version'],
'user_id' => $row['user_id'],
'released_at' => $row['released_at'],
'report_id' => $row['report_id'],
];
}
}
return $entries;
}
/** @return array{divi_version: string, plugin_version: string, user_id: int, released_at: string, report_id: string}|null */
public function find(string $diviVersion, string $pluginVersion): ?array
{
foreach ($this->entries() as $entry) {
if ($entry['divi_version'] === $diviVersion && $entry['plugin_version'] === $pluginVersion) {
return $entry;
}
}
return null;
}
public function add(string $diviVersion, string $pluginVersion, int $userId, string $reportId): void
{
$entries = $this->without($diviVersion, $pluginVersion);
$entries[] = [
'divi_version' => $diviVersion,
'plugin_version' => $pluginVersion,
'user_id' => $userId,
'released_at' => gmdate('c'),
'report_id' => $reportId,
];
update_option(self::OPTION, $entries, false);
}
public function withdraw(string $diviVersion, string $pluginVersion): bool
{
if ($this->find($diviVersion, $pluginVersion) === null) {
return false;
}
update_option(self::OPTION, $this->without($diviVersion, $pluginVersion), false);
return true;
}
/** @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string, report_id: string}> */
private function without(string $diviVersion, string $pluginVersion): array
{
return array_values(array_filter(
$this->entries(),
static fn (array $entry): bool => $entry['divi_version'] !== $diviVersion
|| $entry['plugin_version'] !== $pluginVersion
));
}
}
ReleaseStore ist nicht final, damit Tests ihn mocken können (wie DiviSaver).
src/Integrations/Divi/Release/ReleaseResolver.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Plugin;
defined('ABSPATH') || exit;
/**
* Computes the release status (spec §5.2–§5.4): code checks live (cheap), mirror checks cached in a transient under
* stateKey() – Divi version, plugin version, fingerprints.json and the modification times of the mirrored Divi
* files, so a Divi update or hotfix is checked again at once.
*/
final class ReleaseResolver
{
public const CACHE = 'lit_mcp_divi_state';
/**
* @param (Closure(): ?string)|null $version null = DiviEnvironment::version().
* @param (Closure(string): string)|null $stateKey null = self::stateKey().
*/
public function __construct(
private ?ManifestCheck $check = null,
private ?ReleaseStore $store = null,
private ?Closure $version = null,
private ?Closure $stateKey = null
) {
}
public function resolve(): ReleaseStatus
{
$version = ($this->version ?? DiviEnvironment::version(...))() ?? '';
$check = $this->check ?? new ManifestCheck();
$problems = array_merge(ManifestCheck::failed($check->codeChecks()), $this->mirrorProblems($check, $version));
if ($problems !== []) {
return new ReleaseStatus(ReleaseState::Incompatible, $version, null, $problems);
}
if (in_array($version, DiviReleases::TESTED, true)) {
return ReleaseStatus::released($version);
}
$entry = ($this->store ?? new ReleaseStore())->find($version, Plugin::VERSION);
return $entry !== null
? new ReleaseStatus(ReleaseState::Released, $version, ReleaseStatus::SOURCE_DATABASE, [], $entry)
: new ReleaseStatus(ReleaseState::Untested, $version);
}
/** What the cached mirror results and a check report are valid for. */
public static function stateKey(string $version, ?string $themeDir = null): string
{
$themeDir ??= DiviEnvironment::themeDir();
$parts = [$version, Plugin::VERSION, is_readable(DiviReference::FILE) ? (string) md5_file(DiviReference::FILE) : ''];
foreach (DiviAdapters::mirrors() as $mirror) {
$file = $themeDir === null ? '' : $themeDir . '/' . $mirror->file;
$parts[] = $mirror->file . '@' . ($file !== '' && is_file($file) ? (string) filemtime($file) : '-');
}
return hash('sha256', implode('|', $parts));
}
/** @return list<string> */
private function mirrorProblems(ManifestCheck $check, string $version): array
{
$key = ($this->stateKey ?? self::stateKey(...))($version);
$cached = get_transient(self::CACHE);
if (is_array($cached) && ($cached['key'] ?? null) === $key && is_array($cached['problems'] ?? null)) {
return array_values(array_filter($cached['problems'], 'is_string'));
}
$problems = ManifestCheck::failed($check->mirrorChecks());
set_transient(self::CACHE, ['key' => $key, 'problems' => $problems], DAY_IN_SECONDS);
return $problems;
}
}
DAY_IN_SECONDS ist in Unit-Tests nicht definiert: in tests/bootstrap.php nach der ABSPATH-Zeile ergänzen, falls nicht vorhanden:
if (! defined('DAY_IN_SECONDS')) {
define('DAY_IN_SECONDS', 86400);
}
ManifestCheck ist final; damit ReleaseResolverTest ihn mocken kann, final bei ManifestCheck entfernen und den Docblock um „Not final so the resolver can be tested with a mock.“ ergänzen.
src/Integrations/Divi/Release/DiviLocked.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use RuntimeException;
use Throwable;
use WP_Error;
defined('ABSPATH') || exit;
/** A write refused because the installed Divi is not released (spec §5.5); AbilityRegistrar returns toError(). */
final class DiviLocked extends RuntimeException
{
public function __construct(public readonly ReleaseStatus $status)
{
parent::__construct(sprintf(
'%s is %s for LIT MCP – write refused',
$status->versionLabel(),
$status->state->value
));
}
public function toError(): WP_Error
{
return $this->status->refusal();
}
/** The lock anywhere in $e's chain of previous exceptions (callers wrap failures in WriteFailed). */
public static function in(Throwable $e): ?self
{
for ($cause = $e; $cause !== null; $cause = $cause->getPrevious()) {
if ($cause instanceof self) {
return $cause;
}
}
return null;
}
}
src/Integrations/Divi/Release/ReleaseGate.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Log;
use Throwable;
defined('ABSPATH') || exit;
/**
* The release status of this request and the lock every writing Divi path asks (spec §5.5). whileProbing() lets the
* admin check write its throwaway page whatever the status – only that post, only during the call, and only from
* code (there is no parameter or option for it).
*/
final class ReleaseGate
{
private static ?ReleaseStatus $status = null;
/** @var (Closure(): ReleaseStatus)|null */
private static ?Closure $resolver = null;
private static ?int $probe = null;
/** Spec §9: a status that cannot be determined locks writes like an incompatible Divi (and is logged). */
public static function status(): ReleaseStatus
{
if (self::$status !== null) {
return self::$status;
}
try {
self::$status = (self::$resolver ?? static fn (): ReleaseStatus => (new ReleaseResolver())->resolve())();
} catch (Throwable $e) {
Log::failure('divi release status', $e);
self::$status = new ReleaseStatus(
ReleaseState::Incompatible,
'',
null,
['Der Freigabe-Zustand ließ sich nicht ermitteln (Details im PHP-Error-Log).']
);
}
return self::$status;
}
/** @param (Closure(): ReleaseStatus)|null $resolver null = ReleaseResolver; tests set their own. */
public static function useResolver(?Closure $resolver): void
{
self::$resolver = $resolver;
self::$status = null;
self::$probe = null;
}
/** The next status() computes again (after a check, a release or a withdrawal). */
public static function forget(): void
{
self::$status = null;
}
/** @throws DiviLocked */
public static function assertWritable(?int $postId = null): void
{
if (self::$probe !== null && $postId === self::$probe) {
return;
}
$status = self::status();
if (! $status->writable()) {
throw new DiviLocked($status);
}
}
public static function whileProbing(int $postId, callable $write): mixed
{
$previous = self::$probe;
self::$probe = $postId;
try {
return $write();
} finally {
self::$probe = $previous;
}
}
}
tests/Unit/TestCase.php: Importe LIT\MCP\Integrations\Divi\Release\ReleaseGate und …\ReleaseStatus; in setUp() nach Functions\stubs([...]):
// Divi is released unless a test says otherwise (the release gate would ask WordPress and Divi).
ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseStatus::released('5.2.1'));
in tearDown() vor Monkey\tearDown();:
ReleaseGate::useResolver(null);
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'ReleaseStatusTest|ReleaseStoreTest|ReleaseResolverTest|ReleaseGateTest|ManifestCheckTest'
Expected: PASS.
- Step 5: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
Commit wie in den Global Constraints mit Nachricht feat: Divi release status, store, resolver and gate.
Task 10: Zentrale Sperre im AbilityRegistrar
Files:
- Create:
src/Integrations/Divi/Release/DiviToolGate.php - Modify:
src/Abilities/AbilityRegistrar.php,src/Plugin.php,src/Admin/LogListTable.php,src/Admin/LogTab.php - Test:
tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php(neu),tests/Unit/Abilities/AbilityRegistrarTest.php,tests/Unit/Admin/LogListTableTest.php
Interfaces:
-
Consumes:
ReleaseGate::status(),ReleaseStatus::refusal()/notice()/versionLabel(),DiviLocked::in()(Task 9);IntegrationRegistry::groupMap();LibraryAccess::GROUP('divi-library'),ThemeBuilderAccess::GROUP('divi-theme-builder');ChangeLog::record(). -
Produces:
DiviToolGate::GROUPS,__construct(Closure $groups, ?ChangeLog $log = null),covers(Ability $ability): bool,refusal(Ability $ability): ?WP_Error,annotate(Ability $ability, mixed $result): mixed,outputSchema(Ability $ability, ?array $schema): ?array;AbilityRegistrar::__construct(IntegrationRegistry $registry, ?ExposurePolicy $policy = null, ?DiviToolGate $divi = null); Protokoll-Ergebnisrefused. -
Step 1: Write the failing tests
tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;
final class DiviToolGateTest extends TestCase
{
private const GROUPS = [
'lit/divi-insert' => 'divi',
'lit/divi-outline' => 'divi',
'lit/library-save' => 'divi-library',
'lit/tb-create-template' => 'divi-theme-builder',
'lit/content-create' => 'wordpress',
];
private function ability(string $name, bool $readonly): Ability
{
$ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn($name);
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
return $ability;
}
private function gate(?FakeChangeLog $log = null): DiviToolGate
{
return new DiviToolGate(static fn (): array => self::GROUPS, $log);
}
private function untested(): void
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
}
public function testReleasedDiviRefusesNothingAndAddsNothing(): void
{
$gate = $this->gate(FakeChangeLog::forbidden());
self::assertNull($gate->refusal($this->ability('lit/divi-insert', false)));
self::assertSame(['ok' => true], $gate->annotate($this->ability('lit/divi-outline', true), ['ok' => true]));
}
public function testWriteToolOfEveryDiviGroupIsRefused(): void
{
$this->untested();
$log = new FakeChangeLog();
$gate = $this->gate($log);
foreach (['lit/divi-insert', 'lit/library-save', 'lit/tb-create-template'] as $name) {
self::assertToolError($gate->refusal($this->ability($name, false)), 'divi_untested', $name);
}
self::assertNull($gate->refusal($this->ability('lit/content-create', false)), 'not a Divi tool (adapter lock)');
self::assertNull($gate->refusal($this->ability('lit/divi-outline', true)), 'reading stays allowed');
self::assertSame(['refused', 'refused', 'refused'], array_column($log->records, 'result'));
self::assertSame('Abgelehnt: Divi 5.3.0 ist nicht freigegeben.', $log->records[0]['summary']);
}
public function testReadResultsKeepTheirShapeAndGetTheNotice(): void
{
$this->untested();
$gate = $this->gate();
$read = $this->ability('lit/divi-outline', true);
$notice = 'Achtung: Divi 5.3.0 ist für LIT MCP ungetestet; Lesen funktioniert, Schreiben ist gesperrt.';
self::assertSame(['post_id' => 7, 'warnings' => [$notice]], $gate->annotate($read, ['post_id' => 7]));
self::assertSame(
['warnings' => ['alt', $notice]],
$gate->annotate($read, ['warnings' => ['alt']])
);
self::assertSame([1, 2], $gate->annotate($read, [1, 2]), 'a list stays a list');
$error = new WP_Error('lit_not_found', 'x');
self::assertSame($error, $gate->annotate($read, $error));
self::assertSame(['post_id' => 7], $gate->annotate($this->ability('lit/content-create', true), ['post_id' => 7]));
}
public function testOutputSchemaAllowsWarnings(): void
{
$gate = $this->gate();
$read = $this->ability('lit/divi-outline', true);
$schema = $gate->outputSchema($read, ['type' => 'object', 'properties' => ['post_id' => ['type' => 'integer']]]);
self::assertSame(['type' => 'array', 'items' => ['type' => 'string']], $schema['properties']['warnings'] ?? null);
$own = ['type' => 'object', 'properties' => ['warnings' => ['type' => 'array']]];
self::assertSame($own, $gate->outputSchema($read, $own), 'an existing warnings property stays');
self::assertNull($gate->outputSchema($read, null));
self::assertSame(
['type' => 'object'],
$gate->outputSchema($this->ability('lit/content-create', true), ['type' => 'object'])
);
}
}
In tests/Unit/Abilities/AbilityRegistrarTest.php ergänzen (Importe LIT\MCP\Integrations\Divi\Release\DiviLocked, …\DiviToolGate, …\ReleaseGate, …\ReleaseState, …\ReleaseStatus):
private function diviRegistrar(Ability $ability): AbilityRegistrar
{
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn('divi');
$integration->allows('isAvailable')->andReturn(true);
$integration->allows('abilities')->andReturn([$ability]);
$registry = new IntegrationRegistry($integration);
return new AbilityRegistrar($registry, null, new DiviToolGate($registry->groupMap(...)));
}
public function testUntestedDiviRefusesAWritingDiviToolBeforeItRuns(): void
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
$ability = $this->ability(false);
$ability->expects('execute')->never();
$args = $this->diviRegistrar($ability)->argsFor($ability);
self::assertToolError(($args['execute_callback'])([]), 'divi_untested');
}
public function testALockThrownInsideAWriteBecomesTheLockError(): void
{
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['x']));
$ability = $this->ability();
$ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $locked));
self::assertToolError($this->executeLogged($ability), 'divi_incompatible');
}
public function testAPartialWriteStaysPartialEvenWithALockInside(): void
{
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
$ability = $this->ability();
$ability->allows('execute')->andThrow(WriteFailed::partial('Teilweise gespeichert.', $locked));
self::assertToolError($this->executeLogged($ability), 'partial_write');
}
/** Runs the execute callback with error_log() redirected (a PHP internal, not stubbable – like the tests above). */
private function executeLogged(Ability $ability): mixed
{
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
return ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
}
public function testDiviReadToolsGetWarningsInTheirSchema(): void
{
$ability = $this->ability();
$args = $this->diviRegistrar($ability)->argsFor($ability);
self::assertArrayHasKey('warnings', $args['output_schema']['properties']);
}
Den bestehenden Helfer ability() in derselben Datei um den Lese-Schalter erweitern (alle bisherigen Aufrufe bleiben gültig):
private function ability(bool $readonly = true): Mockery\MockInterface
{
$ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn('lit/test');
$ability->allows('label')->andReturn('Test');
$ability->allows('description')->andReturn('Test ability');
$ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]);
$ability->allows('outputSchema')->andReturn(['type' => 'object']);
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
$ability->allows('isDirect')->andReturn(true);
return $ability;
}
In tests/Unit/Admin/LogListTableTest.php im Datenlieferanten notOk() nach ['partial', 'warning', 'teilweise'], ergänzen:
['refused', 'warning', 'abgelehnt'],
- Step 2: Run tests to verify they fail
Run: vendor/bin/phpunit --filter 'DiviToolGateTest|AbilityRegistrarTest|LogListTableTest'
Expected: FAIL.
- Step 3: Implementation
src/Integrations/Divi/Release/DiviToolGate.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
use WP_Error;
defined('ABSPATH') || exit;
/**
* The central release lock of the Divi tools (spec §5.5): a writing tool of the groups Divi, Divi-Bibliothek or Divi
* Theme Builder is refused before it runs while Divi is not released (logged with result "refused"); a reading tool
* runs and gets the notice in warnings. Other tools that reach Divi (lit/content-create with divi) meet the adapter
* lock (ReleaseGate::assertWritable()).
*/
final class DiviToolGate
{
public const GROUPS = ['divi', LibraryAccess::GROUP, ThemeBuilderAccess::GROUP];
/** @var Closure(): array<string, string> */
private Closure $groups;
/** @param Closure(): array<string, string> $groups Ability name => integration ID (IntegrationRegistry::groupMap). */
public function __construct(Closure $groups, private ?ChangeLog $log = null)
{
$this->groups = $groups;
}
public function covers(Ability $ability): bool
{
return in_array(($this->groups)()[$ability->name()] ?? '', self::GROUPS, true);
}
public function refusal(Ability $ability): ?WP_Error
{
if (! $this->covers($ability) || self::readsOnly($ability)) {
return null;
}
$status = ReleaseGate::status();
if ($status->writable()) {
return null;
}
$this->log?->record(
$ability->name(),
'divi',
$status->version,
sprintf(
'Abgelehnt: %s ist %s.',
$status->versionLabel(),
$status->state === ReleaseState::Incompatible ? 'inkompatibel' : 'nicht freigegeben'
),
result: 'refused'
);
return $status->refusal();
}
public function annotate(Ability $ability, mixed $result): mixed
{
if (! is_array($result) || array_is_list($result) || ! $this->covers($ability) || ! self::readsOnly($ability)) {
return $result;
}
$status = ReleaseGate::status();
if ($status->writable()) {
return $result;
}
$warnings = is_array($result['warnings'] ?? null) ? array_values($result['warnings']) : [];
$warnings[] = $status->notice();
$result['warnings'] = $warnings;
return $result;
}
/**
* @param array<string, mixed>|null $schema
* @return array<string, mixed>|null
*/
public function outputSchema(Ability $ability, ?array $schema): ?array
{
if ($schema === null || ($schema['type'] ?? null) !== 'object' || ! $this->covers($ability)) {
return $schema;
}
$properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
$properties['warnings'] ??= ['type' => 'array', 'items' => ['type' => 'string']];
$schema['properties'] = $properties;
return $schema;
}
private static function readsOnly(Ability $ability): bool
{
return ($ability->annotations()['readonly'] ?? false) === true;
}
}
src/Abilities/AbilityRegistrar.php:
- Importe
LIT\MCP\Integrations\Divi\Release\DiviLocked,LIT\MCP\Integrations\Divi\Release\DiviToolGate. - Klassen-Docblock ergänzen: „… with the role gate, the Divi release lock (DiviToolGate) and neutral error handling.“
- Konstruktor:
public function __construct(
private IntegrationRegistry $registry,
private ?ExposurePolicy $policy = null,
private ?DiviToolGate $divi = null
) {
}
- In
argsFor()als erste Zeile$divi = $this->divi ?? new DiviToolGate($this->registry->groupMap(...));, derexecute_callbackwird
'execute_callback' => static function (mixed $input = null) use ($ability, $divi): mixed {
$refusal = $divi->refusal($ability);
if ($refusal !== null) {
return $refusal;
}
try {
return $divi->annotate($ability, $ability->execute(is_array($input) ? $input : []));
} catch (Throwable $e) {
Log::failure($ability->name(), $e);
return self::errorFor($e);
}
},
- und statt
$output = $ability->outputSchema();steht$output = $divi->outputSchema($ability, $ability->outputSchema());. - Neue Methode:
/**
* A partial write stays partial_write; otherwise a Divi release lock anywhere in the cause chain is its refusal
* (spec §5.5); a rolled-back write is write_failed; anything else internal_error.
*/
private static function errorFor(Throwable $e): WP_Error
{
if ($e instanceof WriteFailed && ! $e->rolledBack) {
return $e->toError();
}
$locked = DiviLocked::in($e);
if ($locked !== null) {
return $locked->toError();
}
return $e instanceof WriteFailed ? $e->toError() : Errors::internal();
}
src/Plugin.php: Import LIT\MCP\Integrations\Divi\Release\DiviToolGate; die Zeile (new AbilityRegistrar($registry, $policy))->register(); wird
(new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register();
src/Admin/LogListTable.php, status(): nach 'partial' => … die Zeile 'refused' => ['warning', __('abgelehnt', 'wp-lit-mcp')],; Docblock der Methode „Marks rows whose write did not complete“ um „or was refused (Divi release lock)“ ergänzen.
src/Admin/LogTab.php: RESULTS = ['ok', 'partial', 'refused']; im Filter $labels um 'refused' => __('abgelehnt', 'wp-lit-mcp') ergänzen.
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'DiviToolGateTest|AbilityRegistrarTest|LogListTableTest|LogTabTest'
Expected: PASS.
- Step 5: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
Commit wie in den Global Constraints mit Nachricht feat: central Divi release lock for the Divi tools.
Task 11: Sperre in den Adaptern, lit-content-create, lit-site-status
Files:
- Modify:
src/Integrations/Divi/Pages/DiviSaver.php,src/Integrations/Divi/Library/DiviLibraryStore.php,src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php,src/Integrations/WordPress/Content/ContentComposer.php,src/Integrations/Divi/Pages/DiviContentComposer.php,src/Integrations/WordPress/Content/ContentCreateAbility.php,src/Integrations/Core/EnvironmentProbe.php,src/Integrations/Core/SiteStatusAbility.php,src/Plugin.php - Test:
tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php(neu),tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php,tests/Unit/Integrations/Core/EnvironmentProbeTest.php, weitere Tests mitMockery::mock(ContentComposer::class)
Interfaces:
-
Consumes:
ReleaseGate::assertWritable(),ReleaseGate::status(),ReleaseStatus::refusal()/toArray(),ReleaseResolver::CACHE(Task 9). -
Produces:
ContentComposer::refusal(): ?WP_Error;components()[divi]inlit-site-statusmit Schlüsselrelease(=ReleaseStatus::toArray(), nur wenn Divi aktiv, sonstnull). -
Step 1: Write the failing tests
tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
/** Spec §5.5: every writing adapter method refuses before its first write while Divi is not released. */
final class ReleaseGuardsTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
// Nothing may be written: every write function WordPress offers fails the test.
foreach (['wp_update_post', 'wp_insert_post', 'update_post_meta', 'add_post_meta', 'delete_post_meta',
'update_option', 'delete_option', 'wp_set_object_terms'] as $write) {
Functions\when($write)->alias(static function () use ($write): never {
self::fail($write . ' ran although Divi is locked');
});
}
Functions\when('current_user_can')->justReturn(true);
}
/** @return iterable<string, array{callable(): mixed}> */
public static function writes(): iterable
{
$post = new WP_Post(['ID' => 7, 'post_content' => '', 'post_type' => 'page']);
$spec = new LibraryItemSpec('Hero', 'section', false, 'regular', null, 'page');
yield 'save' => [static fn () => (new DiviSaver())->save($post, '<!-- wp:divi/section /-->')];
yield 'saveNew' => [static fn () => (new DiviSaver())->saveNew(7, '<!-- wp:divi/section /-->')];
yield 'library create' => [static fn () => (new DiviLibraryStore())->create(
$spec,
new NewDraft(null, FakeChangeLog::forbidden(), 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag')
)];
yield 'tb insertLayout' => [static fn () => (new DiviThemeBuilder())->insertLayout('header', 'Kopf')];
yield 'tb storeTemplate' => [static fn () => (new DiviThemeBuilder())->storeTemplate(3, [
'title' => 'V', 'layouts' => [], 'use_on' => [], 'exclude_from' => [],
])];
yield 'tb attach' => [static fn () => (new DiviThemeBuilder())->attach(3, 9)];
yield 'tb detach' => [static fn () => (new DiviThemeBuilder())->detach(3, 9)];
yield 'tb setConditions' => [static fn () => (new DiviThemeBuilder())->setConditions(9, ['singular'], [])];
yield 'tb setEnabled' => [static fn () => (new DiviThemeBuilder())->setEnabled(9, true)];
yield 'tb unmarkUnused' => [static fn () => (new DiviThemeBuilder())->unmarkUnused(9)];
}
/** @dataProvider writes */
public function testRefusesBeforeTheFirstWrite(callable $write): void
{
$this->expectException(DiviLocked::class);
$write();
}
public function testTheCheckPageMayBeWrittenWhileProbing(): void
{
Functions\when('wp_update_post')->justReturn(41);
Functions\when('update_post_meta')->justReturn(true);
Functions\when('get_post_meta')->alias(static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on');
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 41, 'post_content' => 'x']));
Functions\when('has_filter')->justReturn(false);
$warnings = ReleaseGate::whileProbing(41, static fn () => (new DiviSaver())->saveNew(41, 'x'));
self::assertSame([], $warnings);
}
}
In tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php: die Hilfsmethode composer() bekommt einen dritten Parameter ?WP_Error $refusal = null und $composer->allows('refusal')->andReturn($refusal);. Neuer Test (Import LIT\MCP\Integrations\Divi\Release\ReleaseState, …\ReleaseStatus):
public function testLockedDiviRefusesBeforeTheDraftExists(): void
{
$refusal = (new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->refusal();
Functions\expect('wp_insert_post')->never();
$result = $this->ability($this->composer(true, true, $refusal))->execute(self::INPUT);
self::assertToolError($result, 'divi_untested');
}
Alle übrigen Mocks von ContentComposer im Test-Verzeichnis (grep -rln "mock(ContentComposer::class)" tests) um ->allows('refusal')->andReturn(null) ergänzen, wo der Mock writable erlaubt.
In tests/Unit/Integrations/Core/EnvironmentProbeTest.php ergänzen:
public function testDiviComponentCarriesTheReleaseStatus(): void
{
$theme = Mockery::mock();
$theme->allows('get_template')->andReturn('Divi');
$theme->allows('parent')->andReturn(false);
$theme->allows('get')->with('Version')->andReturn('5.2.1');
Functions\when('wp_get_theme')->justReturn($theme);
$components = array_column(
(new EnvironmentProbe(new DiviCompatibility([])))->components(),
null,
'id'
);
self::assertSame('released', $components['divi']['release']['state'] ?? null);
self::assertNull($components['pods']['release'] ?? null);
}
- Step 2: Run tests to verify they fail
Run: vendor/bin/phpunit --filter 'ReleaseGuardsTest|ContentCreateDiviTest|EnvironmentProbeTest'
Expected: FAIL (keine Ausnahme DiviLocked; refusal unbekannt; kein release).
- Step 3: Implementation
src/Integrations/Divi/Pages/DiviSaver.php – Import LIT\MCP\Integrations\Divi\Release\ReleaseGate; erste Zeile in save() nach $postId = (int) $post->ID;:
ReleaseGate::assertWritable($postId);
erste Zeile in saveNew():
ReleaseGate::assertWritable($postId);
Beide Docblocks um @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. ergänzen (Import …\Release\DiviLocked nur für den Docblock ist in diesem Code üblich; sonst voll qualifiziert im Docblock).
src/Integrations/Divi/Library/DiviLibraryStore.php: erste Zeile in create(): ReleaseGate::assertWritable(); (Import).
src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php: erste Zeile in insertLayout(), storeTemplate(), attach(), detach(), setConditions(), setEnabled(), unmarkUnused(): ReleaseGate::assertWritable(); (Import). In insertLayout() vor der LAYOUT_TYPES-Prüfung.
src/Integrations/WordPress/Content/ContentComposer.php – neue Methode der Schnittstelle nach writable():
/**
* Why writing such content is refused right now although it is enabled (e.g. the installed Divi version is not
* released for LIT MCP, spec §5.5); null = allowed.
*/
public function refusal(): ?WP_Error;
(Import WP_Error ergänzen, falls nicht vorhanden.)
src/Integrations/Divi/Pages/DiviContentComposer.php (Import LIT\MCP\Integrations\Divi\Release\ReleaseGate):
public function refusal(): ?WP_Error
{
$status = ReleaseGate::status();
return $status->writable() ? null : $status->refusal();
}
src/Integrations/WordPress/Content/ContentCreateAbility.php, direkt nach dem writable()-Block:
$locked = $withDivi ? $this->divi->refusal() : null;
if ($locked !== null) {
return $locked;
}
src/Integrations/Core/EnvironmentProbe.php – Import LIT\MCP\Integrations\Divi\Release\ReleaseGate; Rückgabetyp im Docblock um release: ?array<string, mixed> erweitern; im Divi-Eintrag 'release' => $isDivi ? ReleaseGate::status()->toArray() : null,, bei Pods und The Events Calendar 'release' => null,.
src/Integrations/Core/SiteStatusAbility.php, description(): „Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.“ → „Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, und release, ob LIT MCP mit dieser Divi-Version schreiben darf (released, untested, incompatible).“
src/Plugin.php, in boot() nach der ChangeLog-Einrichtung (Import LIT\MCP\Integrations\Divi\Release\ReleaseResolver):
// 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);
});
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'ReleaseGuardsTest|ContentCreate|EnvironmentProbeTest|SiteStatusAbilityTest|DiviSaverTest|DiviLibraryStoreTest|DiviThemeBuilderTest'
Expected: PASS. Scheitert testRefusesBeforeTheFirstWrite für eine Methode mit „… ran although Divi is locked“, steht die Sperre dort nicht in der ersten Zeile.
- Step 5: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
Commit wie in den Global Constraints mit Nachricht feat: adapter-level Divi release lock, content-create and site status.
Task 12: Prüfschritte der Admin-Prüfung (Katalog, Format, Schreibtest, Bericht)
Files:
- Create:
src/Integrations/Divi/Schema/ShortformCheck.php,src/Integrations/Divi/Release/FormatCheck.php,reference-page.html(Kopie vontests/Fixtures/divi/converted-landing.html),WriteProbe.php,CheckStep.php,CheckReport.php,CheckReportStore.php,CompatibilityCheck.php - Modify:
tests/Integration/divi-shortform-check.php - Test:
tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php,tests/Unit/Integrations/Divi/Release/FormatCheckTest.php,WriteProbeTest.php,CheckReportTest.php,CompatibilityCheckTest.php
Interfaces:
-
Consumes:
ManifestCheck(Task 8),ReleaseGate::whileProbing(),ReleaseResolver::stateKey()(Task 9),DiviPages::FLAGS(Task 4),ContentComposer::write(),PageRenderer::render(),Parser,Serializer,Block,ShortformMap,ModuleCatalog,DiviModules,DiviEnvironment(Task 8),Errors::clientMessage(),Plugin::VERSION. -
Produces:
ShortformCheck::problems(ModuleCatalog $catalog): list<string>.FormatCheck::REFERENCE,__construct(?string $file = null, ?Closure $wpBlocks = null),problems(): list<string>.WriteProbe::MARKER = '_lit_mcp_check_page',TITLE = '[LIT MCP Prüfung]',SECTIONS,TEXTS;__construct(ContentComposer $composer, ?PageRenderer $renderer = null, ?string $diviDir = null),run(): list<string>, statischremoveOrphans(): list<int>. Nichtfinal(Mock imCompatibilityCheckTest).CheckStepmit SchlüsselnMANIFEST = 'manifest',MIRRORS = 'mirrors',CATALOG = 'catalog',FORMAT = 'format',WRITE = 'write';__construct(string $key, array $problems, bool $skipped = false),green(): bool,label(): string,toArray(), statischfromArray(mixed $data): ?CheckStep.CheckReport::__construct(string $id, string $createdAt, int $userId, string $diviVersion, string $pluginVersion, string $stateKey, array $steps),green(): bool,freshFor(string $diviVersion, string $pluginVersion, string $stateKey): bool,step(string $key): ?CheckStep,toArray(), statischfromArray(mixed $data): ?CheckReport.CheckReportStore::OPTION = 'lit_mcp_divi_check_report',latest(): ?CheckReport,save(CheckReport $report): void. Nichtfinal.CompatibilityCheck::__construct(ManifestCheck $manifests, Closure $catalog, FormatCheck $format, WriteProbe $probe, Closure $version, Closure $stateKey), statischcreate(ContentComposer $composer): self,run(int $userId): CheckReport.
-
Step 1: Write the failing tests
tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Schema;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
final class ShortformCheckTest extends TestCase
{
public function testAnEmptyCatalogKnowsNoShortformModule(): void
{
$problems = ShortformCheck::problems(new ModuleCatalog([]));
self::assertCount(count(ShortformMap::types()), $problems);
self::assertContains('divi/text: unbekanntes Modul', $problems);
}
/** The fixture catalog (metadata-sample.json) lacks some modules, but every path of the modules it has is right. */
public function testFixtureCatalogHasNoWrongPathsOrChildren(): void
{
foreach (ShortformCheck::problems(DiviFixtures::catalog()) as $problem) {
self::assertStringEndsWith(': unbekanntes Modul', $problem);
}
self::assertNotContains('divi/text: unbekanntes Modul', ShortformCheck::problems(DiviFixtures::catalog()));
}
}
tests/Unit/Integrations/Divi/Release/FormatCheckTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Release\FormatCheck;
use PHPUnit\Framework\TestCase;
final class FormatCheckTest extends TestCase
{
/** parse_blocks() as WordPress would answer for well-formed Divi markup: the same names and nesting. */
private static function wordPress(): \Closure
{
$convert = static function (array $blocks) use (&$convert): array {
return array_map(static fn (Block $block): array => [
'blockName' => $block->name(),
'innerBlocks' => $convert($block->children()),
], $blocks);
};
return static fn (string $content): array => $convert((new Parser())->parse($content)->children());
}
public function testTheShippedReferenceRoundTripsAndMatchesWordPress(): void
{
self::assertSame([], (new FormatCheck(null, self::wordPress()))->problems());
self::assertFileEquals(dirname(__DIR__, 5) . '/tests/Fixtures/divi/converted-landing.html', FormatCheck::REFERENCE);
}
public function testADifferentStructureInWordPressIsReported(): void
{
$check = new FormatCheck(null, static fn (): array => [['blockName' => 'divi/section', 'innerBlocks' => []]]);
self::assertSame(['WordPress liest eine andere Blockstruktur als LIT MCP.'], $check->problems());
}
public function testAMissingReferenceIsReported(): void
{
self::assertSame(
['Der Referenzinhalt der Prüfung fehlt im Plugin.'],
(new FormatCheck('/nonexistent/reference.html', self::wordPress()))->problems()
);
}
}
tests/Unit/Integrations/Divi/Release/WriteProbeTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Post;
final class WriteProbeTest extends TestCase
{
/** @var list<int> */
private array $deleted = [];
protected function setUp(): void
{
parent::setUp();
// The probe must work while Divi is untested – that is when it runs.
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
$this->deleted = [];
Functions\when('wp_insert_post')->justReturn(41);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 41, 'post_content' => '<!-- wp:divi/section /-->']));
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on'
);
Functions\when('wp_delete_post')->alias(function (int $id, bool $force): WP_Post {
self::assertTrue($force, 'never to the trash');
$this->deleted[] = $id;
return new WP_Post(['ID' => $id]);
});
}
private function composer(?callable $write = null): ContentComposer
{
$composer = Mockery::mock(ContentComposer::class);
$composer->allows('write')->andReturnUsing($write ?? static function (int $id, string $type, array $sections): array {
// The composer writes through the saver, which asks the gate: the probe must have opened it for 41.
ReleaseGate::assertWritable($id);
return ['warnings' => [], 'outline' => ''];
});
return $composer;
}
private function renderer(string $html): PageRenderer
{
$renderer = Mockery::mock(PageRenderer::class);
$renderer->allows('render')->andReturn($html);
return $renderer;
}
public function testGreenWhenEveryModuleRendersAndThePageIsGone(): void
{
$probe = new WriteProbe($this->composer(), $this->renderer(implode(' ', WriteProbe::TEXTS)), '/nonexistent-divi');
self::assertSame([], $probe->run());
self::assertSame([41], $this->deleted);
}
public function testMissingTextsAndFlagsAreReported(): void
{
Functions\when('get_post_meta')->justReturn('');
$problems = (new WriteProbe($this->composer(), $this->renderer('litcheck-heading'), '/nonexistent-divi'))->run();
self::assertContains('Builder-Kennzeichen _et_pb_use_builder fehlt.', $problems);
self::assertContains('„litcheck-button“ fehlt in der gerenderten Seite.', $problems);
self::assertNotContains('„litcheck-heading“ fehlt in der gerenderten Seite.', $problems);
self::assertSame([41], $this->deleted);
}
public function testDeletesThePageEvenWhenWritingThrows(): void
{
$composer = $this->composer(static function (): never {
throw new RuntimeException('Composer kaputt');
});
$problems = (new WriteProbe($composer, $this->renderer(''), '/nonexistent-divi'))->run();
self::assertSame(['Schreibtest abgebrochen: Composer kaputt'], $problems);
self::assertSame([41], $this->deleted);
$this->expectException(\LIT\MCP\Integrations\Divi\Release\DiviLocked::class);
ReleaseGate::assertWritable(41);
}
public function testReportsAPageThatCouldNotBeDeleted(): void
{
Functions\when('wp_delete_post')->justReturn(false);
$problems = (new WriteProbe($this->composer(), $this->renderer(implode(' ', WriteProbe::TEXTS)), '/nonexistent-divi'))->run();
self::assertSame(
['Die Testseite 41 konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.'],
$problems
);
}
public function testCreatesAMarkedDraftPage(): void
{
Functions\expect('wp_insert_post')->once()->with(Mockery::on(static fn (array $post): bool =>
$post['post_type'] === 'page'
&& $post['post_status'] === 'draft'
&& str_starts_with($post['post_title'], '[LIT MCP Prüfung] ')
&& $post['meta_input'] === ['_lit_mcp_check_page' => '1']), true)->andReturn(41);
(new WriteProbe($this->composer(), $this->renderer(implode(' ', WriteProbe::TEXTS)), '/nonexistent-divi'))->run();
}
public function testRemovesOrphanedCheckPages(): void
{
Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $query): bool =>
$query['meta_key'] === '_lit_mcp_check_page'
&& $query['post_status'] === 'any'
&& $query['fields'] === 'ids'
&& ($query['date_query'][0]['before'] ?? null) === '10 minutes ago'))->andReturn([51, 52]);
self::assertSame([51, 52], WriteProbe::removeOrphans());
self::assertSame([51, 52], $this->deleted);
}
}
tests/Unit/Integrations/Divi/Release/CheckReportTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use PHPUnit\Framework\TestCase;
final class CheckReportTest extends TestCase
{
private static function report(array $steps): CheckReport
{
return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, '5.3.0', '0.6.0', 'key', $steps);
}
private static function green(): array
{
return array_map(
static fn (string $key): CheckStep => new CheckStep($key, []),
[CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
);
}
public function testGreenOnlyWithEveryStepGreenAndNoneSkipped(): void
{
self::assertTrue(self::report(self::green())->green());
self::assertFalse(self::report([])->green());
$red = self::green();
$red[2] = new CheckStep(CheckStep::CATALOG, ['divi/text: unbekanntes Modul']);
self::assertFalse(self::report($red)->green());
$skipped = self::green();
$skipped[4] = new CheckStep(CheckStep::WRITE, [], true);
self::assertFalse(self::report($skipped)->green());
}
public function testFreshOnlyForTheSameVersionsAndStateKey(): void
{
$report = self::report(self::green());
self::assertTrue($report->freshFor('5.3.0', '0.6.0', 'key'));
self::assertFalse($report->freshFor('5.3.1', '0.6.0', 'key'), 'Divi updated');
self::assertFalse($report->freshFor('5.3.0', '0.7.0', 'key'), 'plugin updated');
self::assertFalse($report->freshFor('5.3.0', '0.6.0', 'other'), 'a Divi file changed');
}
public function testRoundTripsThroughAnArray(): void
{
$steps = self::green();
$steps[1] = new CheckStep(CheckStep::MIRRORS, ['Theme Builder: Nachbau von x() – y']);
$report = self::report($steps);
self::assertEquals($report, CheckReport::fromArray($report->toArray()));
self::assertNull(CheckReport::fromArray('broken'));
self::assertNull(CheckReport::fromArray(['id' => 'r1']));
self::assertSame('Fingerabdrücke nachgebauter Divi-Funktionen', $report->step(CheckStep::MIRRORS)?->label());
}
}
tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\FormatCheck;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class CompatibilityCheckTest extends TestCase
{
private function check(array $code, array $mirrors, ?WriteProbe $probe = null, ?\Closure $catalog = null): CompatibilityCheck
{
Functions\when('wp_generate_uuid4')->justReturn('uuid-1');
$manifests = Mockery::mock(ManifestCheck::class);
$manifests->allows('codeChecks')->andReturn($code);
$manifests->allows('registrationChecks')->andReturn([]);
$manifests->allows('mirrorChecks')->andReturn($mirrors);
$format = Mockery::mock(FormatCheck::class);
$format->allows('problems')->andReturn([]);
if ($probe === null) {
$probe = Mockery::mock(WriteProbe::class);
$probe->allows('run')->andReturn([]);
}
return new CompatibilityCheck(
$manifests,
$catalog ?? static fn (): array => [],
$format,
$probe,
static fn (): string => '5.3.0',
static fn (string $version): string => 'key-' . $version
);
}
public function testAGreenRunNamesVersionsAndStateKey(): void
{
$report = $this->check([], [])->run(7);
self::assertTrue($report->green());
self::assertSame(['uuid-1', 7, '5.3.0', Plugin::VERSION, 'key-5.3.0'], [
$report->id, $report->userId, $report->diviVersion, $report->pluginVersion, $report->stateKey,
]);
self::assertSame(
['manifest', 'mirrors', 'catalog', 'format', 'write'],
array_map(static fn (CheckStep $step): string => $step->key, $report->steps)
);
}
public function testNoWriteTestWhenDiviFunctionsAreMissing(): void
{
$probe = Mockery::mock(WriteProbe::class);
$probe->expects('run')->never();
$report = $this->check(['Seiten: et_x()' => static fn (): bool => false], [], $probe)->run(7);
self::assertSame(['Seiten: et_x()'], $report->step(CheckStep::MANIFEST)?->problems);
self::assertTrue($report->step(CheckStep::WRITE)?->skipped);
}
public function testChangedFingerprintsStillRunTheWriteTest(): void
{
$probe = Mockery::mock(WriteProbe::class);
$probe->expects('run')->once()->andReturn([]);
$report = $this->check([], ['TB: Nachbau von y()' => static fn (): bool => false], $probe)->run(7);
self::assertFalse($report->green());
self::assertTrue($report->step(CheckStep::WRITE)?->green());
}
public function testAThrowingStepIsARedStep(): void
{
$report = $this->check([], [], null, static function (): never {
throw new RuntimeException('Katalog weg');
})->run(7);
self::assertSame(['Prüfschritt abgebrochen: Katalog weg'], $report->step(CheckStep::CATALOG)?->problems);
}
}
- Step 2: Run tests to verify they fail
Run: vendor/bin/phpunit --filter 'ShortformCheckTest|FormatCheckTest|WriteProbeTest|CheckReportTest|CompatibilityCheckTest'
Expected: FAIL (Klassen fehlen).
- Step 3: Implementation
cp tests/Fixtures/divi/converted-landing.html src/Integrations/Divi/Release/reference-page.html
src/Integrations/Divi/Schema/ShortformCheck.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Schema;
defined('ABSPATH') || exit;
/**
* The curated short form against a module catalog (spec §6.2 step 2): every module exists, every field path is a
* known attribute path, every item type is an allowed child. Pure PHP; the admin check passes Divi's live catalog.
*/
final class ShortformCheck
{
/** @return list<string> German problems; [] = the short form matches. */
public static function problems(ModuleCatalog $catalog): array
{
$problems = [];
foreach (ShortformMap::types() as $type) {
$name = ModuleCatalog::normalize($type);
if (! $catalog->has($name)) {
$problems[] = $name . ': unbekanntes Modul';
continue;
}
foreach (ShortformMap::fields($type) as $field => $spec) {
if (! $catalog->isKnownPath($name, (string) $spec['path'])) {
$problems[] = sprintf(
'%s.%s: unbekannter Pfad %s (ähnlich: %s)',
$type,
$field,
$spec['path'],
implode(', ', $catalog->suggestions($name, (string) $spec['path']))
);
}
}
$child = ShortformMap::childType($type);
if ($child !== null && ! in_array(ModuleCatalog::normalize($child), $catalog->childrenOf($name), true)) {
$problems[] = sprintf(
'%s: %s ist kein erlaubtes Kind (%s)',
$type,
$child,
implode(', ', $catalog->childrenOf($name))
);
}
}
return $problems;
}
}
tests/Integration/divi-shortform-check.php – der Schleifenblock ab $problems = []; bis vor if ($problems !== []) wird $problems = ShortformCheck::problems($catalog); (Import LIT\MCP\Integrations\Divi\Schema\ShortformCheck; ShortformMap bleibt für die Erfolgsmeldung).
src/Integrations/Divi/Release/FormatCheck.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
defined('ABSPATH') || exit;
/**
* Spec §6.2 step 3: a builder-made Divi 5 page (reference-page.html, from the converted fixture) parses and
* serializes byte-identically, and WordPress' parse_blocks() sees the same block names and nesting as the plugin's
* parser. Nothing is written. Not final so CompatibilityCheck can be tested with a mock.
*/
class FormatCheck
{
public const REFERENCE = __DIR__ . '/reference-page.html';
/** @param (Closure(string): array<int, mixed>)|null $wpBlocks null = parse_blocks(). */
public function __construct(private ?string $file = null, private ?Closure $wpBlocks = null)
{
}
/** @return list<string> */
public function problems(): array
{
$file = $this->file ?? self::REFERENCE;
$content = is_file($file) ? file_get_contents($file) : false;
if (! is_string($content) || $content === '') {
return ['Der Referenzinhalt der Prüfung fehlt im Plugin.'];
}
$document = (new Parser())->parse($content);
$problems = [];
if (Serializer::document($document) !== $content) {
$problems[] = 'Parser und Serializer geben den Referenzinhalt nicht byte-gleich zurück.';
}
$wordPress = ($this->wpBlocks ?? static fn (string $markup): array => parse_blocks($markup))($content);
if (self::structure($document->children()) !== self::wpStructure($wordPress)) {
$problems[] = 'WordPress liest eine andere Blockstruktur als LIT MCP.';
}
return $problems;
}
/**
* @param list<Block> $blocks
* @return list<array{0: string, 1: list<mixed>}>
*/
private static function structure(array $blocks): array
{
return array_map(static fn (Block $block): array => [$block->name(), self::structure($block->children())], $blocks);
}
/**
* @param array<int, mixed> $blocks parse_blocks() result; freeform parts (blockName null) are left out.
* @return list<array{0: string, 1: list<mixed>}>
*/
private static function wpStructure(array $blocks): array
{
$structure = [];
foreach ($blocks as $block) {
if (is_array($block) && is_string($block['blockName'] ?? null)) {
$inner = is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : [];
$structure[] = [$block['blockName'], self::wpStructure($inner)];
}
}
return $structure;
}
}
src/Integrations/Divi/Release/WriteProbe.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use Throwable;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Spec §6.2 step 4: a throwaway draft page written through the normal Divi write path (ContentComposer → Composer →
* Serializer → DiviSaver, with the release lock opened for this page only), checked (content, builder flags, Divi
* renders every module without a PHP warning from Divi's files) and always deleted for good – in finally, not to the
* trash, with its revisions and meta. Pages a dead check left behind carry MARKER; removeOrphans() deletes them.
* No change log entry. Not final so CompatibilityCheck can be tested with a mock.
*/
class WriteProbe
{
public const MARKER = '_lit_mcp_check_page';
public const TITLE = '[LIT MCP Prüfung]';
/** Everyday modules without media, each with a text to find in the rendered page. */
public const SECTIONS = [[
'type' => 'section',
'label' => 'LIT MCP Prüfung',
'rows' => [
[
'columns' => '1_2,1_2',
'modules' => [
[
['type' => 'heading', 'text' => 'litcheck-heading', 'level' => 'h2'],
['type' => 'text', 'content' => '<p>litcheck-text</p>'],
['type' => 'button', 'text' => 'litcheck-button', 'url' => '/'],
],
[
['type' => 'blurb', 'title' => 'litcheck-blurb', 'content' => '<p>litcheck-blurb-text</p>'],
['type' => 'cta', 'title' => 'litcheck-cta', 'button_text' => 'litcheck-cta-button', 'button_url' => '/'],
['type' => 'divider'],
],
],
],
[
'columns' => '4_4',
'modules' => [
['type' => 'toggle', 'title' => 'litcheck-toggle', 'content' => '<p>litcheck-toggle-text</p>'],
['type' => 'accordion', 'items' => [['title' => 'litcheck-accordion', 'content' => '<p>a</p>']]],
['type' => 'tabs', 'items' => [['title' => 'litcheck-tab', 'content' => '<p>t</p>']]],
['type' => 'icon-list', 'items' => [['text' => 'litcheck-icon-list']]],
],
],
],
]];
public const TEXTS = [
'litcheck-heading', 'litcheck-text', 'litcheck-button', 'litcheck-blurb', 'litcheck-cta', 'litcheck-toggle',
'litcheck-accordion', 'litcheck-tab', 'litcheck-icon-list',
];
/** Only pages older than this are orphans – a running check's page is younger. */
private const ORPHAN_AGE = '10 minutes ago';
/** @param string|null $diviDir PHP warnings from files below it fail the check; null = DiviEnvironment::themeDir(). */
public function __construct(
private ContentComposer $composer,
private ?PageRenderer $renderer = null,
private ?string $diviDir = null
) {
}
/** @return list<string> German problems; [] = green. */
public function run(): array
{
$id = wp_insert_post([
'post_type' => 'page',
'post_status' => 'draft',
'post_title' => self::TITLE . ' ' . gmdate('Y-m-d H:i'),
'post_content' => '',
'meta_input' => [self::MARKER => '1'],
], true);
if ($id instanceof WP_Error || (int) $id <= 0) {
return ['Die Testseite konnte nicht angelegt werden: '
. ($id instanceof WP_Error ? $id->get_error_message() : 'keine ID')];
}
$id = (int) $id;
try {
$problems = $this->exercise($id);
} catch (Throwable $e) {
$problems = ['Schreibtest abgebrochen: ' . $e->getMessage()];
} finally {
if (! self::delete($id)) {
$problems[] = sprintf(
'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.',
$id
);
}
}
return $problems;
}
/** @return list<int> IDs of check pages a dead check left behind, deleted now. */
public static function removeOrphans(): array
{
$ids = get_posts([
'post_type' => 'page',
'post_status' => 'any',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked pages at most.
'meta_key' => self::MARKER,
'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']],
'fields' => 'ids',
'posts_per_page' => 20,
'no_found_rows' => true,
'suppress_filters' => true,
]);
$removed = [];
foreach (is_array($ids) ? $ids : [] as $id) {
if (self::delete((int) $id)) {
$removed[] = (int) $id;
}
}
return $removed;
}
/** @return list<string> */
private function exercise(int $id): array
{
$written = ReleaseGate::whileProbing(
$id,
fn (): array|WP_Error => $this->composer->write($id, 'page', self::SECTIONS)
);
if ($written instanceof WP_Error) {
return ['Die Kurzform der Testseite wurde abgelehnt: ' . Errors::clientMessage($written)];
}
$post = get_post($id);
if (! $post instanceof WP_Post) {
return ['Die Testseite ist nach dem Schreiben nicht lesbar.'];
}
$problems = [];
if (! str_contains((string) $post->post_content, '<!-- wp:divi/')) {
$problems[] = 'Es wurde kein Divi-Inhalt gespeichert.';
}
foreach (DiviPages::FLAGS as $key => $value) {
if (get_post_meta($id, $key, true) !== $value) {
$problems[] = sprintf('Builder-Kennzeichen %s fehlt.', $key);
}
}
[$html, $warnings] = $this->render($post);
foreach (self::TEXTS as $text) {
if (! str_contains($html, $text)) {
$problems[] = sprintf('„%s“ fehlt in der gerenderten Seite.', $text);
}
}
foreach ($warnings as $warning) {
$problems[] = 'PHP-Warnung aus Divi beim Rendern: ' . $warning;
}
return $problems;
}
/** @return array{0: string, 1: list<string>} The rendered content and PHP warnings raised in Divi's files. */
private function render(WP_Post $post): array
{
$dir = $this->diviDir ?? DiviEnvironment::themeDir();
$warnings = [];
set_error_handler(
static function (int $level, string $message, string $file = '', int $line = 0) use ($dir, &$warnings): bool {
if ($dir !== null && str_starts_with($file, $dir)) {
$warnings[] = sprintf('%s (%s:%d)', $message, basename($file), $line);
}
return false;
},
E_WARNING | E_USER_WARNING
);
try {
$html = ($this->renderer ?? new PageRenderer())->render($post);
} finally {
restore_error_handler();
}
return [$html, $warnings];
}
private static function delete(int $id): bool
{
return wp_delete_post($id, true) instanceof WP_Post;
}
}
Hinweis: $problems ist nach catch und finally immer gesetzt – im try durch exercise(), sonst im catch. Die Tests übergeben diviDir ausdrücklich, damit DiviEnvironment (WordPress-Theme-API) nicht gefragt wird. PHP-interne Funktionen wie gmdate() oder time() lassen sich in diesem Projekt nicht stubben (kein patchwork.json); Tests prüfen deshalb nur den festen Teil solcher Werte.
src/Integrations/Divi/Release/CheckStep.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** One step of the admin check (spec §6.2); skipped = not run (the write test after missing Divi functions). */
final class CheckStep
{
public const MANIFEST = 'manifest';
public const MIRRORS = 'mirrors';
public const CATALOG = 'catalog';
public const FORMAT = 'format';
public const WRITE = 'write';
private const LABELS = [
self::MANIFEST => 'Divi-Funktionen, Klassen und Registrierungen',
self::MIRRORS => 'Fingerabdrücke nachgebauter Divi-Funktionen',
self::CATALOG => 'Modulkatalog (Kurzform-Pfade)',
self::FORMAT => 'Format im Speicher (Parser und Serializer)',
self::WRITE => 'Schreibtest auf einer Wegwerfseite',
];
/** @param list<string> $problems */
public function __construct(public readonly string $key, public readonly array $problems, public readonly bool $skipped = false)
{
}
public function green(): bool
{
return ! $this->skipped && $this->problems === [];
}
public function label(): string
{
return self::LABELS[$this->key] ?? $this->key;
}
/** @return array{key: string, problems: list<string>, skipped: bool} */
public function toArray(): array
{
return ['key' => $this->key, 'problems' => $this->problems, 'skipped' => $this->skipped];
}
public static function fromArray(mixed $data): ?self
{
if (! is_array($data) || ! is_string($data['key'] ?? null) || ! is_array($data['problems'] ?? null)) {
return null;
}
return new self(
$data['key'],
array_values(array_filter($data['problems'], 'is_string')),
($data['skipped'] ?? false) === true
);
}
}
src/Integrations/Divi/Release/CheckReport.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** The result of one admin check (spec §6.3); a release needs a fresh green one. */
final class CheckReport
{
/** @param list<CheckStep> $steps */
public function __construct(
public readonly string $id,
public readonly string $createdAt,
public readonly int $userId,
public readonly string $diviVersion,
public readonly string $pluginVersion,
public readonly string $stateKey,
public readonly array $steps
) {
}
public function green(): bool
{
if ($this->steps === []) {
return false;
}
foreach ($this->steps as $step) {
if (! $step->green()) {
return false;
}
}
return true;
}
/** Green and made for exactly this Divi version, plugin version and Divi files (ReleaseResolver::stateKey()). */
public function freshFor(string $diviVersion, string $pluginVersion, string $stateKey): bool
{
return $this->green()
&& $this->diviVersion === $diviVersion
&& $this->pluginVersion === $pluginVersion
&& $this->stateKey === $stateKey;
}
public function step(string $key): ?CheckStep
{
foreach ($this->steps as $step) {
if ($step->key === $key) {
return $step;
}
}
return null;
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'id' => $this->id,
'created_at' => $this->createdAt,
'user_id' => $this->userId,
'divi_version' => $this->diviVersion,
'plugin_version' => $this->pluginVersion,
'state_key' => $this->stateKey,
'steps' => array_map(static fn (CheckStep $step): array => $step->toArray(), $this->steps),
];
}
public static function fromArray(mixed $data): ?self
{
if (! is_array($data)) {
return null;
}
foreach (['id', 'created_at', 'divi_version', 'plugin_version', 'state_key'] as $key) {
if (! is_string($data[$key] ?? null)) {
return null;
}
}
if (! is_int($data['user_id'] ?? null) || ! is_array($data['steps'] ?? null)) {
return null;
}
$steps = [];
foreach ($data['steps'] as $step) {
$parsed = CheckStep::fromArray($step);
if ($parsed === null) {
return null;
}
$steps[] = $parsed;
}
return new self(
$data['id'],
$data['created_at'],
$data['user_id'],
$data['divi_version'],
$data['plugin_version'],
$data['state_key'],
$steps
);
}
}
src/Integrations/Divi/Release/CheckReportStore.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** The latest admin check report of this site (spec §6.3). Not final so the Divi tab can be tested with a mock. */
class CheckReportStore
{
public const OPTION = 'lit_mcp_divi_check_report';
public function latest(): ?CheckReport
{
return CheckReport::fromArray(get_option(self::OPTION, null));
}
public function save(CheckReport $report): void
{
update_option(self::OPTION, $report->toArray(), false);
}
}
src/Integrations/Divi/Release/CompatibilityCheck.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Plugin;
use Throwable;
defined('ABSPATH') || exit;
/**
* The admin check (spec §6.2) – also wp lit-mcp divi-check: manifests (code and registrations), fingerprints,
* module catalog, format round trip and the write test. A step that throws is a red step; the write test is skipped
* only when Divi functions are missing (it would fail anyway), not when only fingerprints changed – so
* --update-reference can see whether everything else still works. Not final so the route and the command can be
* tested with a mock.
*/
class CompatibilityCheck
{
/**
* @param Closure(): list<string> $catalog Short-form problems against Divi's live catalog.
* @param Closure(): string $version Installed Divi version ('' = unknown).
* @param Closure(string): string $stateKey ReleaseResolver::stateKey().
*/
public function __construct(
private ManifestCheck $manifests,
private Closure $catalog,
private FormatCheck $format,
private WriteProbe $probe,
private Closure $version,
private Closure $stateKey
) {
}
public static function create(ContentComposer $composer): self
{
return new self(
new ManifestCheck(),
static fn (): array => DiviModules::isAvailable()
? ShortformCheck::problems((new DiviModules())->catalog())
: ['Divi 5 ist in dieser Anfrage nicht geladen.'],
new FormatCheck(),
new WriteProbe($composer),
static fn (): string => DiviEnvironment::version() ?? '',
static fn (string $version): string => ReleaseResolver::stateKey($version)
);
}
public function run(int $userId): CheckReport
{
$manifest = self::guarded(fn (): array => ManifestCheck::failed(
array_merge($this->manifests->codeChecks(), $this->manifests->registrationChecks())
));
$steps = [
new CheckStep(CheckStep::MANIFEST, $manifest),
new CheckStep(CheckStep::MIRRORS, self::guarded(
fn (): array => ManifestCheck::failed($this->manifests->mirrorChecks())
)),
new CheckStep(CheckStep::CATALOG, self::guarded($this->catalog)),
new CheckStep(CheckStep::FORMAT, self::guarded(fn (): array => $this->format->problems())),
$manifest === []
? new CheckStep(CheckStep::WRITE, self::guarded(fn (): array => $this->probe->run()))
: new CheckStep(CheckStep::WRITE, [], true),
];
$version = ($this->version)();
return new CheckReport(
wp_generate_uuid4(),
gmdate('c'),
$userId,
$version,
Plugin::VERSION,
($this->stateKey)($version),
$steps
);
}
/** @return list<string> */
private static function guarded(Closure $step): array
{
try {
return array_values(array_filter((array) $step(), 'is_string'));
} catch (Throwable $e) {
return ['Prüfschritt abgebrochen: ' . $e->getMessage()];
}
}
}
FormatCheck, WriteProbe, CheckReportStore und CompatibilityCheck sind nicht final (Mocks in den Tests), ManifestCheck seit Task 9 ebenfalls nicht.
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'ShortformCheckTest|FormatCheckTest|WriteProbeTest|CheckReportTest|CompatibilityCheckTest'
Expected: PASS.
- Step 5: Prüfung in DDEV gegen echtes Divi (ohne Oberfläche)
cd /home/janniclas/Projekte/WordPress && ddev wp --user="$(ddev wp user list --role=administrator --field=user_login | head -1)" eval '
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
$divi = new LIT\MCP\Integrations\Divi\Pages\DiviContentComposer(
new LIT\MCP\Integrations\WordPress\Schema\ContentSchema([]),
new LIT\MCP\Integrations\Divi\Pages\DiviComposition(new LIT\MCP\Integrations\Divi\Schema\DiviModules(), new LIT\MCP\Integrations\Divi\Design\DesignSystemReader()),
new LIT\MCP\Integrations\Divi\Pages\DiviSaver(),
new LIT\MCP\Integrations\Divi\Design\DesignSystemReader()
);
echo wp_json_encode(CompatibilityCheck::create($divi)->run(get_current_user_id())->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);'
ddev wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count
Expected: alle fünf Schritte mit "problems": [] und "skipped": false; danach 0 Prüfseiten. Meldet der Schreibtest eine abgelehnte Kurzform, die Fehlermeldung lesen und WriteProbe::SECTIONS an die Kurzform anpassen (Guide docs/guides/divi-shortform.md), Unit-Tests erneut laufen lassen. Meldet er fehlende Texte, die gerenderte Seite unter WP-CLI prüfen – der endgültige Nachweis im REST-Kontext folgt im Smoke (Task 15).
- Step 6: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
Commit wie in den Global Constraints mit Nachricht feat: admin check steps with throwaway write test and reports.
Task 13: REST-Route der Prüfung, Reiter „Divi“, Verdrahtung, Deinstallation
Files:
- Create:
src/Integrations/Divi/Release/DiviCheckRoute.php,src/Admin/DiviTab.php,tests/Stubs/WpRestResponse.php - Modify:
src/Admin/SettingsPage.php,src/Plugin.php,src/Uninstaller.php,uninstall.php(Docblock),tests/bootstrap.php - Test:
tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php,tests/Unit/Admin/DiviTabTest.php,tests/Unit/Admin/SettingsPageTest.php,tests/Unit/UninstallerTest.php
Interfaces:
-
Consumes:
CompatibilityCheck::create()/run(),CheckReportStore,CheckReport::freshFor()(Task 12);ReleaseStore,ReleaseGate,ReleaseResolver::stateKey()/CACHE,DiviEnvironment::version()(Tasks 8/9);WriteProbe::removeOrphans();AdminPostAction,SettingsPage::url(),AdminTab;DiviIntegration::contentComposer(). -
Produces:
DiviCheckRoute::NAMESPACE = 'lit-mcp/v1',ROUTE = '/divi-check',LOCK = 'lit_mcp_divi_check_lock',__construct(Closure $check, ?CheckReportStore $reports = null),register(),registerRoute(),handle(): WP_REST_Response, statischurl(): string;DiviTab::SLUG = 'divi',RELEASE_ACTION = 'lit_mcp_divi_release',WITHDRAW_ACTION = 'lit_mcp_divi_withdraw',__construct(?CheckReportStore $reports = null, ?ReleaseStore $releases = null, ?ChangeLog $log = null, ?AdminPostAction $action = null, ?Closure $version = null, ?Closure $stateKey = null),release(): void,withdraw(): void,render(): void; Hinweisedivi_checked,divi_check_running,divi_released,divi_release_refused,divi_withdrawn,divi_withdraw_failed. -
Step 1: Stub und failing tests
tests/Stubs/WpRestResponse.php:
<?php
declare(strict_types=1);
if (! class_exists('WP_REST_Response')) {
class WP_REST_Response
{
/** @param array<string, string> $headers */
public function __construct(public mixed $data = null, public int $status = 200, public array $headers = [])
{
}
public function get_status(): int
{
return $this->status;
}
/** @return array<string, string> */
public function get_headers(): array
{
return $this->headers;
}
}
}
In tests/bootstrap.php nach der WpRestRequest-Zeile: require_once __DIR__ . '/Stubs/WpRestResponse.php';.
tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviCheckRouteTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://k.de/wp-admin/' . $path);
Functions\when('add_query_arg')->alias(
static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
);
Functions\when('get_current_user_id')->justReturn(1);
}
public function testRegistersAPostRouteForAdministratorsOnly(): void
{
Functions\expect('register_rest_route')->once()->with(
'lit-mcp/v1',
'/divi-check',
Mockery::on(static function (array $args): bool {
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'manage_options');
$allowed = ($args['permission_callback'])();
Functions\when('current_user_can')->justReturn(false);
return $args['methods'] === 'POST' && $allowed === true && ($args['permission_callback'])() === false;
})
);
(new DiviCheckRoute(static fn () => null))->registerRoute();
}
public function testRunsTheCheckStoresTheReportAndRedirectsToTheTab(): void
{
$report = new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', []);
$check = Mockery::mock(CompatibilityCheck::class);
$check->expects('run')->with(1)->andReturn($report);
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save')->with($report);
Functions\expect('add_option')->once()->with(DiviCheckRoute::LOCK, Mockery::type('int'), '', false)->andReturn(true);
Functions\expect('delete_option')->once()->with(DiviCheckRoute::LOCK);
$response = (new DiviCheckRoute(static fn () => $check, $reports))->handle();
self::assertSame(303, $response->get_status());
self::assertStringContainsString('tab=divi', $response->get_headers()['Location']);
self::assertStringContainsString('lit_mcp_notice=divi_checked', $response->get_headers()['Location']);
}
public function testASecondCheckWaitsForTheFirst(): void
{
Functions\when('add_option')->justReturn(false);
Functions\when('get_option')->alias(static fn (): int => time());
Functions\expect('delete_option')->never();
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save')->never();
$response = (new DiviCheckRoute(static fn () => self::fail('no second check'), $reports))->handle();
self::assertStringContainsString('lit_mcp_notice=divi_check_running', $response->get_headers()['Location']);
}
public function testAStaleLockIsTakenOver(): void
{
Functions\when('add_option')->alias(static function (): bool {
static $calls = 0;
return ++$calls > 1;
});
Functions\when('get_option')->justReturn(1);
Functions\expect('delete_option')->twice()->with(DiviCheckRoute::LOCK);
$check = Mockery::mock(CompatibilityCheck::class);
$check->expects('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', []));
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save');
(new DiviCheckRoute(static fn () => $check, $reports))->handle();
}
}
tests/Unit/Admin/DiviTabTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\AdminPostAction;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviTabTest extends TestCase
{
private ?string $redirect = null;
protected function setUp(): void
{
parent::setUp();
$this->redirect = null;
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
Functions\when('current_user_can')->justReturn(true);
Functions\when('check_admin_referer')->justReturn(1);
Functions\when('get_current_user_id')->justReturn(1);
Functions\when('wp_safe_redirect')->alias(function (string $url): bool {
$this->redirect = $url;
return true;
});
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://k.de/wp-admin/' . $path);
Functions\when('add_query_arg')->alias(
static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
);
}
private static function report(bool $green = true, string $version = '5.3.0', string $key = 'key'): CheckReport
{
$steps = array_map(
static fn (string $step): CheckStep => new CheckStep($step, $green ? [] : ['rot']),
[CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
);
return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, $version, Plugin::VERSION, $key, $steps);
}
private function tab(?CheckReport $report, ReleaseStore $releases, ?FakeChangeLog $log = null): DiviTab
{
$reports = Mockery::mock(CheckReportStore::class);
$reports->allows('latest')->andReturn($report);
return new DiviTab(
$reports,
$releases,
$log ?? FakeChangeLog::forbidden(),
new AdminPostAction(static function (): void {
}),
static fn (): string => '5.3.0',
static fn (string $version): string => 'key'
);
}
public function testReleaseNeedsAFreshGreenReport(): void
{
foreach ([null, self::report(false), self::report(true, '5.2.9'), self::report(true, '5.3.0', 'old')] as $report) {
$releases = Mockery::mock(ReleaseStore::class);
$releases->expects('add')->never();
$this->tab($report, $releases)->release();
self::assertStringContainsString('lit_mcp_notice=divi_release_refused', (string) $this->redirect);
}
}
public function testReleaseStoresTheEntryAndLogsIt(): void
{
$releases = Mockery::mock(ReleaseStore::class);
$releases->expects('add')->once()->with('5.3.0', Plugin::VERSION, 1, 'r1');
$log = new FakeChangeLog();
$this->tab(self::report(), $releases, $log)->release();
self::assertStringContainsString('lit_mcp_notice=divi_released', (string) $this->redirect);
self::assertSame('lit/divi-release', $log->records[0]['ability']);
self::assertStringContainsString('Divi 5.3.0', $log->records[0]['summary']);
}
public function testWithdrawRemovesTheEntryOfTheInstalledVersion(): void
{
$releases = Mockery::mock(ReleaseStore::class);
$releases->expects('withdraw')->once()->with('5.3.0', Plugin::VERSION)->andReturn(true);
$log = new FakeChangeLog();
$this->tab(null, $releases, $log)->withdraw();
self::assertStringContainsString('lit_mcp_notice=divi_withdrawn', (string) $this->redirect);
self::assertSame('lit/divi-release', $log->records[0]['ability']);
}
public function testActionsNeedNonceAndManageOptions(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\expect('wp_die')->twice()->andReturnUsing(static function (): never {
throw new \RuntimeException('died');
});
$releases = Mockery::mock(ReleaseStore::class);
$releases->expects('add')->never();
$releases->expects('withdraw')->never();
$tab = $this->tab(self::report(), $releases);
foreach (['release', 'withdraw'] as $method) {
try {
$tab->{$method}();
self::fail($method . ' without manage_options');
} catch (\RuntimeException $e) {
self::assertSame('died', $e->getMessage());
}
}
}
public function testReleaseChecksTheNonce(): void
{
Functions\when('check_admin_referer')->alias(static function (string $action): never {
throw new \RuntimeException('bad nonce for ' . $action);
});
$releases = Mockery::mock(ReleaseStore::class);
$releases->expects('add')->never();
$this->expectExceptionMessage('bad nonce for lit_mcp_divi_release');
$this->tab(self::report(), $releases)->release();
}
/** @return string The rendered tab. */
private function rendered(ReleaseStatus $status, ?CheckReport $report): string
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => $status);
Functions\when('get_posts')->justReturn([]);
Functions\when('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check');
Functions\when('wp_create_nonce')->justReturn('n');
Functions\when('wp_nonce_field')->justReturn('');
Functions\when('submit_button')->alias(static function (string $text): void {
echo '<button>' . $text . '</button>';
});
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan']);
Functions\when('date_i18n')->justReturn('01.10.2026 10:00');
Functions\when('get_option')->justReturn('d.m.Y');
ob_start();
$this->tab($report, Mockery::mock(ReleaseStore::class))->render();
return (string) ob_get_clean();
}
public function testUntestedWithAFreshGreenReportOffersTheRelease(): void
{
$html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report());
self::assertStringContainsString('ungetestet', $html);
self::assertStringContainsString('Prüfung starten', $html);
self::assertStringContainsString('Divi 5.3.0 freigeben', $html);
self::assertStringContainsString('name="_wpnonce" value="n"', $html);
}
public function testUntestedWithAStaleReportAsksForANewCheck(): void
{
$html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report(true, '5.2.9'));
self::assertStringNotContainsString('Divi 5.3.0 freigeben', $html);
self::assertStringContainsString('Prüfung erneut ausführen', $html);
}
public function testReleasedFromCodeOffersNeitherReleaseNorWithdrawal(): void
{
$html = $this->rendered(ReleaseStatus::released('5.3.0'), self::report());
self::assertStringContainsString('freigegeben', $html);
self::assertStringContainsString('im Plugin als getestet hinterlegt', $html);
self::assertStringNotContainsString('freigeben</button>', $html);
self::assertStringNotContainsString('Freigabe zurückziehen', $html);
}
public function testADatabaseReleaseCanBeWithdrawn(): void
{
$status = new ReleaseStatus(ReleaseState::Released, '5.3.0', ReleaseStatus::SOURCE_DATABASE, [], [
'released_at' => '2026-10-01T08:00:00+00:00', 'user_id' => 1,
]);
self::assertStringContainsString('Freigabe zurückziehen', $this->rendered($status, self::report()));
}
public function testIncompatibleListsTheProblems(): void
{
$html = $this->rendered(
new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['Theme Builder: Nachbau von x() – y']),
null
);
self::assertStringContainsString('inkompatibel', $html);
self::assertStringContainsString('Theme Builder: Nachbau von x() – y', $html);
self::assertStringContainsString('Plugin-Update', $html);
}
}
In tests/Unit/Admin/SettingsPageTest.php ergänzen (Aufbau wie testHeaderEndMarkerSitsBetweenTitleAndTabsWithNoticesAfterIt):
/** @dataProvider diviNotices */
public function testDiviNoticesAreShown(string $key, string $class, string $text): void
{
Functions\when('current_user_can')->justReturn(true);
$_GET['lit_mcp_notice'] = $key;
ob_start();
(new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->render();
$html = (string) ob_get_clean();
self::assertStringContainsString('notice-' . $class, $html);
self::assertStringContainsString($text, $html);
}
/** @return list<list<string>> */
public static function diviNotices(): array
{
return [
['divi_checked', 'success', 'Divi-Prüfung abgeschlossen'],
['divi_check_running', 'warning', 'bereits eine Divi-Prüfung'],
['divi_released', 'success', 'für LIT MCP freigegeben'],
['divi_release_refused', 'error', 'Prüfung erneut ausführen'],
['divi_withdrawn', 'success', 'Freigabe zurückgezogen'],
['divi_withdraw_failed', 'error', 'keine Freigabe'],
];
}
In tests/Unit/UninstallerTest.php: in beiden Tests delete_transient mit Functions\when('delete_transient')->alias(...) sammeln statt expect(...)->once(), und erwarten [ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE] (einmal je Website); die erwartete Optionsliste des ersten Tests wird [Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK].
- Step 2: Run tests to verify they fail
Run: vendor/bin/phpunit --filter 'DiviCheckRouteTest|DiviTabTest|SettingsPageTest|UninstallerTest|UninstallTest'
Expected: FAIL.
- Step 3: Implementation
src/Integrations/Divi/Release/DiviCheckRoute.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Admin\SettingsPage;
use WP_REST_Response;
defined('ABSPATH') || exit;
/**
* "Prüfung starten" of the Divi tab (spec §6.1): POST /wp-json/lit-mcp/v1/divi-check. A REST request on purpose –
* not admin-post – so the check runs in the same context as MCP calls (Divi renders its blocks in REST requests,
* lit-page-render-summary). The form sends the wp_rest nonce as _wpnonce (cookie authentication); only manage_options.
* One check at a time: the option LOCK (add_option is atomic); a lock older than LOCK_TTL is a dead check's and taken
* over. Answers 303 back to the tab.
*/
final class DiviCheckRoute
{
public const NAMESPACE = 'lit-mcp/v1';
public const ROUTE = '/divi-check';
public const LOCK = 'lit_mcp_divi_check_lock';
private const LOCK_TTL = 600;
/** @param Closure(): CompatibilityCheck $check */
public function __construct(private Closure $check, private ?CheckReportStore $reports = null)
{
}
public function register(): void
{
add_action('rest_api_init', [$this, 'registerRoute']);
}
public function registerRoute(): void
{
register_rest_route(self::NAMESPACE, self::ROUTE, [
'methods' => 'POST',
'callback' => [$this, 'handle'],
'permission_callback' => static fn (): bool => current_user_can('manage_options'),
]);
}
public static function url(): string
{
return rest_url(self::NAMESPACE . self::ROUTE);
}
public function handle(): WP_REST_Response
{
$notice = 'divi_check_running';
if (self::lock()) {
try {
$report = ($this->check)()->run(get_current_user_id());
($this->reports ?? new CheckReportStore())->save($report);
ReleaseGate::forget();
$notice = 'divi_checked';
} finally {
delete_option(self::LOCK);
}
}
return new WP_REST_Response(
null,
303,
['Location' => SettingsPage::url(DiviTab::SLUG, ['lit_mcp_notice' => $notice])]
);
}
private static function lock(): bool
{
if (add_option(self::LOCK, time(), '', false)) {
return true;
}
$since = (int) get_option(self::LOCK, 0);
if ($since > 0 && time() - $since < self::LOCK_TTL) {
return false;
}
delete_option(self::LOCK);
return (bool) add_option(self::LOCK, time(), '', false);
}
}
src/Admin/DiviTab.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use Closure;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
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\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin;
defined('ABSPATH') || exit;
/**
* Tab "Divi" (spec §6): release status of the installed Divi, the latest check report, "Prüfung starten" (REST,
* DiviCheckRoute), "Divi <Version> freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (only
* for a database release). Opening the tab removes check pages a dead check left behind.
*/
final class DiviTab implements AdminTab
{
public const SLUG = 'divi';
public const RELEASE_ACTION = 'lit_mcp_divi_release';
public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw';
private const STATES = [
'released' => 'freigegeben',
'untested' => 'ungetestet',
'incompatible' => 'inkompatibel',
];
/**
* @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment.
* @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey().
*/
public function __construct(
private ?CheckReportStore $reports = null,
private ?ReleaseStore $releases = null,
private ?ChangeLog $log = null,
private ?AdminPostAction $action = null,
private ?Closure $version = null,
private ?Closure $stateKey = null
) {
}
public function slug(): string
{
return self::SLUG;
}
public function label(): string
{
return __('Divi', 'wp-lit-mcp');
}
public function registerSettings(): void
{
}
public function registerActions(): void
{
add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']);
add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
}
public function release(): void
{
$action = $this->action ?? new AdminPostAction();
$action->guard();
check_admin_referer(self::RELEASE_ACTION);
$version = $this->version();
$report = $this->reports()->latest();
if ($version === '' || $report === null || ! $report->freshFor($version, Plugin::VERSION, $this->stateKey($version))) {
$action->redirect(self::SLUG, 'divi_release_refused');
return;
}
($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id);
ReleaseGate::forget();
$this->log?->record(
'lit/divi-release',
'divi',
$version,
sprintf('Divi %s für LIT MCP %s freigegeben (Prüfbericht %s).', $version, Plugin::VERSION, $report->id)
);
$action->redirect(self::SLUG, 'divi_released');
}
public function withdraw(): void
{
$action = $this->action ?? new AdminPostAction();
$action->guard();
check_admin_referer(self::WITHDRAW_ACTION);
$version = $this->version();
if (! ($this->releases ?? new ReleaseStore())->withdraw($version, Plugin::VERSION)) {
$action->redirect(self::SLUG, 'divi_withdraw_failed');
return;
}
ReleaseGate::forget();
$this->log?->record('lit/divi-release', 'divi', $version, sprintf('Freigabe von Divi %s zurückgezogen.', $version));
$action->redirect(self::SLUG, 'divi_withdrawn');
}
public function render(): void
{
$removed = WriteProbe::removeOrphans();
$status = ReleaseGate::status();
$report = $this->reports()->latest();
echo '<h2>' . esc_html__('Divi-Freigabe', 'wp-lit-mcp') . '</h2>';
echo '<p>' . esc_html__(
'LIT MCP schreibt nur mit Divi-Versionen, die geprüft sind. Nach einem Divi-Update hier die Prüfung '
. 'starten und die Version freigeben; bis dahin lesen die Divi-Werkzeuge nur.',
'wp-lit-mcp'
) . '</p>';
if ($removed !== []) {
printf(
'<div class="notice notice-warning inline"><p>%s</p></div>',
esc_html(sprintf(
/* translators: %s: comma separated page IDs */
__('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'),
implode(', ', $removed)
))
);
}
$this->renderStatus($status);
$this->renderForms($status, $report);
if ($report !== null) {
$this->renderReport($report);
}
}
private function renderStatus(ReleaseStatus $status): void
{
echo '<table class="form-table" role="presentation"><tbody>';
printf(
'<tr><th scope="row">%s</th><td>%s</td></tr>',
esc_html__('Divi-Version', 'wp-lit-mcp'),
esc_html($status->version === '' ? __('nicht erkannt', 'wp-lit-mcp') : $status->version)
);
printf(
'<tr><th scope="row">%s</th><td><strong>%s</strong> – %s</td></tr>',
esc_html__('Zustand', 'wp-lit-mcp'),
esc_html(self::STATES[$status->state->value]),
esc_html($this->explanation($status))
);
if ($status->problems !== []) {
echo '<tr><th scope="row">' . esc_html__('Geändert in Divi', 'wp-lit-mcp') . '</th><td><ul>';
foreach ($status->problems as $problem) {
echo '<li>' . esc_html($problem) . '</li>';
}
echo '</ul></td></tr>';
}
echo '</tbody></table>';
}
private function explanation(ReleaseStatus $status): string
{
if ($status->state === ReleaseState::Incompatible) {
return __('Divi hat Stellen geändert, die LIT MCP nutzt. Schreiben bleibt gesperrt, bis ein Plugin-Update '
. 'von LIT MCP eingespielt ist.', 'wp-lit-mcp');
}
if ($status->state === ReleaseState::Untested) {
return __('Diese Version ist nicht geprüft. Die Divi-Werkzeuge lesen nur, bis sie freigegeben ist.', 'wp-lit-mcp');
}
if ($status->source === ReleaseStatus::SOURCE_CODE) {
return __('Diese Version ist im Plugin als getestet hinterlegt.', 'wp-lit-mcp');
}
$user = get_userdata((int) ($status->release['user_id'] ?? 0));
return sprintf(
/* translators: 1: date, 2: user name */
__('Auf dieser Website freigegeben am %1$s von %2$s.', 'wp-lit-mcp'),
date_i18n((string) get_option('date_format'), (int) strtotime((string) ($status->release['released_at'] ?? ''))),
is_object($user) && isset($user->display_name) ? (string) $user->display_name : '?'
);
}
private function renderForms(ReleaseStatus $status, ?CheckReport $report): void
{
printf('<form method="post" action="%s">', esc_url(DiviCheckRoute::url()));
printf('<input type="hidden" name="_wpnonce" value="%s">', esc_attr(wp_create_nonce('wp_rest')));
submit_button(__('Prüfung starten', 'wp-lit-mcp'), 'secondary', 'lit-mcp-divi-check', false);
echo '</form>';
if ($status->state === ReleaseState::Untested && $status->version !== '') {
if ($report !== null && $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version))) {
$this->actionForm(
self::RELEASE_ACTION,
/* translators: %s: Divi version */
sprintf(__('Divi %s freigeben', 'wp-lit-mcp'), $status->version),
'primary'
);
} else {
echo '<p>' . esc_html__(
'Freigeben geht erst nach einer vollständig grünen Prüfung dieser Divi-Version – Prüfung erneut '
. 'ausführen.',
'wp-lit-mcp'
) . '</p>';
}
}
if ($status->state === ReleaseState::Released && $status->source === ReleaseStatus::SOURCE_DATABASE) {
$this->actionForm(self::WITHDRAW_ACTION, __('Freigabe zurückziehen', 'wp-lit-mcp'), 'secondary');
}
}
private function actionForm(string $action, string $label, string $type): void
{
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
printf('<input type="hidden" name="action" value="%s">', esc_attr($action));
wp_nonce_field($action);
submit_button($label, $type, 'submit', false);
echo '</form>';
}
private function renderReport(CheckReport $report): void
{
printf(
'<h3>%s</h3><p>%s</p><ul class="lit-mcp-divi-report">',
esc_html__('Letzte Prüfung', 'wp-lit-mcp'),
esc_html(sprintf(
/* translators: 1: Divi version, 2: plugin version, 3: date */
__('Divi %1$s mit LIT MCP %2$s, %3$s', 'wp-lit-mcp'),
$report->diviVersion,
$report->pluginVersion,
date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt))
))
);
foreach ($report->steps as $step) {
$mark = $step->skipped ? '–' : ($step->green() ? '✓' : '✗');
printf('<li>%s %s', esc_html($mark), esc_html($step->label()));
if ($step->skipped) {
echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')';
}
if ($step->problems !== []) {
echo '<ul>';
foreach ($step->problems as $problem) {
echo '<li>' . esc_html($problem) . '</li>';
}
echo '</ul>';
}
echo '</li>';
}
echo '</ul><p class="description">' . esc_html__(
'Optional: eine Divi-Seite im Visual Builder öffnen und speichern.',
'wp-lit-mcp'
) . '</p>';
}
private function version(): string
{
return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')();
}
private function stateKey(string $version): string
{
return ($this->stateKey ?? static fn (string $v): string => ReleaseResolver::stateKey($v))($version);
}
private function reports(): CheckReportStore
{
return $this->reports ?? new CheckReportStore();
}
}
src/Admin/SettingsPage.php, in renderNotice() in den match vor default:
'divi_checked' => ['success', __('Divi-Prüfung abgeschlossen; das Ergebnis steht unten.', 'wp-lit-mcp')],
'divi_check_running' => [
'warning',
__('Es läuft bereits eine Divi-Prüfung; bitte in ein paar Minuten erneut versuchen.', 'wp-lit-mcp'),
],
'divi_released' => ['success', __('Die Divi-Version ist für LIT MCP freigegeben.', 'wp-lit-mcp')],
'divi_release_refused' => [
'error',
__('Freigabe nicht möglich: Der letzte Prüfbericht ist nicht grün oder passt nicht mehr zur installierten '
. 'Divi- oder Plugin-Version. Bitte die Prüfung erneut ausführen.', 'wp-lit-mcp'),
],
'divi_withdrawn' => [
'success',
__('Freigabe zurückgezogen; schreibende Divi-Werkzeuge sind wieder gesperrt.', 'wp-lit-mcp'),
],
'divi_withdraw_failed' => [
'error',
__('Für die installierte Divi-Version gibt es auf dieser Website keine Freigabe.', 'wp-lit-mcp'),
],
src/Plugin.php – Importe LIT\MCP\Admin\DiviTab, LIT\MCP\Integrations\Divi\Release\CompatibilityCheck, LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; nach dem Aufbau von $registry:
// Spec §6: the admin check runs as a REST request (same context as MCP calls).
$diviCheck = static fn (): CompatibilityCheck => CompatibilityCheck::create($divi->contentComposer());
(new DiviCheckRoute($diviCheck))->register();
und im Tab-Array nach new LimitsTab(),: new DiviTab(log: $changeLog),.
src/Uninstaller.php: Importe der Klassen sind im Deinstallations-Pfad nicht garantiert (Autoloader, siehe Docblock von uninstall.php) – deshalb Literale:
private const OPTIONS = [
'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version',
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
];
private const TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state'];
und in removeSiteData() delete_transient(self::TRANSIENT); → foreach (self::TRANSIENTS as $transient) { delete_transient($transient); }. Klassen-Docblock (Liste „Removed“) und uninstall.php-Docblock um die neuen Optionen und den Transient ergänzen. UninstallerTest vergleicht die Literale mit den Konstanten ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK, ReleaseResolver::CACHE (Test hält sie synchron).
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'DiviCheckRouteTest|DiviTabTest|SettingsPageTest|UninstallerTest|UninstallTest'
Expected: PASS.
- Step 5: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
Commit wie in den Global Constraints mit Nachricht feat: Divi tab with REST check, release and withdrawal.
Task 14: WP-CLI wp lit-mcp divi-check
Files:
- Create:
src/Integrations/Divi/Release/LineDiff.php,src/Cli/DiviCheckCommand.php - Modify:
src/Plugin.php - Test:
tests/Unit/Integrations/Divi/Release/LineDiffTest.php,tests/Unit/Cli/DiviCheckCommandTest.php
Interfaces:
-
Consumes:
CompatibilityCheck,CheckReport,CheckStep,CheckReportStore(Task 12);DiviReference,DiviEnvironment,SourceFingerprint,DiviAdapters(Tasks 1/2/8);ReleaseGate::forget(),ReleaseResolver::CACHE. -
Produces:
LineDiff::lines(array $old, array $new, int $context = 2): list<string>;DiviCheckCommand::__construct(Closure $check, ?CheckReportStore $reports = null, ?Closure $out = null, ?Closure $themeDir = null, ?string $expectedFile = null, ?Closure $sourceDir = null),run(array $assoc): int,__invoke(array $args, array $assoc): void, statischregister(Closure $check): void. -
Step 1: Write the failing tests
tests/Unit/Integrations/Divi/Release/LineDiffTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\LineDiff;
use PHPUnit\Framework\TestCase;
final class LineDiffTest extends TestCase
{
public function testShowsChangedLinesWithTwoLinesOfContext(): void
{
$old = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'];
$new = ['a', 'b', 'c', 'd', 'E', 'f', 'g', 'h', 'i', 'j'];
self::assertSame(
[' c', ' d', '- e', '+ E', ' f', ' g', ' h', ' i', '+ j'],
LineDiff::lines($old, $new)
);
}
public function testMarksAGapBetweenDistantChanges(): void
{
$old = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
$new = ['A', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'J'];
self::assertSame(['- a', '+ A', ' b', ' c', '…', ' h', ' i', '- j', '+ J'], LineDiff::lines($old, $new));
}
public function testEqualInputHasNoLines(): void
{
self::assertSame([], LineDiff::lines(['a', 'b'], ['a', 'b']));
}
}
tests/Unit/Cli/DiviCheckCommandTest.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Cli;
use Brain\Monkey\Functions;
use LIT\MCP\Cli\DiviCheckCommand;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviCheckCommandTest extends TestCase
{
/** @var list<string> */
private array $lines = [];
private string $themeDir = '';
private string $sourceDir = '';
private string $expectedFile = '';
protected function setUp(): void
{
parent::setUp();
$this->lines = [];
$base = sys_get_temp_dir() . '/lit-cli-' . uniqid();
$this->themeDir = $base . '/Divi';
$this->sourceDir = $base . '/reference';
mkdir($this->themeDir . '/includes/builder', 0777, true);
mkdir($this->sourceDir, 0777, true);
$this->expectedFile = $base . '/fingerprints.json';
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(1);
Functions\when('delete_transient')->justReturn(true);
}
protected function tearDown(): void
{
exec('rm -rf ' . escapeshellarg(dirname($this->themeDir)));
parent::tearDown();
}
/** @param array<string, list<string>> $problems Step key => problems. */
private function command(array $problems = []): DiviCheckCommand
{
$steps = array_map(
static fn (string $key): CheckStep => new CheckStep($key, $problems[$key] ?? []),
[CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
);
$check = Mockery::mock(CompatibilityCheck::class);
$check->allows('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', $steps));
$reports = Mockery::mock(CheckReportStore::class);
$reports->allows('save');
return new DiviCheckCommand(
static fn () => $check,
$reports,
function (string $line): void {
$this->lines[] = $line;
},
fn (): ?string => $this->themeDir,
$this->expectedFile,
fn (): ?string => $this->sourceDir
);
}
public function testNeedsAnAdministrator(): void
{
Functions\when('current_user_can')->justReturn(false);
self::assertSame(1, $this->command()->run([]));
self::assertStringContainsString('--user=', implode("\n", $this->lines));
}
public function testPrintsTheStepsAndSucceedsWhenGreen(): void
{
self::assertSame(0, $this->command()->run([]));
self::assertContains('✓ Schreibtest auf einer Wegwerfseite', $this->lines);
}
public function testFailsWhenAStepIsRed(): void
{
self::assertSame(1, $this->command([CheckStep::CATALOG => ['divi/text: unbekanntes Modul']])->run([]));
self::assertContains('✗ Modulkatalog (Kurzform-Pfade)', $this->lines);
self::assertContains(' divi/text: unbekanntes Modul', $this->lines);
}
public function testUpdateReferenceRefusesWhenMoreThanFingerprintsIsRed(): void
{
$code = $this->command([CheckStep::FORMAT => ['kaputt']])->run(['update-reference' => true]);
self::assertSame(1, $code);
self::assertFileDoesNotExist($this->expectedFile);
}
public function testDiffShowsWhatChangedInAMirroredFunction(): void
{
file_put_contents(
$this->themeDir . '/includes/builder/shortcode-core.php',
"<?php\nfunction et_pb_submit_layout( \$args ) {\n\t\$a = 1;\n\treturn 2;\n}\n"
);
DiviReference::writeExpected(['library-submit-layout' => str_repeat('0', 64)], $this->expectedFile);
DiviReference::writeSource(
'library-submit-layout',
"function et_pb_submit_layout( \$args ) {\n\t\$a = 1;\n\treturn 1;\n}",
$this->sourceDir
);
$this->command([CheckStep::MIRRORS => ['Bibliothek: Nachbau von et_pb_submit_layout() – Terme']])
->run(['diff' => true]);
$output = implode("\n", $this->lines);
self::assertStringContainsString('library-submit-layout', $output);
self::assertStringContainsString("- \treturn 1;", $output);
self::assertStringContainsString("+ \treturn 2;", $output);
}
public function testUpdateReferenceWritesNothingWhenAMirrorIsUnreadable(): void
{
file_put_contents(
$this->themeDir . '/includes/builder/shortcode-core.php',
"<?php\nfunction et_pb_submit_layout( \$args ) {\n\treturn 2;\n}\n"
);
$code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]);
// The other mirrored Divi files are missing in the temp theme.
self::assertSame(1, $code);
self::assertStringContainsString('nicht lesbar', implode("\n", $this->lines));
self::assertFileDoesNotExist($this->expectedFile, 'all or nothing');
}
public function testUpdateReferenceWritesFingerprintsAndSources(): void
{
$this->writeDivi();
$code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]);
self::assertSame(0, $code);
self::assertSame(
['library-submit-layout', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags'],
array_keys(DiviReference::expected($this->expectedFile))
);
self::assertStringStartsWith(
'function et_pb_submit_layout(',
(string) DiviReference::readSource('library-submit-layout', $this->sourceDir)
);
}
/** Minimal stand-ins for the five mirrored Divi functions at their real paths. */
private function writeDivi(): void
{
$files = [
'includes/builder/shortcode-core.php' => "function et_pb_submit_layout( \$args ) {\n\treturn 1;\n}",
'includes/builder/frontend-builder/theme-builder/api.php' => "function et_theme_builder_api_save() {\n}",
'includes/builder/frontend-builder/theme-builder/theme-builder.php' =>
"function et_theme_builder_store_template( \$a, \$b, \$c ) {\n}\n"
. "function et_theme_builder_load_template_setting_options( \$ids ) {\n}",
'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php' =>
"class SyncToServerController {\n\tpublic static function update( \$request ) {\n\t}\n}",
];
foreach ($files as $path => $code) {
$file = $this->themeDir . '/' . $path;
if (! is_dir(dirname($file))) {
mkdir(dirname($file), 0777, true);
}
file_put_contents($file, "<?php\n" . $code . "\n");
}
}
}
- Step 2: Run tests to verify they fail
Run: vendor/bin/phpunit --filter 'LineDiffTest|DiviCheckCommandTest'
Expected: FAIL.
- Step 3: Implementation
src/Integrations/Divi/Release/LineDiff.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** A small line diff (longest common subsequence) for wp lit-mcp divi-check --diff. */
final class LineDiff
{
/**
* @param list<string> $old
* @param list<string> $new
* @return list<string> " kept", "- removed", "+ added"; "…" between changes more than 2 × $context lines apart.
*/
public static function lines(array $old, array $new, int $context = 2): array
{
$operations = self::operations($old, $new);
$keep = [];
foreach ($operations as $i => [$op]) {
if ($op === ' ') {
continue;
}
for ($j = max(0, $i - $context); $j <= min(count($operations) - 1, $i + $context); ++$j) {
$keep[$j] = true;
}
}
$lines = [];
$last = null;
foreach ($operations as $i => [$op, $line]) {
if (! isset($keep[$i])) {
continue;
}
if ($last !== null && $i > $last + 1) {
$lines[] = '…';
}
$lines[] = $op . ' ' . $line;
$last = $i;
}
return $lines;
}
/**
* @param list<string> $a
* @param list<string> $b
* @return list<array{0: string, 1: string}>
*/
private static function operations(array $a, array $b): array
{
$n = count($a);
$m = count($b);
$lcs = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
for ($i = $n - 1; $i >= 0; --$i) {
for ($j = $m - 1; $j >= 0; --$j) {
$lcs[$i][$j] = $a[$i] === $b[$j] ? $lcs[$i + 1][$j + 1] + 1 : max($lcs[$i + 1][$j], $lcs[$i][$j + 1]);
}
}
$operations = [];
$i = 0;
$j = 0;
while ($i < $n && $j < $m) {
if ($a[$i] === $b[$j]) {
$operations[] = [' ', $a[$i]];
++$i;
++$j;
} elseif ($lcs[$i + 1][$j] >= $lcs[$i][$j + 1]) {
$operations[] = ['-', $a[$i++]];
} else {
$operations[] = ['+', $b[$j++]];
}
}
while ($i < $n) {
$operations[] = ['-', $a[$i++]];
}
while ($j < $m) {
$operations[] = ['+', $b[$j++]];
}
return $operations;
}
}
src/Cli/DiviCheckCommand.php:
<?php
declare(strict_types=1);
namespace LIT\MCP\Cli;
use Closure;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
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;
defined('ABSPATH') || exit;
/**
* wp lit-mcp divi-check (spec §7): the admin check from the command line, for a developer checking a new Divi
* version. Never releases anything – a green version goes into DiviReleases::TESTED by hand.
*/
final class DiviCheckCommand
{
/** @var Closure(string): void */
private Closure $out;
/**
* @param Closure(): CompatibilityCheck $check
* @param (Closure(string): void)|null $out null = WP_CLI::line().
* @param (Closure(): ?string)|null $themeDir null = DiviEnvironment::themeDir().
* @param string|null $expectedFile null = DiviReference::FILE.
* @param (Closure(): ?string)|null $sourceDir null = DiviReference::sourceDir().
*/
public function __construct(
private Closure $check,
private ?CheckReportStore $reports = null,
?Closure $out = null,
private ?Closure $themeDir = null,
private ?string $expectedFile = null,
private ?Closure $sourceDir = null
) {
$this->out = $out ?? static function (string $line): void {
\WP_CLI::line($line);
};
}
/** @param Closure(): CompatibilityCheck $check */
public static function register(Closure $check): void
{
if (defined('WP_CLI') && WP_CLI) {
\WP_CLI::add_command('lit-mcp divi-check', new self($check));
}
}
/**
* Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format
* und Schreibtest auf einer Wegwerfseite. Gibt nichts frei.
*
* ## OPTIONS
*
* [--diff]
* : Zeigt für jede geänderte nachgebaute Divi-Funktion den Unterschied zur Referenz.
*
* [--update-reference]
* : Schreibt Soll-Fingerabdrücke und Referenzquellen neu – nur wenn alle übrigen Schritte grün sind.
*
* ## EXAMPLES
*
* wp --user=admin lit-mcp divi-check --diff
*
* @param list<string> $args
* @param array<string, mixed> $assoc
*/
public function __invoke(array $args, array $assoc): void
{
$code = $this->run($assoc);
if ($code !== 0) {
\WP_CLI::halt($code);
}
}
/** @param array<string, mixed> $assoc */
public function run(array $assoc): int
{
if (! current_user_can('manage_options')) {
($this->out)('Bitte mit --user=<Administrator> ausführen: der Schreibtest legt eine Seite an.');
return 1;
}
$report = ($this->check)()->run(get_current_user_id());
($this->reports ?? new CheckReportStore())->save($report);
($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->diviVersion ?: '?', $report->pluginVersion));
foreach ($report->steps as $step) {
($this->out)(($step->skipped ? '–' : ($step->green() ? '✓' : '✗')) . ' ' . $step->label());
foreach ($step->problems as $problem) {
($this->out)(' ' . $problem);
}
}
if (isset($assoc['diff'])) {
$this->diff();
}
if (isset($assoc['update-reference'])) {
return $this->updateReference($report);
}
return $report->green() ? 0 : 1;
}
private function diff(): void
{
$themeDir = ($this->themeDir ?? DiviEnvironment::themeDir(...))();
$sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))();
$expected = DiviReference::expected($this->expectedFile);
foreach (DiviAdapters::mirrors() as $mirror) {
try {
$print = SourceFingerprint::of((string) $themeDir . '/' . $mirror->file, $mirror->function, $mirror->class);
} catch (RuntimeException $e) {
($this->out)(sprintf('%s: nicht lesbar (%s)', $mirror->id, $e->getMessage()));
continue;
}
if (($expected[$mirror->id] ?? null) === $print->hash) {
continue;
}
($this->out)(sprintf('%s: %s, Zeilen %d–%d', $mirror->id, $mirror->file, $print->startLine, $print->endLine));
$reference = $sourceDir === null ? null : DiviReference::readSource($mirror->id, $sourceDir);
if ($reference === null) {
($this->out)(' keine Referenzquelle (nur in einer Entwicklungskopie mit tests/)');
continue;
}
foreach (LineDiff::lines(explode("\n", rtrim($reference, "\n")), explode("\n", $print->source)) as $line) {
($this->out)(' ' . $line);
}
}
}
private function updateReference(CheckReport $report): int
{
foreach ($report->steps as $step) {
if ($step->key !== CheckStep::MIRRORS && ! $step->green()) {
($this->out)('Referenz nicht aktualisiert: nicht nur Fingerabdrücke sind rot (siehe oben).');
return 1;
}
}
$themeDir = ($this->themeDir ?? DiviEnvironment::themeDir(...))();
try {
$prints = DiviReference::current((string) $themeDir);
} catch (RuntimeException $e) {
($this->out)('Referenz nicht aktualisiert – eine nachgebaute Divi-Funktion ist nicht lesbar: ' . $e->getMessage());
return 1;
}
DiviReference::writeExpected(
array_map(static fn (SourceFingerprint $print): string => $print->hash, $prints),
$this->expectedFile
);
$sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))();
foreach ($sourceDir === null ? [] : $prints as $id => $print) {
DiviReference::writeSource($id, $print->source, $sourceDir);
}
delete_transient(ReleaseResolver::CACHE);
ReleaseGate::forget();
($this->out)(sprintf('Referenz aktualisiert (%d Fingerabdrücke). Smoke-Skripte laufen lassen, dann committen.', count($prints)));
return 0;
}
}
DiviReference::current() wirft beim ersten unlesbaren Mirror; der Befehl schreibt dann nichts (testUpdateReferenceWritesNothingWhenAMirrorIsUnreadable).
src/Plugin.php nach der Registrierung der Route: Import LIT\MCP\Cli\DiviCheckCommand;
DiviCheckCommand::register($diviCheck);
- Step 4: Run tests to verify they pass
Run: vendor/bin/phpunit --filter 'LineDiffTest|DiviCheckCommandTest'
Expected: PASS.
- Step 5: In DDEV ausprobieren
cd /home/janniclas/Projekte/WordPress && ddev wp --user="$(ddev wp user list --role=administrator --field=user_login | head -1)" lit-mcp divi-check --diff; echo "exit=$?"
Expected: fünf Zeilen mit ✓, keine Diff-Ausgabe, exit=0. Ohne --user: Hinweis auf --user= und exit=1.
- Step 6: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
Commit wie in den Global Constraints mit Nachricht feat: wp lit-mcp divi-check with diff and reference update.
Task 15: Version 0.6.0, Doku, Smoke-Skript, Abnahme
Files:
- Create:
tests/Integration/divi-release-smoke.sh,docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md - Modify:
wp-lit-mcp.php(Version),src/Plugin.php(VERSION = '0.6.0'),readme.txt,docs/guides/overview.md
Interfaces:
-
Consumes: alles Vorherige.
-
Produces: Release 0.6.0.
-
Step 1: Version und Doku
-
wp-lit-mcp.php:* Version: 0.6.0;src/Plugin.php:public const VERSION = '0.6.0';;readme.txt:Stable tag: 0.6.0. -
readme.txt, neuer Abschnitt vor== Changelog ==:
== Divi-Freigabe ==
LIT MCP schreibt nur mit Divi-Versionen, die für diese Plugin-Version geprüft sind. Nach einem Divi-Update lesen die Divi-Werkzeuge weiter; schreibende Divi-Werkzeuge antworten mit „divi_untested“, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung startet und die Version freigibt. Die Prüfung legt eine Entwurfsseite an, schreibt und rendert sie und löscht sie danach endgültig. Hat Divi Stellen geändert, die LIT MCP nachbildet, ist die Version „inkompatibel“ (divi_incompatible) – dann hilft nur ein Update von LIT MCP. Für Entwickler: `wp --user=<admin> lit-mcp divi-check [--diff] [--update-reference]`.
readme.txt, Changelog:
= 0.6.0 =
* Divi-Absicherung: Divi-Zugriffe gebündelt in Adaptern mit Manifest; Fingerabdrücke der nachgebildeten Divi-Funktionen.
* Ungetestete oder veränderte Divi-Versionen sperren alle schreibenden Divi-Wege (divi_untested / divi_incompatible); lesende Divi-Werkzeuge melden es in warnings.
* Neuer Reiter „Divi“: Prüfung mit Wegwerfseite, Freigabe nach grüner Prüfung, Freigabe zurückziehen.
* lit-site-status zeigt den Freigabe-Zustand (release).
* WP-CLI: wp lit-mcp divi-check mit --diff und --update-reference.
docs/guides/overview.md: im Abschnitt zu Fehlern (oder am Ende) ergänzen:
## Divi-Version nicht freigegeben
Antwortet ein schreibendes Divi-Werkzeug mit `divi_untested`, ist die installierte Divi-Version für LIT MCP noch nicht
geprüft; bei `divi_incompatible` hat Divi Stellen geändert, die LIT MCP nutzt. In beiden Fällen wurde nichts
geändert. Lesende Divi-Werkzeuge funktionieren weiter und melden den Zustand in `warnings`; `lit-site-status` zeigt
ihn unter `components` → `divi` → `release`. Den Nutzer bitten, unter Einstellungen → LIT MCP → Divi die Prüfung
auszuführen und die Version freizugeben (`divi_untested`) bzw. LIT MCP zu aktualisieren (`divi_incompatible`).
- Step 2: Smoke-Skript schreiben
tests/Integration/divi-release-smoke.sh:
#!/usr/bin/env bash
# End-to-end checks of the Divi release gate (spec 2026-09-29-divi-absicherung).
# Runs INSIDE the DDEV web container (Divi 5.2.1 must be the active theme):
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh
# Leaves nothing behind: pages, the check report, the check lock, the state transient and the application password
# are removed again.
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
CREATED_POSTS=()
BASE=http://localhost
cleanup() {
for id in "${CREATED_POSTS[@]}"; do
wp post delete "$id" --force >/dev/null 2>&1 || true
done
wp option delete lit_mcp_divi_check_report lit_mcp_divi_check_lock >/dev/null 2>&1 || true
wp transient delete lit_mcp_divi_state >/dev/null 2>&1 || true
cleanup_app_passwords_for "$ADMIN_LOGIN"
}
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
check_pages() { wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count; }
[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
# 1. Code, registrations and fingerprints match Divi 5.2.1
[ "$(wp eval 'echo wp_json_encode((new LIT\MCP\Integrations\Divi\DiviCompatibility())->problems());')" = "[]" ] \
|| fail "Divi compatibility problems under WP-CLI"
pass "manifests and fingerprints match the installed Divi"
connect "$ADMIN_LOGIN"
STATUS=$(tool lit-site-status '{}')
echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .problems == [] and .release.state == "released" and .release.source == "code"' >/dev/null \
|| fail "site status must show Divi released from code: $STATUS"
pass "lit-site-status: Divi 5.2.1 released (code), no problems in an MCP request"
# 2. Admin check through the REST route (application password instead of the cookie nonce)
APP_PASSWORD=$(wp user application-password create "$ADMIN_LOGIN" lit-mcp-integration-test --porcelain)
HEADERS=$(curl -s -o /dev/null -D - -X POST -H "Host: $HOST" -u "$ADMIN_LOGIN:$APP_PASSWORD" "$BASE/wp-json/lit-mcp/v1/divi-check")
echo "$HEADERS" | grep -qi '^HTTP/[0-9.]* 303' || fail "check route must answer 303: $HEADERS"
echo "$HEADERS" | grep -qi '^location:.*tab=divi.*lit_mcp_notice=divi_checked' || fail "redirect to the tab: $HEADERS"
REPORT=$(wp option get lit_mcp_divi_check_report --format=json)
echo "$REPORT" | jq -e '[.steps[] | (.problems == [] and .skipped == false)] | all and length == 5' >/dev/null \
|| fail "every check step must be green: $REPORT"
[ "$(check_pages)" = "0" ] || fail "the throwaway page must be gone"
[ "$(wp option get lit_mcp_divi_check_lock 2>/dev/null || true)" = "" ] || fail "the check lock must be released"
pass "admin check (REST): five green steps, throwaway page deleted, lock released"
UNAUTH=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Host: $HOST" "$BASE/wp-json/lit-mcp/v1/divi-check")
[ "$UNAUTH" = "401" ] || [ "$UNAUTH" = "403" ] || fail "anonymous check must be refused, got $UNAUTH"
pass "check route refuses anonymous requests"
# 3. Incompatible Divi (simulated: a failed fingerprint in the state cache) locks every write path
wp eval '
$key = LIT\MCP\Integrations\Divi\Release\ReleaseResolver::stateKey((string) LIT\MCP\Integrations\Divi\Release\DiviEnvironment::version());
set_transient(LIT\MCP\Integrations\Divi\Release\ReleaseResolver::CACHE, ["key" => $key, "problems" => ["Smoke: Nachbau geändert"]], 600);'
PAGE_ID=$(wp --user="$ADMIN_LOGIN" post create --post_type=page --post_status=draft --post_title="LIT Freigabe Smoke" --porcelain)
CREATED_POSTS+=("$PAGE_ID")
wp post meta update "$PAGE_ID" _et_pb_use_builder on >/dev/null
wp post meta update "$PAGE_ID" _et_pb_use_divi_5 on >/dev/null
BEFORE=$(wp post get "$PAGE_ID" --field=post_content)
OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}")
echo "$OUTLINE" | jq -e '.warnings | any(test("inkompatibel"))' >/dev/null || fail "read tool must carry the notice: $OUTLINE"
pass "reading Divi tool runs and warns"
INSERT=$(tool lit-divi-insert "{\"post_id\":$PAGE_ID,\"tree_rev\":$(echo "$OUTLINE" | jq '.tree_rev'),\"nodes\":[{\"type\":\"section\",\"modules\":[{\"type\":\"text\",\"content\":\"<p>x</p>\"}]}]}")
echo "$INSERT" | jq -e '.__error | fromjson | .code == "divi_incompatible"' >/dev/null || fail "write must be refused: $INSERT"
[ "$(wp post get "$PAGE_ID" --field=post_content)" = "$BEFORE" ] || fail "nothing may be written"
pass "writing Divi tool refused with divi_incompatible, page unchanged"
CREATE=$(tool lit-content-create '{"post_type":"page","title":"LIT Freigabe Smoke Neu","divi":[{"type":"section","modules":[{"type":"text","content":"<p>x</p>"}]}]}')
echo "$CREATE" | jq -e '.__error | fromjson | .code == "divi_incompatible"' >/dev/null || fail "content-create with divi must be refused: $CREATE"
[ "$(wp post list --post_type=page --post_status=any --title='LIT Freigabe Smoke Neu' --format=count)" = "0" ] \
|| fail "no draft may remain"
pass "lit-content-create with divi refused before a draft exists"
STATUS=$(tool lit-site-status '{}')
echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .release.state == "incompatible"' >/dev/null \
|| fail "site status must show incompatible: $STATUS"
[ "$(wp db query "SELECT COUNT(*) FROM $(wp db prefix)lit_mcp_log WHERE result = 'refused' AND ability = 'lit/divi-insert'" --skip-column-names)" -ge 1 ] \
|| fail "the refusal must be in the change log"
pass "site status incompatible, refusal logged"
# 4. Back to released: writes work again
wp transient delete lit_mcp_divi_state >/dev/null
OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}")
INSERT=$(tool lit-divi-insert "{\"post_id\":$PAGE_ID,\"tree_rev\":$(echo "$OUTLINE" | jq '.tree_rev'),\"nodes\":[{\"type\":\"section\",\"modules\":[{\"type\":\"text\",\"content\":\"<p>x</p>\"}]}]}")
echo "$INSERT" | jq -e '.ok == true' >/dev/null || fail "write must work when released: $INSERT"
pass "released again: writing works"
# 5. WP-CLI
wp --user="$ADMIN_LOGIN" lit-mcp divi-check >/dev/null || fail "wp lit-mcp divi-check must succeed"
[ "$(check_pages)" = "0" ] || fail "the CLI check must delete its page"
pass "wp lit-mcp divi-check is green and leaves no page"
finish
(Vor dem ersten Lauf die Parameternamen von lit-divi-insert und lit-content-create mit tests/Integration/divi-write-smoke.sh abgleichen – dort stehen gültige Aufrufe; abweichende Schlüssel übernehmen. Hinweis: Die Tabelle heißt <prefix>lit_mcp_log.)
- Step 3: Smokes laufen lassen
cd /home/janniclas/Projekte/WordPress
for s in mcp-smoke wp-basis-smoke media-admin-smoke divi-read-smoke divi-write-smoke divi-library-smoke tb-smoke divi-release-smoke; do
echo "== $s"; timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/$s.sh | tail -3
done
Expected: jedes Skript endet mit ALL CHECKS PASSED. (Die Skripte, die Pods brauchen, nennen das selbst; wie in den früheren Etappen den Benutzer fragen, bevor Pods aktiviert wird.)
- Step 4: Abnahme-Notiz schreiben
docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md mit den manuellen Punkten (vom Benutzer im Browser abzuhaken):
# Divi-Absicherung – manuelle Abnahme
Stand <Datum>, Branch `divi-absicherung`, Version 0.6.0.
1. Einstellungen → LIT MCP → Divi: Zustand „freigegeben – im Plugin als getestet hinterlegt“, Divi 5.2.1.
2. „Prüfung starten“: nach einigen Sekunden Hinweis „Divi-Prüfung abgeschlossen“, fünf ✓; unter Seiten keine
„[LIT MCP Prüfung]“-Seite, auch nicht im Papierkorb.
3. Ungetestete Version simulieren (nur lokal, nicht committen): in `src/Integrations/Divi/Release/DiviReleases.php`
`TESTED = []` setzen. Reiter zeigt „ungetestet“; ein MCP-Client bekommt beim Schreiben `divi_untested` mit dem
Hinweistext, beim Lesen den Hinweis in `warnings`.
4. „Prüfung starten“, dann „Divi 5.2.1 freigeben“: Zustand „freigegeben – auf dieser Website freigegeben am … von …“;
`lit-site-status` zeigt `release.source = database`; Protokoll zeigt den Eintrag `lit/divi-release`.
5. „Freigabe zurückziehen“: wieder „ungetestet“, Schreiben gesperrt, Protokoll-Eintrag. Danach `TESTED` zurücksetzen
(`git diff` leer).
6. Optional: eine vom Plugin geschriebene Seite im Visual Builder öffnen und speichern.
- Step 5: Gesamtlauf, Lint, Commit
Run: composer test && composer lint – Expected: grün.
git add wp-lit-mcp.php src/Plugin.php readme.txt docs/guides/overview.md tests/Integration/divi-release-smoke.sh docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md
Commit wie in den Global Constraints mit Nachricht chore: release 0.6.0 with Divi release gate, smoke test and acceptance notes.
Danach übernimmt der Controller wie in den früheren Etappen: Gesamt-Review des Branches, Security-Audit (Notiz docs/superpowers/notes/2026-09-30-divi-absicherung-security-audit.md), manuelle Abnahme durch den Benutzer, dann superpowers:finishing-a-development-branch.