Files
WP-LIT-MCP/docs/superpowers/plans/2026-09-30-divi-absicherung.md
2026-09-30 00:36:19 +02:00

6856 lines
276 KiB
Markdown
Raw Permalink Blame History

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