From ca40e39b18f348809cc2bb3e38a2f808a18f4dae Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 00:36:19 +0200 Subject: [PATCH] docs: Divi safeguard implementation plan Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plans/2026-09-30-divi-absicherung.md | 6855 +++++++++++++++++ 1 file changed, 6855 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-divi-absicherung.md diff --git a/docs/superpowers/plans/2026-09-30-divi-absicherung.md b/docs/superpowers/plans/2026-09-30-divi-absicherung.md new file mode 100644 index 0000000..2fda3e8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-divi-absicherung.md @@ -0,0 +1,6855 @@ +# 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. + +1. **Sieben Adapterklassen, fünf Bereiche.** `Manifest::$area` ordnet jede Klasse einem der fünf Bereiche der Spec zu: Seiten = `DiviPages` (neu; übernimmt `DiviFlags`, CSS-Cache, Builder-Kennzeichen lesen, Builder-URL, Divis Link-Typen); Bibliothek = `DiviLibraryStore` (übernimmt die Meta-Schreibweise aus `LibraryItemSpec` und das Lesen von `_et_pb_built_for_post_type` aus `NewLibraryEntry`) + `DiviRoles`; Theme Builder = `DiviThemeBuilder`; Designsystem = `DesignSystemReader` (übernimmt das Divi-Logo für `MediaUsageFinder`); Katalog = `DiviModules` (übernimmt Divis interne Post-Typen und `et_builder_enabled_for_post_type` für `ContentSchema`, den Block-Typ-Test aus `DiviCompatibility`) + `DynamicContentMap`. `DiviSaver`, `LibraryItemSpec`, `NewLibraryEntry`, `WpLinkTargets`, `PostPresenter`, `ContentSchema`, `MediaUsageFinder`, `DiviPage` und `DiviCompatibility` berühren Divi danach nicht mehr selbst. +2. **Fingerabdrücke per Tokenizer statt Reflection.** Divi lädt `theme-builder/api.php` nur in admin-ajax (Etappe-3-Befund F20); Reflection fände `et_theme_builder_api_save()` in MCP-Anfragen nicht. `SourceFingerprint` liest die Divi-Datei mit `token_get_all()` und schneidet die Funktion bzw. Methode heraus. Gehasht werden die Tokens ohne Kommentare und Leerraum. +3. **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 in `TemplateConditions`), `et_pb_submit_layout` (Terme und Meta neuer Bibliothekseinträge), `SyncToServerController::update` (Builder-Kennzeichen). Große Funktionen wie `SyncToServerController::update` melden auch Änderungen, die uns nicht betreffen – gewollt: lieber einmal zu oft prüfen als still falsch schreiben; `--diff` zeigt die Stelle. +4. **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-status` und im Prüfbericht (Schritt „Manifeste“) und müssen für eine Freigabe grün sein. Präzisiert Spec §5.2 „Live-Prüfung“. +5. **„Prüfung starten“ läuft über eine REST-Route** (`POST /wp-json/lit-mcp/v1/divi-check`, Cookie-Anmeldung mit `wp_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 über `the_content` rendert; 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 bleiben `admin-post`-Aktionen. Präzisiert Spec §6.1. +6. **Prüfsperre als Option** `lit_mcp_divi_check_lock` (`add_option` ist atomar, ein Transient nicht); älter als 10 Minuten gilt sie als verwaist. Präzisiert Spec §8 („Prüfsperre“). +7. **Prüfschritte im Bericht:** fünf statt vier – „Manifeste“ (Code und Registrierungen) und „Fingerabdrücke“ getrennt, damit `--update-reference` erkennt, dass nur Fingerabdrücke rot sind. Der Schreibtest entfällt nur, wenn „Manifeste“ rot ist. +8. **Wegwerfseite:** Post-Typ `page`, Status `draft`, Titel „[LIT MCP Prüfung] “, Marker-Meta `_lit_mcp_check_page` = `1` (für das Aufräumen verwaister Seiten). Endgültig gelöscht mit `wp_delete_post($id, true)`. +9. **Version 0.6.0.** DB-Freigaben hängen an der Plugin-Version; die Etappe ändert, wie das Plugin Divi nutzt. +10. **Unit-Tests:** `LIT\MCP\Tests\Unit\TestCase` setzt in `setUp()` einen Resolver, der „freigegeben“ meldet (`ReleaseGate::useResolver()`), und setzt ihn in `tearDown()` 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-MCP` anlegen 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 Transient `lit_mcp_divi_state` wieder 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 mit `defined('ABSPATH') || exit;`, höchstens 120 Zeichen pro Zeile. Nach jedem Task: `composer test` und `composer lint` sauber. +- **Divi-Grenze (Spec §3):** Nach Task 7 berührt außerhalb der Klassen in `DiviAdapters::CLASSES` kein Code Divi (`et_*(`-Aufrufe, `ET\…`/`ET_Core…`/`ET_Builder…`-Klassen, `ET_*`-Konstanten, Zeichenketten `'et_…'`/`'_et_…'`); jede Fundstelle in einem Adapter steht in dessen Manifest. `DiviBoundaryTest` erzwingt beides. +- **Sperre (Spec §5):** Zustand `released` nur, wenn die Codeprüfung grün ist **und** die Divi-Version in `DiviReleases::TESTED` steht oder einen DB-Eintrag für genau diese Plugin-Version hat. Schreibende Divi-Tools liefern sonst `divi_untested` bzw. `divi_incompatible` und schreiben nichts; lesende Divi-Tools laufen und bekommen den Hinweis in `warnings`. 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. Nur `manage_options`, mit Nonce. +- **Wegwerfseite immer löschen:** `wp_delete_post($id, true)` im `finally`; 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-`, indirekte `lit/`. +- 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 mit `Functions\expect()` prüfen. Reine PHP-Tests erben von `PHPUnit\Framework\TestCase`. Einzelne Tests: `vendor/bin/phpunit --filter `. Integrationstests: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`. WP-CLI in DDEV: `cd /home/janniclas/Projekte/WordPress && ddev wp `. +- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "" -m "Co-Authored-By: Claude Opus 5.5 (1M context) "` (im Folgenden „Commit wie in den Global Constraints“). + +## Review Focus + +1. **Sperre umgangen über einen Nebenweg:** `lit-content-create` mit `divi`, `lit-library-insert`, `lit/library-make-global`, `lit/tb-create-template`, `lit/tb-assign`, `lit/tb-activate` bei ungetesteter Version. Erwartet: `divi_untested`, kein Entwurf, kein Bibliothekseintrag, keine Vorlage bleibt zurück → Tests `DiviToolGateTest::testWriteToolOfEveryDiviGroupIsRefused` (Task 10), `ContentCreateDiviTest::testLockedDiviRefusesBeforeTheDraftExists` (Task 11), `ReleaseGuardsTest` (Task 11: Saver, Bibliothek, Theme Builder werfen `DiviLocked` vor dem ersten Schreibaufruf). +2. **Hinweis bricht lesende Tools:** Ein lesendes Divi-Tool liefert eine Liste, einen `WP_Error` oder schon ein `warnings`-Feld; das Ausgabeschema erlaubt kein `warnings`. Erwartet: Fehler und Listen unverändert, vorhandene Warnungen bleiben erhalten, Schema erlaubt `warnings` → Tests `DiviToolGateTest::testReadResultsKeepTheirShapeAndGetTheNotice`, `::testOutputSchemaAllowsWarnings` (Task 10). +3. **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). +4. **Wegwerfseite bleibt stehen:** Composer wirft, Rendern wirft, `wp_delete_post` liefert `false`, der Prozess stirbt. Erwartet: Seite gelöscht bzw. ID im roten Bericht; beim nächsten Öffnen des Reiters aufgeräumt → Tests `WriteProbeTest::testDeletesThePageEvenWhenWritingThrows`, `::testReportsAPageThatCouldNotBeDeleted`, `::testRemovesOrphanedCheckPages` (Task 12). +5. **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::class` im Rumpf, Datei fehlt. Erwartet: Hash stabil bzw. geändert, richtige Funktion, fehlende Datei = rot statt Ausnahme → Tests `SourceFingerprintTest` (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` (wirft `RuntimeException`), `SourceFingerprint::fromSource(string $source, string $function, ?string $class = null): ?SourceFingerprint`, Eigenschaften `string $hash` (sha256, 64 Hex-Zeichen), `string $source` (wörtlicher Text von `function` bis zur schließenden Klammer), `int $startLine`, `int $endLine`. + +- [ ] **Step 1: Write the failing test** + +```php + 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 + $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 $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`). + +```bash +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 = [])`, Konstanten `PAGES`, `LIBRARY`, `THEME_BUILDER`, `DESIGN`, `CATALOG`; `names(): list` (Funktionen, Klassen, Konstanten, Post-Typen, Taxonomien, Schlüssel). + - `interface DiviAdapter { public static function manifest(): Manifest; }` + - `DiviAdapters::CLASSES` (list), `DiviAdapters::manifests(): array`, `DiviAdapters::mirrors(): list`. + - `DiviTouches::inSource(string $source): list`, `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 +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 +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 + $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 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 +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 +.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 + $calls Divi functions and static methods the adapter calls. + * @param list $constants Divi constants it reads. + * @param list $postTypes Divi post types it needs registered. + * @param list $taxonomies Divi taxonomies it needs registered. + * @param list $blockTypes Divi block types it needs (registered or in Divi's module metadata). + * @param list $keys Other Divi names it uses as strings: meta keys, options, internal post + * types that need no registration check, query arguments. + * @param list $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 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 +> */ + public const CLASSES = []; + + /** @return array, Manifest> */ + public static function manifests(): array + { + $manifests = []; + foreach (self::CLASSES as $class) { + $manifests[$class] = $class::manifest(); + } + + return $manifests; + } + + /** @return list */ + 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 +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 */ + public static function inFile(string $path): array + { + $source = file_get_contents($path); + + return is_string($source) ? self::inSource($source) : []; + } + + /** @return list */ + 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 $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. + +```bash +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` (nur `knowsGlobalLayout()`), `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 in `ContentSchema::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 +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): + +```php + /** 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 +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: + +```php + 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: + +```php + /** 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: + +```php + 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 + +```php + 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.php` aus `DiviFlags.php`; `tests/Unit/Integrations/Divi/Pages/DiviPagesTest.php` aus `DiviFlagsTest.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 DiviAdapter` mit `FLAGS` (wie bisher `DiviFlags::FLAGS`), `setFlags(int $postId): void` (wirft `RuntimeException`, wie `DiviFlags::set()`), `usesBuilder(int $postId): bool`, `builderUrl(WP_Post|int $post): string`, `clearPageCss(int $postId): void`, `isLinkable(string $postType): bool` – alle statisch. Mirror-ID `vb-sync-flags`. + +- [ ] **Step 1: Klasse und Test umbenennen, Tests schreiben** + +```bash +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`): + +```php + 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 + '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_ 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 + +```php + 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()`: + +```php + /** 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`; + +```php + public static function usesDivi(WP_Post $post): bool + { + return DiviPages::usesBuilder((int) $post->ID) + || str_contains((string) $post->post_content, '')]; + yield 'saveNew' => [static fn () => (new DiviSaver())->saveNew(7, '')]; + 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`): + +```php + 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: + +```php + 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;`: + +```php + ReleaseGate::assertWritable($postId); +``` + +erste Zeile in `saveNew()`: + +```php + 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()`: + +```php + /** + * 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`): + +```php + 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: + +```php + $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` 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`): + +```php + // 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 von `tests/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`. + - `FormatCheck::REFERENCE`, `__construct(?string $file = null, ?Closure $wpBlocks = null)`, `problems(): list`. + - `WriteProbe::MARKER = '_lit_mcp_check_page'`, `TITLE = '[LIT MCP Prüfung]'`, `SECTIONS`, `TEXTS`; `__construct(ContentComposer $composer, ?PageRenderer $renderer = null, ?string $diviDir = null)`, `run(): list`, statisch `removeOrphans(): list`. Nicht `final` (Mock im `CompatibilityCheckTest`). + - `CheckStep` mit Schlüsseln `MANIFEST = 'manifest'`, `MIRRORS = 'mirrors'`, `CATALOG = 'catalog'`, `FORMAT = 'format'`, `WRITE = 'write'`; `__construct(string $key, array $problems, bool $skipped = false)`, `green(): bool`, `label(): string`, `toArray()`, statisch `fromArray(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()`, statisch `fromArray(mixed $data): ?CheckReport`. + - `CheckReportStore::OPTION = 'lit_mcp_divi_check_report'`, `latest(): ?CheckReport`, `save(CheckReport $report): void`. Nicht `final`. + - `CompatibilityCheck::__construct(ManifestCheck $manifests, Closure $catalog, FormatCheck $format, WriteProbe $probe, Closure $version, Closure $stateKey)`, statisch `create(ContentComposer $composer): self`, `run(int $userId): CheckReport`. + +- [ ] **Step 1: Write the failing tests** + +`tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php`: + +```php + [ + '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 + */ + 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' => ''])); + 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 + 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 +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** + +```bash +cp tests/Fixtures/divi/converted-landing.html src/Integrations/Divi/Release/reference-page.html +``` + +`src/Integrations/Divi/Schema/ShortformCheck.php`: + +```php + 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 +)|null $wpBlocks null = parse_blocks(). */ + public function __construct(private ?string $file = null, private ?Closure $wpBlocks = null) + { + } + + /** @return list */ + 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 $blocks + * @return list}> + */ + private static function structure(array $blocks): array + { + return array_map(static fn (Block $block): array => [$block->name(), self::structure($block->children())], $blocks); + } + + /** + * @param array $blocks parse_blocks() result; freeform parts (blockName null) are left out. + * @return list}> + */ + 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 + 'section', + 'label' => 'LIT MCP Prüfung', + 'rows' => [ + [ + 'columns' => '1_2,1_2', + 'modules' => [ + [ + ['type' => 'heading', 'text' => 'litcheck-heading', 'level' => 'h2'], + ['type' => 'text', 'content' => '

litcheck-text

'], + ['type' => 'button', 'text' => 'litcheck-button', 'url' => '/'], + ], + [ + ['type' => 'blurb', 'title' => 'litcheck-blurb', 'content' => '

litcheck-blurb-text

'], + ['type' => 'cta', 'title' => 'litcheck-cta', 'button_text' => 'litcheck-cta-button', 'button_url' => '/'], + ['type' => 'divider'], + ], + ], + ], + [ + 'columns' => '4_4', + 'modules' => [ + ['type' => 'toggle', 'title' => 'litcheck-toggle', 'content' => '

litcheck-toggle-text

'], + ['type' => 'accordion', 'items' => [['title' => 'litcheck-accordion', 'content' => '

a

']]], + ['type' => 'tabs', 'items' => [['title' => 'litcheck-tab', 'content' => '

t

']]], + ['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 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 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 */ + 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, '