# 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, '