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

276 KiB
Raw Permalink Blame History

Divi-Absicherung – Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Nur fünf Adapterbereiche sprechen Divi an, jeder mit Manifest und Fingerabdrücken der nachgebauten Divi-Funktionen; eine ungetestete oder veränderte Divi-Version sperrt alle schreibenden Divi-Wege mit klarer Antwort (divi_untested/divi_incompatible), lesende Divi-Tools liefern einen Hinweis; Freigabe über eine Versionsliste im Code oder – nach grüner Admin-Prüfung mit Wegwerfseite – über einen Datenbankeintrag; dieselbe Prüfung als wp lit-mcp divi-check mit --diff und --update-reference.

Architecture: Neuer Namensraum LIT\MCP\Integrations\Divi\Release: SourceFingerprint (Tokenizer, liest Divi-Dateien ohne sie zu laden), Manifest/Call/Mirror, DiviAdapter-Schnittstelle und Register DiviAdapters, DiviTouches (Scanner für den Architekturtest), ManifestCheck (Prüfungen aus den Manifesten), ReleaseResolver/ReleaseStatus/ReleaseGate (Zustand, Zwischenspeicher, Sperre), ReleaseStore (DB-Freigaben), CompatibilityCheck mit FormatCheck/WriteProbe/CheckReport (Admin-Prüfung), DiviToolGate (zentrale Sperre in AbilityRegistrar), DiviCheckRoute (REST-Route der Prüfung), Admin-Reiter DiviTab, WP-CLI DiviCheckCommand. Sieben Adapterklassen in fünf Bereichen: DiviPages (neu, ersetzt DiviFlags), DiviLibraryStore, DiviRoles, DiviThemeBuilder, DesignSystemReader, DiviModules, DynamicContentMap.

Tech Stack: PHP ≥ 8.1 (token_get_all, T_NAME_*-Tokens, Enums), WordPress 7.x (Abilities API, REST API mit Cookie-Nonce, Options, Transients, wp_delete_post), Divi 5.2.1, WP-CLI (WP_CLI::add_command), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq/curl-Smokes.

Spec: docs/superpowers/specs/2026-09-29-divi-absicherung-design.md (freigegeben 2026-09-30). Hintergrund: docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md (Divi-Speicherformate), tests/Fixtures/divi/stage3/README.md (Befunde, v. a. Nr. 6: Taxonomien nicht in jedem Kontext registriert).

Voraussetzung: Branch divi-absicherung ab main (903246d, 0.5.0) mit Spec-Commit 94b8c7b; composer test grün (2202 Tests). DDEV unter /home/janniclas/Projekte/WordPress mit aktivem Divi 5.2.1.

Entscheidungen dieses Plans

Die Spec überlässt die Klassenaufteilung dem Plan (§3). Diese Punkte präzisieren sie; wo der Plan von der Spec-Formulierung abweicht, steht es hier und wird dem Benutzer bei der Übergabe genannt.

  1. Sieben Adapterklassen, fünf Bereiche. Manifest::$area ordnet jede Klasse einem der fünf Bereiche der Spec zu: Seiten = DiviPages (neu; übernimmt DiviFlags, CSS-Cache, Builder-Kennzeichen lesen, Builder-URL, Divis Link-Typen); Bibliothek = DiviLibraryStore (übernimmt die Meta-Schreibweise aus LibraryItemSpec und das Lesen von _et_pb_built_for_post_type aus NewLibraryEntry) + DiviRoles; Theme Builder = DiviThemeBuilder; Designsystem = DesignSystemReader (übernimmt das Divi-Logo für MediaUsageFinder); Katalog = DiviModules (übernimmt Divis interne Post-Typen und et_builder_enabled_for_post_type für ContentSchema, den Block-Typ-Test aus DiviCompatibility) + DynamicContentMap. DiviSaver, LibraryItemSpec, NewLibraryEntry, WpLinkTargets, PostPresenter, ContentSchema, MediaUsageFinder, DiviPage und DiviCompatibility berühren Divi danach nicht mehr selbst.
  2. Fingerabdrücke per Tokenizer statt Reflection. Divi lädt theme-builder/api.php nur in admin-ajax (Etappe-3-Befund F20); Reflection fände et_theme_builder_api_save() in MCP-Anfragen nicht. SourceFingerprint liest die Divi-Datei mit token_get_all() und schneidet die Funktion bzw. Methode heraus. Gehasht werden die Tokens ohne Kommentare und Leerraum.
  3. Nachgebaute Divi-Funktionen (Mirrors), fünf Stück: et_theme_builder_api_save (Vorlagenliste, „ungenutzt“-Markierung), et_theme_builder_store_template (Bedingungen, Aktiv-Schalter), et_theme_builder_load_template_setting_options (Zerlegen der Bedingungs-IDs in TemplateConditions), et_pb_submit_layout (Terme und Meta neuer Bibliothekseinträge), SyncToServerController::update (Builder-Kennzeichen). Große Funktionen wie SyncToServerController::update melden auch Änderungen, die uns nicht betreffen – gewollt: lieber einmal zu oft prüfen als still falsch schreiben; --diff zeigt die Stelle.
  4. Die Sperre zur Laufzeit prüft nur Code, also Funktionen und Methoden samt Argumentzahl, Konstanten und Fingerabdrücke. Registrierungen (Post-Typen, Taxonomien, Block-Typen) hängen vom Anfragekontext ab (Befund 6) und würden sonst fälschlich sperren. Sie stehen in lit-site-status und im Prüfbericht (Schritt „Manifeste“) und müssen für eine Freigabe grün sein. Präzisiert Spec §5.2 „Live-Prüfung“.
  5. „Prüfung starten“ läuft über eine REST-Route (POST /wp-json/lit-mcp/v1/divi-check, Cookie-Anmeldung mit wp_rest-Nonce als Formularfeld, manage_options, Antwort 303 zurück zum Reiter). Grund: Im Admin-Kontext (is_admin()) ist nicht belegt, dass Divi Blöcke über the_content rendert; in REST-Anfragen ist es belegt (lit-page-render-summary), und die Codeprüfung läuft so im selben Kontext wie MCP-Aufrufe. Freigeben und Zurückziehen bleiben admin-post-Aktionen. Präzisiert Spec §6.1.
  6. Prüfsperre als Option lit_mcp_divi_check_lock (add_option ist atomar, ein Transient nicht); älter als 10 Minuten gilt sie als verwaist. Präzisiert Spec §8 („Prüfsperre“).
  7. Prüfschritte im Bericht: fünf statt vier – „Manifeste“ (Code und Registrierungen) und „Fingerabdrücke“ getrennt, damit --update-reference erkennt, dass nur Fingerabdrücke rot sind. Der Schreibtest entfällt nur, wenn „Manifeste“ rot ist.
  8. Wegwerfseite: Post-Typ page, Status draft, Titel „[LIT MCP Prüfung] “, Marker-Meta _lit_mcp_check_page = 1 (für das Aufräumen verwaister Seiten). Endgültig gelöscht mit wp_delete_post($id, true).
  9. Version 0.6.0. DB-Freigaben hängen an der Plugin-Version; die Etappe ändert, wie das Plugin Divi nutzt.
  10. Unit-Tests: LIT\MCP\Tests\Unit\TestCase setzt in setUp() einen Resolver, der „freigegeben“ meldet (ReleaseGate::useResolver()), und setzt ihn in tearDown() zurück. Bestehende Tests bleiben so unverändert; Tests der Sperre setzen ihren eigenen Status.

Global Constraints

  • Dateien nur im Plugin-Verzeichnis /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP anlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und räumen Testseiten, Optionen (lit_mcp_divi_releases, lit_mcp_divi_check_report, lit_mcp_divi_check_lock) und den Transient lit_mcp_divi_state wieder auf.
  • PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.x); Divi 5.2.1. Namespace LIT\MCP\ → src/, PSR-4, eine Klasse pro Datei, jede Datei mit defined('ABSPATH') || exit;, höchstens 120 Zeichen pro Zeile. Nach jedem Task: composer test und composer lint sauber.
  • Divi-Grenze (Spec §3): Nach Task 7 berührt außerhalb der Klassen in DiviAdapters::CLASSES kein Code Divi (et_*(-Aufrufe, ET\…/ET_Core…/ET_Builder…-Klassen, ET_*-Konstanten, Zeichenketten 'et_…'/'_et_…'); jede Fundstelle in einem Adapter steht in dessen Manifest. DiviBoundaryTest erzwingt beides.
  • Sperre (Spec §5): Zustand released nur, wenn die Codeprüfung grün ist und die Divi-Version in DiviReleases::TESTED steht oder einen DB-Eintrag für genau diese Plugin-Version hat. Schreibende Divi-Tools liefern sonst divi_untested bzw. divi_incompatible und schreiben nichts; lesende Divi-Tools laufen und bekommen den Hinweis in warnings. Jede schreibende Adapter-Methode prüft zusätzlich selbst (ReleaseGate::assertWritable()); Ausnahme nur für die ID der Wegwerfseite während der Prüfung (ReleaseGate::whileProbing()), nie von außen setzbar.
  • Freigabe per Datenbank nur nach frischem grünem Bericht: gleiche Divi-Version, gleiche Plugin-Version, gleicher Zustandsschlüssel (ReleaseResolver::stateKey()), alle Schritte grün. Nur manage_options, mit Nonce.
  • Wegwerfseite immer löschen: wp_delete_post($id, true) im finally; schlägt es fehl, ist der Bericht rot und nennt die ID; verwaiste Seiten (Marker-Meta) räumt der Reiter beim Öffnen auf.
  • Umbau ohne Verhaltensänderung: Alle bestehenden Unit-Tests und Smoke-Skripte bleiben grün; geänderte Tests nur dort, wo eine Klasse umzieht oder ein Test Divi-Namen direkt prüft.
  • Sprache und Wortwahl: Oberfläche, Fehlermeldungen, Hinweise, Guides, Readme auf Deutsch mit echten Umlauten. Neutral formulieren: „KI-Assistent“, „MCP-Client“, „Verbindung“. Tool-Namen in Texten: direkte lit-<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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use LIT\MCP\Integrations\Divi\Release\SourceFingerprint;
use PHPUnit\Framework\TestCase;
use RuntimeException;

final class SourceFingerprintTest extends TestCase
{
    /** Shaped like Divi's files: a guarded function, two classes with a method of the same name. */
    private const SOURCE = <<<'PHP'
<?php
use function et_demo_save;
if ( ! function_exists( 'et_demo_save' ) ) {
	/**
	 * Saves.
	 */
	function et_demo_save( $id, $data = array() ) {
		$value = "{$data['x']}";
		if ( $id ) {
			update_post_meta( $id, '_et_demo', $value );
		}
		return Foo::class;
	}
}

class Controller {
	public static function update( $request ) {
		return array( 'a' => 1 );
	}
}

class Other {
	public static function update( $request ) {
		return array( 'b' => 2 );
	}
}
PHP;

    public function testFindsAFunctionInsideAFunctionExistsGuard(): void
    {
        $print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save');

        self::assertNotNull($print);
        self::assertSame(7, $print->startLine);
        self::assertSame(13, $print->endLine);
        self::assertStringStartsWith('function et_demo_save(', $print->source);
        self::assertStringEndsWith('}', $print->source);
        self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $print->hash);
    }

    public function testClassConstantsAndInterpolationDoNotConfuseTheBraceCount(): void
    {
        $print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save');

        self::assertNotNull($print);
        self::assertStringContainsString('return Foo::class;', $print->source);
        self::assertStringNotContainsString('class Controller', $print->source);
    }

    public function testMethodIsFoundInItsOwnClassOnly(): void
    {
        $controller = SourceFingerprint::fromSource(self::SOURCE, 'update', 'ET\Builder\VisualBuilder\Controller');
        $other = SourceFingerprint::fromSource(self::SOURCE, 'update', 'Other');

        self::assertNotNull($controller);
        self::assertNotNull($other);
        self::assertStringContainsString("'a' => 1", $controller->source);
        self::assertStringContainsString("'b' => 2", $other->source);
        self::assertNotSame($controller->hash, $other->hash);
        self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'update'), 'a method is no top-level function');
        self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save', 'Controller'));
        self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'missing'));
    }

    public function testCommentsAndWhitespaceDoNotChangeTheHash(): void
    {
        $reformatted = str_replace(
            ["\t\t\$value = ", "if ( \$id ) {"],
            ["\t\t// Why: demo.\n\t\t\$value   =   ", "if (\$id)\n\t\t{"],
            self::SOURCE
        );

        self::assertSame(
            SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
            SourceFingerprint::fromSource($reformatted, 'et_demo_save')?->hash
        );
    }

    public function testACodeChangeChangesTheHash(): void
    {
        $changed = str_replace("'_et_demo'", "'_et_demo_v2'", self::SOURCE);

        self::assertNotSame(
            SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
            SourceFingerprint::fromSource($changed, 'et_demo_save')?->hash
        );
    }

    public function testOfReadsTheFileAndThrowsForMissingFunctions(): void
    {
        $file = (string) tempnam(sys_get_temp_dir(), 'lit');
        file_put_contents($file, self::SOURCE);

        try {
            self::assertSame(
                SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
                SourceFingerprint::of($file, 'et_demo_save')->hash
            );
            try {
                SourceFingerprint::of($file, 'update', 'Missing');
                self::fail('a missing method must throw');
            } catch (RuntimeException $e) {
                self::assertStringContainsString('Missing::update() not found', $e->getMessage());
            }
        } finally {
            unlink($file);
        }
    }

    public function testOfThrowsForAnUnreadableFile(): void
    {
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('is not readable');

        SourceFingerprint::of('/nonexistent/lit-divi.php', 'et_demo_save');
    }
}
  • Step 2: Run test to verify it fails

Run: vendor/bin/phpunit --filter SourceFingerprintTest Expected: FAIL with „Class "LIT\MCP\Integrations\Divi\Release\SourceFingerprint" not found“.

  • Step 3: Write minimal implementation
<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use RuntimeException;

defined('ABSPATH') || exit;

/**
 * Fingerprint of one Divi function or method (spec §4.2), read with token_get_all(): the file is never included,
 * so functions Divi loads only in some requests (theme-builder/api.php, admin-ajax only) are readable in every
 * request. The hash covers the tokens from "function" to the closing brace without comments and whitespace –
 * new comments or reformatting keep it, any code change changes it. $source is the verbatim text of those
 * tokens for the human diff (wp lit-mcp divi-check --diff).
 */
final class SourceFingerprint
{
    private const IGNORED = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];

    private const TYPES = [T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM];

    private function __construct(
        public readonly string $hash,
        public readonly string $source,
        public readonly int $startLine,
        public readonly int $endLine
    ) {
    }

    /** @throws RuntimeException When the file is unreadable or holds no such function. */
    public static function of(string $file, string $function, ?string $class = null): self
    {
        $source = is_file($file) && is_readable($file) ? file_get_contents($file) : false;
        if (! is_string($source)) {
            throw new RuntimeException(sprintf('%s is not readable', $file));
        }

        return self::fromSource($source, $function, $class) ?? throw new RuntimeException(sprintf(
            '%s not found in %s',
            $class === null ? $function . '()' : self::shortName($class) . '::' . $function . '()',
            $file
        ));
    }

    /**
     * The function $function outside every class body, or – with $class – the method $function declared directly
     * in the class (trait, interface, enum) named like $class's last segment. The namespace is not compared: the
     * file path of the manifest fixes where to look.
     */
    public static function fromSource(string $source, string $function, ?string $class = null): ?self
    {
        $tokens = token_get_all($source);
        $wanted = $class === null ? null : self::shortName($class);
        $depth = 0;
        /** @var list<array{0: int, 1: string}> $bodies Open class bodies: [brace depth, name]. */
        $bodies = [];
        $pending = null;

        foreach ($tokens as $i => $token) {
            $id = is_array($token) ? $token[0] : null;
            $text = is_array($token) ? $token[1] : $token;

            if ($id !== null && in_array($id, self::TYPES, true) && self::previousId($tokens, $i) !== T_DOUBLE_COLON) {
                $pending = self::nextName($tokens, $i) ?? '';
                continue;
            }
            if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
                ++$depth;
                if ($pending !== null && $text === '{') {
                    $bodies[] = [$depth, $pending];
                    $pending = null;
                }
                continue;
            }
            if ($text === '}') {
                if ($bodies !== [] && $bodies[count($bodies) - 1][0] === $depth) {
                    array_pop($bodies);
                }
                --$depth;
                continue;
            }
            if ($id !== T_FUNCTION || self::previousId($tokens, $i) === T_USE) {
                continue;
            }

            $name = self::nextName($tokens, $i);
            if ($name === null || strcasecmp($name, $function) !== 0) {
                continue;
            }
            $body = $bodies === [] ? null : $bodies[count($bodies) - 1];
            $matches = $wanted === null
                ? $body === null
                : $body !== null && $body[0] === $depth && strcasecmp($body[1], $wanted) === 0;
            if ($matches) {
                return self::capture($tokens, $i);
            }
        }

        return null;
    }

    /** @param array<int, mixed> $tokens */
    private static function capture(array $tokens, int $start): ?self
    {
        $startLine = (int) $tokens[$start][2];
        $parts = [];
        $verbatim = '';
        $depth = 0;
        $opened = false;
        $count = count($tokens);

        for ($i = $start; $i < $count; ++$i) {
            $token = $tokens[$i];
            $id = is_array($token) ? $token[0] : null;
            $text = is_array($token) ? $token[1] : $token;
            $verbatim .= $text;
            if ($id === null || ! in_array($id, self::IGNORED, true)) {
                $parts[] = $text;
            }

            if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
                ++$depth;
                $opened = true;
            } elseif ($text === '}') {
                --$depth;
                if ($opened && $depth === 0) {
                    return new self(
                        hash('sha256', implode(' ', $parts)),
                        $verbatim,
                        $startLine,
                        $startLine + substr_count($verbatim, "\n")
                    );
                }
            } elseif ($text === ';' && ! $opened) {
                return null;
            }
        }

        return null;
    }

    /** The next name after $i (skipping whitespace, comments and "&"), null when something else follows. */
    private static function nextName(array $tokens, int $i): ?string
    {
        $count = count($tokens);
        for ($j = $i + 1; $j < $count; ++$j) {
            $token = $tokens[$j];
            if (is_array($token) && in_array($token[0], self::IGNORED, true)) {
                continue;
            }
            if ($token === '&' || (is_array($token) && $token[1] === '&')) {
                continue;
            }

            return is_array($token) && $token[0] === T_STRING ? $token[1] : null;
        }

        return null;
    }

    /** Token ID of the previous significant token, null for a single-character token or none. */
    private static function previousId(array $tokens, int $i): ?int
    {
        for ($j = $i - 1; $j >= 0; --$j) {
            $token = $tokens[$j];
            if (is_array($token) && in_array($token[0], self::IGNORED, true)) {
                continue;
            }

            return is_array($token) ? $token[0] : null;
        }

        return null;
    }

    public static function shortName(string $class): string
    {
        $position = strrpos($class, '\\');

        return $position === false ? $class : substr($class, $position + 1);
    }
}
  • Step 4: Run test to verify it passes

Run: vendor/bin/phpunit --filter SourceFingerprintTest Expected: PASS (7 Tests).

  • Step 5: Lint und Commit

Run: composer lint (bei Befunden in der neuen Datei vendor/bin/phpcbf src/Integrations/Divi/Release/SourceFingerprint.php und erneut composer lint).

git add src/Integrations/Divi/Release/SourceFingerprint.php tests/Unit/Integrations/Divi/Release/SourceFingerprintTest.php

Commit wie in den Global Constraints mit Nachricht feat: fingerprint Divi functions from source tokens.


Task 2: Manifest, Adapter-Register, Scanner und Architekturtest

Files:

  • Create: src/Integrations/Divi/Release/Call.php, Mirror.php, Manifest.php, DiviAdapter.php, DiviAdapters.php, DiviTouches.php
  • Test: tests/Unit/Integrations/Divi/Release/ManifestTest.php, tests/Unit/Integrations/Divi/Release/DiviTouchesTest.php, tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php

Interfaces:

  • Consumes: SourceFingerprint::shortName() (Task 1).

  • Produces:

    • Call::__construct(string $symbol, ?int $args = null); className(): ?string (voll qualifiziert, ohne führenden \), member(): string, label(): string (z. B. et_pb_is_allowed(), GlobalData::get_global_colors()).
    • Mirror::__construct(string $id, string $file, string $function, ?string $class, string $what); label(): string.
    • Manifest::__construct(string $area, array $calls = [], array $constants = [], array $postTypes = [], array $taxonomies = [], array $blockTypes = [], array $keys = [], array $mirrors = []), 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), DiviAdapters::manifests(): array<class-string, Manifest>, DiviAdapters::mirrors(): list<Mirror>.
    • DiviTouches::inSource(string $source): list<array{kind: string, name: string, line: int}>, DiviTouches::inFile(string $path), DiviTouches::detects(string $name): bool (kind ∈ function, class, constant, string).
  • Step 1: Write the failing tests

tests/Unit/Integrations/Divi/Release/ManifestTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use PHPUnit\Framework\TestCase;

final class ManifestTest extends TestCase
{
    public function testCallsKnowTheirClassMemberAndLabel(): void
    {
        $function = new Call('et_pb_is_allowed', 1);
        $method = new Call('ET\Builder\Packages\GlobalData\GlobalData::get_global_colors', 0);

        self::assertNull($function->className());
        self::assertSame('et_pb_is_allowed', $function->member());
        self::assertSame('et_pb_is_allowed()', $function->label());
        self::assertSame('ET\Builder\Packages\GlobalData\GlobalData', $method->className());
        self::assertSame('get_global_colors', $method->member());
        self::assertSame('GlobalData::get_global_colors()', $method->label());
        self::assertSame('ET_Core_PageResource', (new Call('\ET_Core_PageResource::remove_static_resources', 3))->className());
    }

    public function testMirrorLabel(): void
    {
        $function = new Mirror('tb-api-save', 'includes/api.php', 'et_theme_builder_api_save', null, 'Vorlagenliste');
        $method = new Mirror('vb-flags', 'x.php', 'update', 'SyncToServerController', 'Kennzeichen');

        self::assertSame('et_theme_builder_api_save()', $function->label());
        self::assertSame('SyncToServerController::update()', $method->label());
    }

    public function testNamesListEveryDeclaredDiviName(): void
    {
        $manifest = new Manifest(
            Manifest::LIBRARY,
            calls: [new Call('et_pb_is_allowed', 1), new Call('ET\Builder\GlobalLayout::is_global_layout_template', 1)],
            constants: ['ET_BUILDER_PRODUCT_VERSION'],
            postTypes: ['et_pb_layout'],
            taxonomies: ['scope'],
            blockTypes: ['divi/global-layout'],
            keys: ['_et_pb_module_type']
        );

        self::assertSame(
            ['et_pb_is_allowed', 'ET\Builder\GlobalLayout', 'ET_BUILDER_PRODUCT_VERSION', 'et_pb_layout', 'scope',
                '_et_pb_module_type'],
            $manifest->names()
        );
        self::assertSame('Bibliothek', $manifest->area);
    }
}

tests/Unit/Integrations/Divi/Release/DiviTouchesTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use LIT\MCP\Integrations\Divi\Release\DiviTouches;
use PHPUnit\Framework\TestCase;

final class DiviTouchesTest extends TestCase
{
    public function testFindsCallsClassesConstantsAndNames(): void
    {
        $source = <<<'PHP'
<?php
use ET\Builder\Packages\GlobalData\GlobalData;
// et_comment_only() and 'et_in_a_comment' are ignored.
/** @see et_theme_builder_get_template() */
final class X {
    public const REGISTRY = 'ET\Builder\Packages\Module\DynamicContentOptions';
    public function a(): void {
        et_pb_is_allowed('theme_builder');
        \et_theme_builder_clear_wp_cache('all');
        \ET_Core_PageResource::remove_static_resources(1, 'all', true);
        $v = defined('ET_BUILDER_PRODUCT_VERSION') ? ET_BUILDER_PRODUCT_VERSION : '';
        get_post_meta(1, '_et_pb_use_builder', true);
        function_exists('et_');
        $this->et_method();
        Foo::et_static();
        get_option('lit_mcp_limits');
    }
}
PHP;

        $touches = array_map(
            static fn (array $t): string => $t['kind'] . ':' . $t['name'],
            DiviTouches::inSource($source)
        );

        self::assertSame(
            [
                'class:ET\Builder\Packages\GlobalData\GlobalData',
                'class:ET\Builder\Packages\Module\DynamicContentOptions',
                'function:et_pb_is_allowed',
                'function:et_theme_builder_clear_wp_cache',
                'class:ET_Core_PageResource',
                'constant:ET_BUILDER_PRODUCT_VERSION',
                'constant:ET_BUILDER_PRODUCT_VERSION',
                'string:_et_pb_use_builder',
                'string:et_',
            ],
            $touches
        );
    }

    public function testDetectsOnlyNamesTheScannerReports(): void
    {
        self::assertTrue(DiviTouches::detects('et_pb_layout'));
        self::assertTrue(DiviTouches::detects('_et_template'));
        self::assertTrue(DiviTouches::detects('ET\Builder\Packages\GlobalData\GlobalData'));
        self::assertTrue(DiviTouches::detects('ET_Core_PageResource'));
        self::assertTrue(DiviTouches::detects('ET_BUILDER_PRODUCT_VERSION'));
        self::assertFalse(DiviTouches::detects('scope'));
        self::assertFalse(DiviTouches::detects('divi/global-layout'));
    }
}

tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use FilesystemIterator;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviTouches;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;

/**
 * Spec §3: only the adapters in DiviAdapters::CLASSES touch Divi, and every Divi name an adapter uses stands in its
 * manifest (and nothing more).
 */
final class DiviBoundaryTest extends TestCase
{
    /**
     * Files that still touch Divi outside an adapter (paths below src/). Tasks 3–7 shrink this list to
     * DiviCompatibility.php; Task 8 removes it together with testLegacyFilesStillTouchDivi.
     */
    private const LEGACY = [
        'Integrations/Divi/Design/DesignSystemReader.php',
        'Integrations/Divi/DiviCompatibility.php',
        'Integrations/Divi/Library/DiviLibraryStore.php',
        'Integrations/Divi/Library/DiviRoles.php',
        'Integrations/Divi/Library/LibraryItemSpec.php',
        'Integrations/Divi/Library/NewLibraryEntry.php',
        'Integrations/Divi/Pages/DiviFlags.php',
        'Integrations/Divi/Pages/DiviPage.php',
        'Integrations/Divi/Pages/DiviSaver.php',
        'Integrations/Divi/Pages/DynamicContentMap.php',
        'Integrations/Divi/Pages/WpLinkTargets.php',
        'Integrations/Divi/Schema/DiviModules.php',
        'Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php',
        'Integrations/Media/MediaUsageFinder.php',
        'Integrations/WordPress/Content/PostPresenter.php',
        'Integrations/WordPress/Schema/ContentSchema.php',
    ];

    public function testOnlyAdaptersTouchDivi(): void
    {
        $offenders = [];
        foreach (self::sourceFiles() as $relative => $path) {
            $touches = DiviTouches::inFile($path);
            if ($touches === [] || in_array($relative, self::LEGACY, true)) {
                continue;
            }
            if (! in_array(self::classOf($relative), DiviAdapters::CLASSES, true)) {
                $offenders[] = $relative . ': ' . implode(', ', array_unique(array_column($touches, 'name')));
            }
        }

        self::assertSame([], $offenders, "Divi darf nur ein Adapter aus DiviAdapters::CLASSES berühren:\n"
            . implode("\n", $offenders));
    }

    public function testLegacyFilesStillTouchDivi(): void
    {
        $files = self::sourceFiles();
        foreach (self::LEGACY as $relative) {
            self::assertArrayHasKey($relative, $files, $relative . ' gibt es nicht mehr – aus LEGACY streichen');
            self::assertNotSame([], DiviTouches::inFile($files[$relative]), $relative . ' ist sauber – aus LEGACY streichen');
            self::assertNotContains(self::classOf($relative), DiviAdapters::CLASSES, $relative . ' ist ein Adapter');
        }
    }

    public function testAdaptersDeclareExactlyTheDiviNamesTheyUse(): void
    {
        foreach (DiviAdapters::CLASSES as $class) {
            self::assertTrue(is_subclass_of($class, DiviAdapter::class), $class . ' implementiert DiviAdapter nicht');
            $declared = $class::manifest()->names();
            $used = array_values(array_unique(array_column(
                DiviTouches::inFile((string) (new ReflectionClass($class))->getFileName()),
                'name'
            )));

            self::assertSame([], array_values(array_diff($used, $declared)), $class . ': nicht im Manifest');
            self::assertSame(
                [],
                array_values(array_diff(array_filter($declared, [DiviTouches::class, 'detects']), $used)),
                $class . ': im Manifest, aber nicht benutzt'
            );
        }
    }

    public function testMirrorIdsAreUniqueAndFilesRelative(): void
    {
        $ids = [];
        foreach (DiviAdapters::mirrors() as $mirror) {
            self::assertNotContains($mirror->id, $ids, 'doppelte Mirror-ID ' . $mirror->id);
            self::assertStringStartsNotWith('/', $mirror->file);
            $ids[] = $mirror->id;
        }
    }

    /** @return array<string, string> Path below src/ => absolute path. */
    private static function sourceFiles(): array
    {
        $root = dirname(__DIR__, 5) . '/src/';
        $files = [];
        $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
        foreach ($iterator as $file) {
            if ($file->getExtension() === 'php') {
                $files[substr((string) $file->getPathname(), strlen($root))] = (string) $file->getPathname();
            }
        }
        ksort($files);

        return $files;
    }

    private static function classOf(string $relative): string
    {
        return 'LIT\\MCP\\' . str_replace(['/', '.php'], ['\\', ''], $relative);
    }
}
  • Step 2: Run tests to verify they fail

Run: vendor/bin/phpunit --filter 'ManifestTest|DiviTouchesTest|DiviBoundaryTest' Expected: FAIL („Class … Call not found“, „… DiviTouches not found“, „… DiviAdapters not found“).

  • Step 3: Write the implementation

src/Integrations/Divi/Release/Call.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/**
 * One Divi function or static method an adapter calls (spec §4.1), with the number of arguments the plugin passes.
 * $args null: only its existence matters (a function Divi itself needs, e.g. et_() inside store_template).
 */
final class Call
{
    /** @param string $symbol "function_name" or "Fully\Qualified\Class::method". */
    public function __construct(public readonly string $symbol, public readonly ?int $args = null)
    {
    }

    public function className(): ?string
    {
        return str_contains($this->symbol, '::') ? ltrim(explode('::', $this->symbol, 2)[0], '\\') : null;
    }

    public function member(): string
    {
        return str_contains($this->symbol, '::') ? explode('::', $this->symbol, 2)[1] : $this->symbol;
    }

    public function label(): string
    {
        $class = $this->className();

        return ($class === null ? '' : SourceFingerprint::shortName($class) . '::') . $this->member() . '()';
    }
}

src/Integrations/Divi/Release/Mirror.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/**
 * A Divi function whose storage logic an adapter repeats instead of calling it (spec §4.1 "mirrors"). Its
 * fingerprint (SourceFingerprint) must match the one in fingerprints.json, else Divi changed what we copy.
 */
final class Mirror
{
    /**
     * @param string      $id       Key in fingerprints.json and name of tests/Fixtures/divi/reference/<id>.txt.
     * @param string      $file     Divi file, relative to the Divi theme directory.
     * @param string      $function Function or method name.
     * @param string|null $class    Class of a method – the short name is enough (the file fixes where it
     *                              lives, and a short name is no Divi name for DiviBoundaryTest); null for a function.
     * @param string      $what     What the plugin repeats (German, for reports).
     */
    public function __construct(
        public readonly string $id,
        public readonly string $file,
        public readonly string $function,
        public readonly ?string $class,
        public readonly string $what
    ) {
    }

    public function label(): string
    {
        return ($this->class === null ? '' : SourceFingerprint::shortName($this->class) . '::') . $this->function . '()';
    }
}

src/Integrations/Divi/Release/Manifest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/**
 * The Divi dependencies of one adapter (spec §4.1) – the single source for DiviCompatibility, the release gate,
 * the admin check and DiviBoundaryTest. Pure data, no WordPress calls.
 */
final class Manifest
{
    public const PAGES = 'Seiten';
    public const LIBRARY = 'Bibliothek';
    public const THEME_BUILDER = 'Theme Builder';
    public const DESIGN = 'Designsystem';
    public const CATALOG = 'Katalog';

    /**
     * @param string       $area       One of the constants above (the five areas of spec §3).
     * @param list<Call>   $calls      Divi functions and static methods the adapter calls.
     * @param list<string> $constants  Divi constants it reads.
     * @param list<string> $postTypes  Divi post types it needs registered.
     * @param list<string> $taxonomies Divi taxonomies it needs registered.
     * @param list<string> $blockTypes Divi block types it needs (registered or in Divi's module metadata).
     * @param list<string> $keys       Other Divi names it uses as strings: meta keys, options, internal post
     *                                 types that need no registration check, query arguments.
     * @param list<Mirror> $mirrors    Divi functions whose storage logic it repeats.
     */
    public function __construct(
        public readonly string $area,
        public readonly array $calls = [],
        public readonly array $constants = [],
        public readonly array $postTypes = [],
        public readonly array $taxonomies = [],
        public readonly array $blockTypes = [],
        public readonly array $keys = [],
        public readonly array $mirrors = []
    ) {
    }

    /**
     * @return list<string> Every Divi name the adapter's code may contain (DiviBoundaryTest): called functions and
     *         classes, constants, post types, taxonomies, keys and the names of mirrored functions.
     */
    public function names(): array
    {
        $names = [];
        foreach ($this->calls as $call) {
            $names[] = $call->className() ?? $call->member();
        }
        $mirrored = array_map(static fn (Mirror $mirror): string => $mirror->function, $this->mirrors);

        return array_values(array_unique(array_merge(
            $names,
            $this->constants,
            $this->postTypes,
            $this->taxonomies,
            $this->keys,
            $mirrored
        )));
    }
}

src/Integrations/Divi/Release/DiviAdapter.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/** A class that may touch Divi (spec §3); it lists every Divi dependency in its manifest. */
interface DiviAdapter
{
    public static function manifest(): Manifest;
}

src/Integrations/Divi/Release/DiviAdapters.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/** The Divi boundary (spec §3): the only classes that touch Divi, in the order reports list them. */
final class DiviAdapters
{
    /** @var list<class-string<DiviAdapter>> */
    public const CLASSES = [];

    /** @return array<class-string<DiviAdapter>, Manifest> */
    public static function manifests(): array
    {
        $manifests = [];
        foreach (self::CLASSES as $class) {
            $manifests[$class] = $class::manifest();
        }

        return $manifests;
    }

    /** @return list<Mirror> */
    public static function mirrors(): array
    {
        $mirrors = [];
        foreach (self::manifests() as $manifest) {
            array_push($mirrors, ...$manifest->mirrors);
        }

        return $mirrors;
    }
}

src/Integrations/Divi/Release/DiviTouches.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/**
 * Finds where PHP code touches Divi (DiviBoundaryTest, spec §3): calls of et_* functions, ET\… / ET_Core… /
 * ET_Builder… classes (also as strings), ET_* constants (also as strings) and the strings 'et_…'/'_et_…' (post
 * types, meta keys, options, function names for function_exists()). Comments are ignored; method calls named
 * et_* ($x->et_a(), Foo::et_b()) are not Divi's.
 */
final class DiviTouches
{
    private const IGNORED = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];

    private const NOT_A_CALL = [
        T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_CONST,
    ];

    /** @return list<array{kind: string, name: string, line: int}> */
    public static function inFile(string $path): array
    {
        $source = file_get_contents($path);

        return is_string($source) ? self::inSource($source) : [];
    }

    /** @return list<array{kind: string, name: string, line: int}> */
    public static function inSource(string $source): array
    {
        $tokens = array_values(array_filter(
            token_get_all($source),
            static fn (mixed $token): bool => ! is_array($token) || ! in_array($token[0], self::IGNORED, true)
        ));

        $touches = [];
        foreach ($tokens as $i => $token) {
            if (! is_array($token)) {
                continue;
            }
            $touch = match ($token[0]) {
                T_STRING, T_NAME_FULLY_QUALIFIED => self::name($tokens, $i, ltrim($token[1], '\\')),
                T_NAME_QUALIFIED => str_starts_with($token[1], 'ET\\') ? ['class', $token[1]] : null,
                T_CONSTANT_ENCAPSED_STRING => self::literal($token[1]),
                default => null,
            };
            if ($touch !== null) {
                $touches[] = ['kind' => $touch[0], 'name' => $touch[1], 'line' => (int) $token[2]];
            }
        }

        return $touches;
    }

    /** Whether inSource() reports $name when the code uses it (DiviBoundaryTest's unused-entry check). */
    public static function detects(string $name): bool
    {
        return self::classify($name) !== null || preg_match('/^_?et_[a-z0-9_]*$/', $name) === 1;
    }

    /**
     * @param array<int, mixed> $tokens
     * @return array{0: string, 1: string}|null
     */
    private static function name(array $tokens, int $i, string $name): ?array
    {
        $classified = self::classify($name);
        if ($classified !== null) {
            return $classified;
        }
        if (preg_match('/^et_[a-z0-9_]*$/', $name) !== 1 || ($tokens[$i + 1] ?? null) !== '(') {
            return null;
        }
        $previous = $tokens[$i - 1] ?? null;

        return is_array($previous) && in_array($previous[0], self::NOT_A_CALL, true) ? null : ['function', $name];
    }

    /** @return array{0: string, 1: string}|null */
    private static function literal(string $text): ?array
    {
        $value = substr($text, 1, -1);
        if ($text[0] === "'") {
            $value = str_replace(['\\\\', "\\'"], ['\\', "'"], $value);
        }
        $value = ltrim($value, '\\');

        $classified = self::classify($value);
        if ($classified !== null) {
            return $classified;
        }

        return preg_match('/^_?et_[a-z0-9_]*$/', $value) === 1 ? ['string', $value] : null;
    }

    /** @return array{0: string, 1: string}|null A Divi class or constant name. */
    private static function classify(string $name): ?array
    {
        if (preg_match('/^ET\\\\[A-Za-z0-9_\\\\]+$/', $name) === 1
            || preg_match('/^ET_(?:Core|Builder)[A-Za-z0-9_]*$/', $name) === 1) {
            return ['class', $name];
        }

        return preg_match('/^ET_[A-Z0-9_]+$/', $name) === 1 ? ['constant', $name] : null;
    }
}
  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'ManifestTest|DiviTouchesTest|DiviBoundaryTest' Expected: PASS. Meldet testOnlyAdaptersTouchDivi weitere Dateien, stimmt die Liste LEGACY nicht mit dem Code überein: die gemeldeten Dateien prüfen – echte Divi-Berührungen in LEGACY aufnehmen (und im Bericht an den Controller nennen), Fehlalarme des Scanners als Test in DiviTouchesTest festhalten und DiviTouches korrigieren. Meldet testLegacyFilesStillTouchDivi eine Datei als sauber, sie aus LEGACY streichen.

  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint Expected: alle Tests grün (2202 + neue), Lint sauber.

git add src/Integrations/Divi/Release tests/Unit/Integrations/Divi/Release

Commit wie in den Global Constraints mit Nachricht feat: Divi manifests, adapter register and boundary test.


Task 3: Adapter „Katalog“ (DiviModules, DynamicContentMap, ContentSchema)

Files:

  • Modify: src/Integrations/Divi/Schema/DiviModules.php, src/Integrations/Divi/Pages/DynamicContentMap.php, src/Integrations/WordPress/Schema/ContentSchema.php, src/Integrations/Divi/DiviCompatibility.php (nur knowsGlobalLayout()), src/Integrations/Divi/Release/DiviAdapters.php
  • Test: tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php (neu), tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php, tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php (LEGACY)

Interfaces:

  • Consumes: Manifest, Call, DiviAdapter, DiviAdapters (Task 2); GlobalInstance::BLOCK ('divi/global-layout'), DiviLibraryStore::POST_TYPE, DiviThemeBuilder::THEME_BUILDER, ::TEMPLATE, ::LAYOUT_TYPES (bestehend).

  • Produces: DiviModules implements DiviAdapter; DiviModules::INTERNAL_POST_TYPES (list, Reihenfolge wie bisher in ContentSchema::EXCLUDED), DiviModules::builderEnabledFor(string $postType): bool, DiviModules::knowsBlock(string $name): bool; DynamicContentMap implements DiviAdapter.

  • Step 1: Write the failing tests

tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Schema;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\TestCase;

final class DiviModulesTest extends TestCase
{
    public function testCatalogAdaptersAreRegistered(): void
    {
        self::assertContains(DiviModules::class, DiviAdapters::CLASSES);
        self::assertContains(DynamicContentMap::class, DiviAdapters::CLASSES);
        self::assertSame(Manifest::CATALOG, DiviModules::manifest()->area);
        self::assertSame(Manifest::CATALOG, DynamicContentMap::manifest()->area);
    }

    public function testManifestListsModuleRegistrationAndTheBuilderSwitch(): void
    {
        $manifest = DiviModules::manifest();

        self::assertSame(
            [
                'ModuleRegistration::get_all_core_modules_metadata()',
                'ModuleRegistration::get_default_attrs()',
                'et_builder_enabled_for_post_type()',
            ],
            array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
        );
        self::assertSame(['divi/global-layout'], $manifest->blockTypes);
        self::assertSame(['ET_BUILDER_PRODUCT_VERSION'], $manifest->constants);
    }

    public function testInternalPostTypesAreDivisOwn(): void
    {
        self::assertSame(
            [
                'et_pb_layout', 'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout',
                'et_footer_layout', 'et_pb_canvas', '_et_pb_speculation',
            ],
            DiviModules::INTERNAL_POST_TYPES
        );
    }

    public function testBuilderEnabledForAsksDivi(): void
    {
        Functions\when('et_builder_enabled_for_post_type')->alias(static fn (string $type): bool => $type === 'page');

        self::assertTrue(DiviModules::builderEnabledFor('page'));
        self::assertFalse(DiviModules::builderEnabledFor('post'));
    }
}

In tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php ergänzen (Import LIT\MCP\Integrations\WordPress\Schema\ContentSchema ist vorhanden):

    /** Spec §3: Divi's post types come from the Katalog adapter – the list itself stays the same. */
    public function testExcludedPostTypesStayTheSame(): void
    {
        self::assertSame(
            [
                'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
                'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'et_pb_layout',
                'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout',
                'et_pb_canvas', '_et_pb_speculation',
            ],
            ContentSchema::EXCLUDED
        );
    }
  • Step 2: Run tests to verify they fail

Run: vendor/bin/phpunit --filter 'DiviModulesTest|ContentSchemaTest' Expected: DiviModulesTest FAIL („Call to undefined method … DiviModules::manifest()“); testExcludedPostTypesStayTheSame PASS (Schutz für den Umbau).

  • Step 3: Implementation

src/Integrations/Divi/Schema/DiviModules.php – Klassenkopf, Konstante, Manifest und zwei neue Methoden; isAvailable(), catalog(), builderVersion() bleiben unverändert:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Schema;

use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;

defined('ABSPATH') || exit;

/**
 * Adapter "Katalog" (spec §3): Divi's module metadata once per request (Divi 5 only), Divi's own post types and
 * its per-type builder switch for the content tools, and whether Divi knows a block type. Not final so abilities
 * can be unit-tested with a mock.
 */
class DiviModules implements DiviAdapter
{
    /** Divi's own post types the content tools leave alone (library, Theme Builder, canvases, speculation). */
    public const INTERNAL_POST_TYPES = [
        DiviLibraryStore::POST_TYPE,
        DiviThemeBuilder::THEME_BUILDER,
        DiviThemeBuilder::TEMPLATE,
        DiviThemeBuilder::LAYOUT_TYPES['header'],
        DiviThemeBuilder::LAYOUT_TYPES['body'],
        DiviThemeBuilder::LAYOUT_TYPES['footer'],
        'et_pb_canvas',
        '_et_pb_speculation',
    ];

    private ?ModuleCatalog $catalog = null;

    public static function manifest(): Manifest
    {
        return new Manifest(
            Manifest::CATALOG,
            calls: [
                new Call(ModuleRegistration::class . '::get_all_core_modules_metadata', 0),
                new Call(ModuleRegistration::class . '::get_default_attrs', 2),
                new Call('et_builder_enabled_for_post_type', 1),
            ],
            constants: ['ET_BUILDER_PRODUCT_VERSION'],
            blockTypes: [GlobalInstance::BLOCK],
            keys: ['et_pb_canvas', '_et_pb_speculation']
        );
    }

    // isAvailable(), catalog(), builderVersion(): unverändert.

    /** Whether Divi's builder is switched on for $postType (Divi → Theme-Optionen → Builder); false without Divi. */
    public static function builderEnabledFor(string $postType): bool
    {
        return function_exists('et_builder_enabled_for_post_type')
            && (bool) et_builder_enabled_for_post_type($postType);
    }

    /**
     * Whether Divi still has block type $name: registered with WordPress, or in Divi's core module metadata (Divi
     * renders global instances itself and does not always register the type with WordPress).
     */
    public static function knowsBlock(string $name): bool
    {
        if (class_exists('WP_Block_Type_Registry') && \WP_Block_Type_Registry::get_instance()->is_registered($name)) {
            return true;
        }
        if (! self::isAvailable()) {
            return false;
        }

        foreach ((array) ModuleRegistration::get_all_core_modules_metadata() as $module) {
            if (is_array($module) && ($module['name'] ?? null) === $name) {
                return true;
            }
        }

        return false;
    }
}

src/Integrations/Divi/Pages/DynamicContentMap.php: class DynamicContentMap implements DiviAdapter, Importe LIT\MCP\Integrations\Divi\Release\Call, …\DiviAdapter, …\Manifest, im Klassen-Docblock den ersten Satz mit „Adapter "Katalog" (spec §3):“ beginnen, am Docblock von REGISTRY „also checked by DiviCompatibility“ durch „listed in manifest()“ ersetzen, und:

    public static function manifest(): Manifest
    {
        return new Manifest(Manifest::CATALOG, calls: [new Call(self::REGISTRY . '::get_options', 2)]);
    }

src/Integrations/WordPress/Schema/ContentSchema.php: Import use LIT\MCP\Integrations\Divi\Schema\DiviModules;, dann:

    /** Post types handled elsewhere (media tools, Divi tools, WordPress internals). */
    public const EXCLUDED = [
        'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
        'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face',
        ...DiviModules::INTERNAL_POST_TYPES,
    ];

und im Konstruktor:

    public function __construct(private array $sources, ?callable $diviEnabled = null)
    {
        $this->diviEnabled = $diviEnabled ?? DiviModules::builderEnabledFor(...);
    }

src/Integrations/Divi/DiviCompatibility.php: Die Prüfung 'Block-Typ divi/global-layout (Instanzen globaler Module)' ruft DiviModules::knowsBlock(GlobalInstance::BLOCK) auf; die private Methode knowsGlobalLayout() entfällt (Import DiviModules ist vorhanden).

src/Integrations/Divi/Release/DiviAdapters.php: CLASSES wird

    public const CLASSES = [
        \LIT\MCP\Integrations\Divi\Schema\DiviModules::class,
        \LIT\MCP\Integrations\Divi\Pages\DynamicContentMap::class,
    ];

(mit use-Importen statt voll qualifizierter Namen, wie im übrigen Code). In DiviBoundaryTest::LEGACY die Zeilen Integrations/Divi/Schema/DiviModules.php, Integrations/Divi/Pages/DynamicContentMap.php und Integrations/WordPress/Schema/ContentSchema.php streichen.

  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'DiviModulesTest|ContentSchemaTest|DiviBoundaryTest|DiviCompatibilityTest' Expected: PASS. Meldet testAdaptersDeclareExactlyTheDiviNamesTheyUse einen Namen, fehlt er im Manifest (ergänzen) oder ist dort überflüssig (streichen).

  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün. Commit wie in den Global Constraints mit Nachricht refactor: Divi catalog adapter with manifest.


Task 4: Adapter „Seiten“ (DiviPages statt DiviFlags)

Files:

  • Create (per git mv): src/Integrations/Divi/Pages/DiviPages.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

git mv src/Integrations/Divi/Pages/DiviFlags.php src/Integrations/Divi/Pages/DiviPages.php
git mv tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php tests/Unit/Integrations/Divi/Pages/DiviPagesTest.php

In DiviPagesTest.php: Klasse DiviPagesTest, Import LIT\MCP\Integrations\Divi\Pages\DiviPages, jedes DiviFlags::set(7) → DiviPages::setFlags(7), im Kommentar des ersten Tests „(SyncToServerController.php:382-387)“ → „(SyncToServerController::update, Mirror vb-sync-flags)“. Dazu diese Tests (Importe LIT\MCP\Integrations\Divi\Release\DiviAdapters, LIT\MCP\Integrations\Divi\Release\Manifest, WP_Post):

    public function testIsTheRegisteredPagesAdapterWithTheFlagMirror(): void
    {
        $manifest = DiviPages::manifest();

        self::assertContains(DiviPages::class, DiviAdapters::CLASSES);
        self::assertSame(Manifest::PAGES, $manifest->area);
        self::assertSame(['vb-sync-flags'], array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors));
        self::assertSame('SyncToServerController::update()', $manifest->mirrors[0]->label());
    }

    public function testUsesBuilderReadsDivisFlag(): void
    {
        Functions\when('get_post_meta')->alias(
            static fn (int $id, string $key): string => $id === 7 && $key === '_et_pb_use_builder' ? 'on' : ''
        );

        self::assertTrue(DiviPages::usesBuilder(7));
        self::assertFalse(DiviPages::usesBuilder(8));
    }

    public function testBuilderUrlOpensTheVisualBuilder(): void
    {
        Functions\when('get_permalink')->justReturn('https://k.de/start/');
        Functions\when('add_query_arg')->alias(
            static fn (string $key, string $value, string $url): string => $url . '?' . $key . '=' . $value
        );

        self::assertSame('https://k.de/start/?et_fb=1', DiviPages::builderUrl(new WP_Post(['ID' => 7])));
    }

    public function testClearPageCssRemovesThePagesStaticFiles(): void
    {
        \ET_Core_PageResource::$removed = [];

        DiviPages::clearPageCss(7);

        self::assertSame([[7, 'all', true]], \ET_Core_PageResource::$removed);
    }

    public function testIsLinkableFollowsDivisPublicPostTypes(): void
    {
        Functions\when('et_builder_get_public_post_types')->justReturn(['page' => 'Seiten', 'post' => 'Beiträge']);

        self::assertTrue(DiviPages::isLinkable('page'));
        self::assertFalse(DiviPages::isLinkable('product'));
    }
  • Step 2: Run tests to verify they fail

Run: vendor/bin/phpunit --filter DiviPagesTest Expected: FAIL („Class … DiviPages not found“ bzw. „undefined method setFlags“).

  • Step 3: Implementation

src/Integrations/Divi/Pages/DiviPages.php (vollständig):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Pages;

use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use RuntimeException;
use WP_Post;

defined('ABSPATH') || exit;

/**
 * Adapter "Seiten" (spec §3): the Divi 5 builder flags of an entry as the Visual Builder's own save sets them
 * (mirror vb-sync-flags: _et_pb_use_divi_5 = "on", legacy _et_pb_use_builder = "on", _et_pb_show_page_creation =
 * "off" so the builder skips its page creation flow), whether an entry uses the builder, its builder URL, the
 * static CSS of one page and Divi's list of linkable post types. Ruling S11: update_post_meta() returns false for an
 * unchanged value too, so setFlags() checks by reading the flags back. Used by DiviSaver, the Divi content creation,
 * WpLinkTargets and PostPresenter.
 */
final class DiviPages implements DiviAdapter
{
    public const FLAGS = [
        '_et_pb_use_divi_5' => 'on',
        '_et_pb_use_builder' => 'on',
        '_et_pb_show_page_creation' => 'off',
    ];

    public static function manifest(): Manifest
    {
        return new Manifest(
            Manifest::PAGES,
            calls: [
                new Call('ET_Core_PageResource::remove_static_resources', 3),
                new Call('et_builder_get_public_post_types', 0),
            ],
            keys: ['_et_pb_use_divi_5', '_et_pb_use_builder', '_et_pb_show_page_creation', 'et_fb'],
            mirrors: [
                new Mirror(
                    'vb-sync-flags',
                    'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php',
                    'update',
                    'SyncToServerController',
                    'Builder-Kennzeichen einer Seite (_et_pb_use_divi_5, _et_pb_use_builder, _et_pb_show_page_creation)'
                ),
            ]
        );
    }

    /** @throws RuntimeException When a flag does not read back as set. */
    public static function setFlags(int $postId): void
    {
        foreach (self::FLAGS as $key => $value) {
            update_post_meta($postId, $key, $value);
        }

        foreach (self::FLAGS as $key => $value) {
            if (get_post_meta($postId, $key, true) !== $value) {
                throw new RuntimeException(sprintf('Divi flag %s of post %d is not "%s".', $key, $postId, $value));
            }
        }
    }

    /** Whether Divi's builder flag marks $postId as a builder layout. */
    public static function usesBuilder(int $postId): bool
    {
        return get_post_meta($postId, '_et_pb_use_builder', true) === 'on';
    }

    /** The entry opened in the Divi Visual Builder: its permalink with et_fb=1. */
    public static function builderUrl(WP_Post|int $post): string
    {
        return (string) add_query_arg('et_fb', '1', (string) get_permalink($post));
    }

    /** Removes the static CSS files Divi generated for $postId (spec §5.6 step 5). */
    public static function clearPageCss(int $postId): void
    {
        if (class_exists('ET_Core_PageResource')) {
            \ET_Core_PageResource::remove_static_resources($postId, 'all', true);
        }
    }

    /** Whether Divi offers a post_link_url_<type> option for $postType. Fail closed: without Divi's list, none. */
    public static function isLinkable(string $postType): bool
    {
        if (! function_exists('et_builder_get_public_post_types')) {
            return false;
        }

        $types = et_builder_get_public_post_types();

        return is_array($types) && array_key_exists($postType, $types);
    }
}

src/Integrations/Divi/Pages/DiviSaver.php:

  • Klassen-Docblock: „Divi 5 flags (DiviFlags), Divi's static CSS cache“ → „Divi 5 flags and the page's static CSS (DiviPages)“; „Security::initialize() (Divi 5.2.1 Security.php:74-81)“ → „Security::initialize() (Divi's Security.php)“.
  • In save(): static fn () => DiviFlags::set($postId) → static fn () => DiviPages::setFlags($postId); der CSS-Block wird
            static function () use ($postId, $scope): void {
                DiviPages::clearPageCss($postId);
                // A global library entry is part of every page that shows it (spec §5.6 step 5).
                if ($scope->clearsSiteCss()) {
                    DiviThemeBuilder::clearSiteCaches();
                }
            },
  • In saveNew(): DiviFlags::set($postId); → DiviPages::setFlags($postId);.

src/Integrations/Divi/Pages/DiviPage.php, Methode builderUrl():

    /** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */
    public static function builderUrl(WP_Post|int $post): string
    {
        return DiviPages::builderUrl($post);
    }

src/Integrations/Divi/Pages/WpLinkTargets.php: Import LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; NEVER = ['attachment', DiviLibraryStore::POST_TYPE]; im Konstruktor $this->linkable = $linkable ?? DiviPages::isLinkable(...);; die private Methode diviLinkable() entfällt; Docblock des Konstruktors: „null = DiviPages::isLinkable() (Divi's et_builder_get_public_post_types(), the list its link option uses)“.

src/Integrations/WordPress/Content/PostPresenter.php: Import LIT\MCP\Integrations\Divi\Pages\DiviPages;

    public static function usesDivi(WP_Post $post): bool
    {
        return DiviPages::usesBuilder((int) $post->ID)
            || str_contains((string) $post->post_content, '<!-- wp:divi/');
    }

DiviAdapters::CLASSES um DiviPages::class ergänzen (als erstes Element: Reihenfolge Seiten, Bibliothek, Theme Builder, Designsystem, Katalog). Aus DiviBoundaryTest::LEGACY streichen: Pages/DiviFlags.php, Pages/DiviPage.php, Pages/DiviSaver.php, Pages/WpLinkTargets.php, WordPress/Content/PostPresenter.php.

Restliche Verweise suchen und ersetzen: grep -rn "DiviFlags" src tests muss leer sein.

  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'DiviPagesTest|DiviSaverTest|WpLinkTargetsTest|PostPresenterTest|DiviBoundaryTest' Expected: PASS.

  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün. Commit wie in den Global Constraints mit Nachricht refactor: Divi pages adapter replaces DiviFlags.


Task 5: Adapter „Bibliothek“ (DiviLibraryStore, DiviRoles)

Files:

  • Modify: src/Integrations/Divi/Library/DiviLibraryStore.php, DiviRoles.php, LibraryItemSpec.php, NewLibraryEntry.php, src/Integrations/Divi/Release/DiviAdapters.php
  • Test: tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php, LibraryItemSpecTest.php, DiviRolesTest.php, tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php

Interfaces:

  • Consumes: Task 2.

  • Produces: DiviLibraryStore implements 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):

    public function testIsTheRegisteredLibraryAdapterWithTheSubmitLayoutMirror(): void
    {
        $manifest = DiviLibraryStore::manifest();

        self::assertContains(DiviLibraryStore::class, DiviAdapters::CLASSES);
        self::assertSame(Manifest::LIBRARY, $manifest->area);
        self::assertSame(['et_pb_layout'], $manifest->postTypes);
        self::assertSame(['scope', 'layout_type', 'module_width', 'layout_category', 'layout_tag'], $manifest->taxonomies);
        self::assertSame(['library-submit-layout'], array_map(static fn ($m): string => $m->id, $manifest->mirrors));
        self::assertSame('et_pb_submit_layout()', $manifest->mirrors[0]->label());
    }

    public function testBuiltForReadsTheSourceType(): void
    {
        Functions\when('get_post_meta')->alias(
            static fn (int $id, string $key): string => $id === 1054 && $key === '_et_pb_built_for_post_type' ? 'post' : ''
        );

        self::assertSame('post', DiviLibraryStore::builtFor(1054));
        self::assertSame('', DiviLibraryStore::builtFor(1055));
    }

In DiviRolesTest ergänzen:

    public function testIsTheRegisteredRolesAdapter(): void
    {
        self::assertContains(DiviRoles::class, \LIT\MCP\Integrations\Divi\Release\DiviAdapters::CLASSES);
        self::assertSame('et_pb_is_allowed()', DiviRoles::manifest()->calls[0]->label());
    }
  • Step 2: Run tests to verify they fail

Run: vendor/bin/phpunit --filter 'DiviLibraryStoreTest|LibraryItemSpecTest|DiviRolesTest' Expected: FAIL („undefined method metaFor/manifest/builtFor“).

  • Step 3: Implementation

src/Integrations/Divi/Library/DiviLibraryStore.php:

  • class DiviLibraryStore implements DiviAdapter; 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:
    public const MODULE_TYPE = '_et_pb_module_type';
    public const EXCLUDED_OPTIONS = '_et_pb_excluded_global_options';
    public const BUILT_FOR = '_et_pb_built_for_post_type';
    public const TEMPLATE_TYPE = '_et_pb_template_type';

    public static function manifest(): Manifest
    {
        return new Manifest(
            Manifest::LIBRARY,
            calls: [new Call(self::GLOBAL_LAYOUT . '::is_global_layout_template', 1)],
            postTypes: [self::POST_TYPE],
            taxonomies: [...self::TAXONOMIES, 'layout_category', 'layout_tag'],
            keys: [self::MODULE_TYPE, self::EXCLUDED_OPTIONS, self::BUILT_FOR, self::TEMPLATE_TYPE],
            mirrors: [
                new Mirror(
                    'library-submit-layout',
                    'includes/builder/shortcode-core.php',
                    'et_pb_submit_layout',
                    null,
                    'Terme und Meta neuer Bibliothekseinträge (scope, layout_type, module_width, _et_pb_module_type, '
                        . '_et_pb_excluded_global_options, _et_pb_built_for_post_type, _et_pb_template_type)'
                ),
            ]
        );
    }

    /**
     * The meta of a new entry as et_pb_submit_layout() writes it (compared with the real builder entries, fixtures
     * stage3): _et_pb_module_type (modules), _et_pb_excluded_global_options = "[]" (global modules),
     * _et_pb_built_for_post_type, _et_pb_template_type. _et_pb_row_layout is left out – Divi 5.2.1 stores
     * "[object Object]" there (finding 5).
     *
     * @return array<string, string>
     */
    public static function metaFor(LibraryItemSpec $spec): array
    {
        $meta = [];
        if ($spec->moduleType !== null) {
            $meta[self::MODULE_TYPE] = $spec->moduleType;
        }
        if ($spec->global && $spec->layoutType === 'module') {
            $meta[self::EXCLUDED_OPTIONS] = '[]';
        }
        $meta[self::BUILT_FOR] = $spec->builtFor;
        $meta[self::TEMPLATE_TYPE] = $spec->layoutType;

        return $meta;
    }

    /** _et_pb_built_for_post_type of entry $id ('' when unset): the type an entry was built for. */
    public static function builtFor(int $id): string
    {
        return (string) get_post_meta($id, self::BUILT_FOR, true);
    }
  • In describe(): get_post_meta($id, '_et_pb_module_type', true) → get_post_meta($id, self::MODULE_TYPE, true).
  • In describeNew(): foreach ($spec->meta() as $key => $value) → foreach (self::metaFor($spec) as $key => $value).

src/Integrations/Divi/Library/LibraryItemSpec.php: Methode meta() löschen; Klassen-Docblock: „Title, terms and meta of a new library entry …“ → „Title and terms of a new library entry, like Divi's et_pb_submit_layout() writes them (compared with the real builder entries, fixtures stage3): terms scope (global / non_global), layout_type, module_width, optional layout_category/layout_tag. The meta follows from DiviLibraryStore::metaFor(). Pure PHP.“

src/Integrations/Divi/Library/NewLibraryEntry.php, Methode builtFor():

        $builtFor = DiviLibraryStore::builtFor((int) $page->post->ID);

src/Integrations/Divi/Library/DiviRoles.php: class DiviRoles implements DiviAdapter, Importe wie oben (ohne Mirror), Docblock „Adapter for Divi's role editor“ → „Adapter "Bibliothek" (spec §3) for Divi's role editor“ und „(Divi 5.2.1 builder/core.php:1128)“ → „(Divi's builder/core.php)“;

    public static function manifest(): Manifest
    {
        return new Manifest(Manifest::LIBRARY, calls: [new Call('et_pb_is_allowed', 1)]);
    }

DiviAdapters::CLASSES: nach DiviPages::class DiviLibraryStore::class, DiviRoles::class einfügen. Aus DiviBoundaryTest::LEGACY streichen: Library/DiviLibraryStore.php, Library/DiviRoles.php, Library/LibraryItemSpec.php, Library/NewLibraryEntry.php.

grep -rn "\->meta()" src tests muss leer sein.

  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'DiviLibraryStoreTest|LibraryItemSpecTest|DiviRolesTest|NewLibraryEntry|LibrarySaveAbilityTest|LibraryMakeGlobalAbilityTest|DiviBoundaryTest' Expected: PASS.

  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün. Commit wie in den Global Constraints mit Nachricht refactor: Divi library adapters with manifest and submit-layout mirror.


Task 6: Adapter „Theme Builder“ (DiviThemeBuilder)

Files:

  • Modify: src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php, TemplateConditions.php, Template.php (nur Docblocks), src/Integrations/Divi/DiviCompatibility.php (Theme-Builder-Prüfung), src/Integrations/Divi/Release/DiviAdapters.php
  • Test: tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php, tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php

Interfaces:

  • Consumes: Task 2.

  • Produces: DiviThemeBuilder implements DiviAdapter; Mirror-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):

    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():
    private function assertCanWrite(string ...$functions): void
    {
        if (! current_user_can('edit_others_posts')) {
            throw new RuntimeException('edit_others_posts missing – Divi would call wp_die()');
        }
        $missing = array_values(array_filter($functions, static fn (string $name): bool => ! function_exists($name)));
        if ($missing !== []) {
            throw new RuntimeException('Divi function missing: ' . implode(', ', $missing));
        }
    }
  • Manifest:
    public static function manifest(): Manifest
    {
        $dir = 'includes/builder/frontend-builder/theme-builder/';

        return new Manifest(
            Manifest::THEME_BUILDER,
            calls: [
                new Call('et_theme_builder_get_theme_builder_post_id', 2),
                new Call('et_theme_builder_get_theme_builder_template_ids', 2),
                new Call('et_theme_builder_get_template', 1),
                new Call('et_theme_builder_insert_layout', 1),
                new Call('et_theme_builder_store_template', 3),
                new Call('et_theme_builder_get_flat_template_settings_options', 0),
                new Call('et_theme_builder_clear_wp_cache', 1),
                // store_template needs Divi's et_() helper (E15).
                new Call('et_'),
                new Call('ET_Core_PageResource::remove_static_resources', 3),
            ],
            postTypes: self::postTypes(),
            keys: [self::TEMPLATE_LIST, self::USE_ON, self::EXCLUDE_FROM, self::ENABLED, self::UNUSED, self::BACKUP_OPTION],
            mirrors: [
                new Mirror(
                    'tb-api-save',
                    $dir . 'api.php',
                    'et_theme_builder_api_save',
                    null,
                    'Vorlagenliste des Live-Theme-Builders (_et_template mit Sicherung et_tb_templates_backup_) und '
                        . '„ungenutzt“-Markierung'
                ),
                new Mirror(
                    'tb-store-template',
                    $dir . 'theme-builder.php',
                    'et_theme_builder_store_template',
                    null,
                    'Bedingungen (_et_use_on, _et_exclude_from) und Aktiv-Schalter (_et_enabled) einer Vorlage'
                ),
                new Mirror(
                    'tb-load-setting-options',
                    $dir . 'theme-builder.php',
                    'et_theme_builder_load_template_setting_options',
                    null,
                    'Zerlegen der Bedingungs-IDs (TemplateConditions)'
                ),
            ]
        );
    }
  • Zeilenangaben hinter Divi-Dateinamen in den Docblocks dieser drei Dateien entfernen (die Quelle steht im Manifest): sed -i -E 's/([A-Za-z]+\.php):[0-9]+(-[0-9]+)?(, *[0-9]+(-[0-9]+)?)*/\1/g' src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php src/Integrations/Divi/ThemeBuilder/Template.php src/Integrations/Divi/ThemeBuilder/TemplateConditions.php, 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):

            'Theme Builder: Funktionen et_theme_builder_* und et_()' => static fn (): bool => array_filter(
                DiviThemeBuilder::manifest()->calls,
                static fn (Call $call): bool => $call->className() === null && ! function_exists($call->member())
            ) === [],

(Import LIT\MCP\Integrations\Divi\Release\Call.)

DiviAdapters::CLASSES: nach DiviRoles::class DiviThemeBuilder::class. Aus DiviBoundaryTest::LEGACY streichen: ThemeBuilder/DiviThemeBuilder.php.

  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'DiviThemeBuilderTest|Tb|Template|ThemeBuilder|DiviCompatibilityTest|DiviBoundaryTest' Expected: PASS.

  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün. Commit wie in den Global Constraints mit Nachricht refactor: Theme Builder adapter manifest with three mirrors.


Task 7: Adapter „Designsystem“ (DesignSystemReader, MediaUsageFinder)

Files:

  • Modify: src/Integrations/Divi/Design/DesignSystemReader.php, src/Integrations/Media/MediaUsageFinder.php, src/Integrations/Divi/Release/DiviAdapters.php
  • Test: tests/Unit/Integrations/Divi/Design/DesignSystemReaderTest.php (neu), tests/Unit/Integrations/Media/MediaUsageFinderTest.php (unverändert grün), tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php

Interfaces:

  • Consumes: Task 2.

  • Produces: DesignSystemReader implements DiviAdapter, DesignSystemReader::diviLogo(): string (statisch; '' ohne Logo).

  • Step 1: Write the failing test

tests/Unit/Integrations/Divi/Design/DesignSystemReaderTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Tests\Unit\TestCase;

final class DesignSystemReaderTest extends TestCase
{
    public function testIsTheRegisteredDesignAdapter(): void
    {
        $manifest = DesignSystemReader::manifest();

        self::assertContains(DesignSystemReader::class, DiviAdapters::CLASSES);
        self::assertSame(Manifest::DESIGN, $manifest->area);
        self::assertSame(
            ['GlobalData::get_global_colors()', 'GlobalData::get_global_variables()', 'GlobalPreset::get_data()'],
            array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
        );
        self::assertSame(['et_divi'], $manifest->keys);
    }

    public function testDiviLogoReadsTheThemeOptions(): void
    {
        Functions\when('get_option')->alias(
            static fn (string $name): mixed => $name === 'et_divi' ? ['divi_logo' => 'https://k.de/logo.svg'] : false
        );

        self::assertSame('https://k.de/logo.svg', DesignSystemReader::diviLogo());
    }

    public function testDiviLogoIsEmptyWithoutOptions(): void
    {
        Functions\when('get_option')->justReturn(false);

        self::assertSame('', DesignSystemReader::diviLogo());
    }
}
  • Step 2: Run test to verify it fails

Run: vendor/bin/phpunit --filter DesignSystemReaderTest Expected: FAIL („undefined method manifest()“).

  • Step 3: Implementation

src/Integrations/Divi/Design/DesignSystemReader.php (vollständig):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

use ET\Builder\Packages\GlobalData\GlobalData;
use ET\Builder\Packages\GlobalData\GlobalPreset;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\Manifest;

defined('ABSPATH') || exit;

/**
 * Adapter "Designsystem" (spec §3): reads the design system from Divi's own APIs once per request (spec §2
 * principle 2 – no copy is ever stored), and the logo of Divi's theme options. Not final so abilities can be
 * unit-tested with a mock.
 */
class DesignSystemReader implements DiviAdapter
{
    /** Divi's theme options (one array option); customizer colors, fonts and the logo live there. */
    private const OPTIONS = 'et_divi';

    private ?DesignSystem $designSystem = null;

    public static function manifest(): Manifest
    {
        return new Manifest(
            Manifest::DESIGN,
            calls: [
                new Call(GlobalData::class . '::get_global_colors', 0),
                new Call(GlobalData::class . '::get_global_variables', 0),
                new Call(GlobalPreset::class . '::get_data', 0),
            ],
            keys: [self::OPTIONS]
        );
    }

    public function read(): DesignSystem
    {
        if ($this->designSystem === null) {
            $colors = GlobalData::get_global_colors();
            $variables = GlobalData::get_global_variables();
            $presets = GlobalPreset::get_data();
            $options = get_option(self::OPTIONS, []);
            $this->designSystem = DesignSystem::fromDivi(
                is_array($colors) ? $colors : [],
                is_array($variables) ? $variables : [],
                is_array($presets) ? $presets : [],
                is_array($options) ? $options : []
            );
        }

        return $this->designSystem;
    }

    /** The logo URL of Divi's theme options (Divi → Theme-Optionen → Logo), '' when none is set. */
    public static function diviLogo(): string
    {
        $options = get_option(self::OPTIONS);

        return is_array($options) && is_string($options['divi_logo'] ?? null) ? $options['divi_logo'] : '';
    }
}

src/Integrations/Media/MediaUsageFinder.php: Import LIT\MCP\Integrations\Divi\Design\DesignSystemReader; die zwei Zeilen

        $divi = get_option('et_divi');
        $diviLogo = is_array($divi) && is_string($divi['divi_logo'] ?? null) ? $divi['divi_logo'] : '';

werden

        $diviLogo = DesignSystemReader::diviLogo();

DiviAdapters::CLASSES: nach DiviThemeBuilder::class DesignSystemReader::class (vor den Katalog-Klassen). Aus DiviBoundaryTest::LEGACY streichen: Design/DesignSystemReader.php, Media/MediaUsageFinder.php. Übrig bleibt nur Integrations/Divi/DiviCompatibility.php (Task 8).

  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'DesignSystemReaderTest|MediaUsageFinderTest|DiviDesignSystemAbilityTest|DiviBoundaryTest' Expected: PASS.

  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün. Commit wie in den Global Constraints mit Nachricht refactor: Divi design adapter, logo lookup moves out of media.


Task 8: Manifest-Prüfung, DiviCompatibility, Soll-Fingerabdrücke

Files:

  • Create: src/Integrations/Divi/Release/ManifestCheck.php, DiviEnvironment.php, DiviReference.php, fingerprints.json (erzeugt), tests/Fixtures/divi/reference/*.txt (erzeugt)
  • Modify: src/Integrations/Divi/DiviCompatibility.php (neu geschrieben), src/Integrations/Core/EnvironmentProbe.php
  • Test: tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php (neu), DiviReferenceTest.php (neu), tests/Unit/Integrations/Divi/DiviCompatibilityTest.php (neu geschrieben), tests/Unit/Integrations/Core/EnvironmentProbeTest.php, DiviBoundaryTest.php (LEGACY entfällt)

Interfaces:

  • Consumes: SourceFingerprint (Task 1), Manifest/Call/Mirror/DiviAdapters (Task 2), DiviModules::knowsBlock() (Task 3).

  • Produces:

    • ManifestCheck::__construct(?array $manifests = null, ?array $expected = null, ?Closure $current = null, ?Closure $knowsBlock = null); codeChecks(): array<string, Closure(): bool> (Aufrufe mit Argumentzahl, Konstanten), mirrorChecks(): array<string, Closure(): bool>, registrationChecks(): array<string, Closure(): bool>; 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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use ArrayObject;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;

final class ManifestCheckTest extends TestCase
{
    private function check(?\Closure $current = null): ManifestCheck
    {
        $manifests = [
            'A' => new Manifest(
                Manifest::PAGES,
                calls: [
                    new Call('strlen', 1),
                    new Call('lit_mcp_missing_function', 0),
                    new Call(ArrayObject::class . '::count', 0),
                    new Call('Missing\Klass::run', 0),
                    new Call('str_replace'),
                ],
                constants: ['PHP_VERSION', 'LIT_MCP_MISSING_CONSTANT'],
                postTypes: ['et_pb_layout'],
                taxonomies: ['scope'],
                blockTypes: ['divi/global-layout'],
                mirrors: [
                    new Mirror('same', 'a.php', 'a', null, 'gleich'),
                    new Mirror('changed', 'b.php', 'b', null, 'geändert'),
                    new Mirror('unreadable', 'c.php', 'c', null, 'fehlt'),
                    new Mirror('no-expectation', 'd.php', 'd', null, 'ohne Soll'),
                ]
            ),
        ];

        return new ManifestCheck(
            $manifests,
            ['same' => 'h1', 'changed' => 'h2', 'unreadable' => 'h3'],
            $current ?? static fn (Mirror $mirror): ?string => ['same' => 'h1', 'changed' => 'hX', 'no-expectation' => 'h4'][$mirror->id] ?? null,
            static fn (string $name): bool => false
        );
    }

    public function testCodeChecksCoverExistenceAndArgumentCount(): void
    {
        self::assertSame(
            [
                'Seiten: strlen()',
                'Seiten: lit_mcp_missing_function()',
                'Seiten: ArrayObject::count()',
                'Seiten: Klass::run()',
                'Seiten: str_replace()',
                'Seiten: Konstante PHP_VERSION',
                'Seiten: Konstante LIT_MCP_MISSING_CONSTANT',
            ],
            array_keys($this->check()->codeChecks())
        );
        self::assertSame(
            ['Seiten: lit_mcp_missing_function()', 'Seiten: Klass::run()', 'Seiten: Konstante LIT_MCP_MISSING_CONSTANT'],
            ManifestCheck::failed($this->check()->codeChecks())
        );
    }

    public function testAcceptsChecksTheArgumentCountTheAdapterPasses(): void
    {
        self::assertTrue(ManifestCheck::accepts(new Call('strlen', 1)));
        self::assertFalse(ManifestCheck::accepts(new Call('strlen', 2)), 'strlen takes one argument');
        self::assertFalse(ManifestCheck::accepts(new Call('str_replace', 2)), 'str_replace needs three');
        self::assertTrue(ManifestCheck::accepts(new Call('sprintf', 5)), 'variadic');
        self::assertTrue(ManifestCheck::accepts(new Call('str_replace')), 'existence only');
    }

    public function testMirrorChecksCompareWithTheExpectedFingerprints(): void
    {
        $checks = $this->check()->mirrorChecks();

        self::assertSame(
            [
                'Seiten: Nachbau von a() – gleich',
                'Seiten: Nachbau von b() – geändert',
                'Seiten: Nachbau von c() – fehlt',
                'Seiten: Nachbau von d() – ohne Soll',
            ],
            array_keys($checks)
        );
        self::assertSame(
            ['Seiten: Nachbau von b() – geändert', 'Seiten: Nachbau von c() – fehlt', 'Seiten: Nachbau von d() – ohne Soll'],
            ManifestCheck::failed($checks)
        );
    }

    public function testUnreadableMirrorIsAFailedCheck(): void
    {
        $check = $this->check(static function (): ?string {
            throw new RuntimeException('Divi file gone');
        });

        self::assertCount(4, ManifestCheck::failed($check->mirrorChecks()));
    }

    public function testRegistrationChecksAskWordPressAndDivi(): void
    {
        Functions\when('post_type_exists')->justReturn(true);
        Functions\when('taxonomy_exists')->justReturn(false);

        $checks = $this->check()->registrationChecks();

        self::assertSame(
            ['Seiten: Post-Typ et_pb_layout', 'Seiten: Taxonomie scope', 'Seiten: Block-Typ divi/global-layout'],
            array_keys($checks)
        );
        self::assertSame(
            ['Seiten: Taxonomie scope', 'Seiten: Block-Typ divi/global-layout'],
            ManifestCheck::failed($checks)
        );
    }
}

tests/Unit/Integrations/Divi/Release/DiviReferenceTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
use PHPUnit\Framework\TestCase;

final class DiviReferenceTest extends TestCase
{
    public function testEveryMirrorHasAnExpectedFingerprintAndAReferenceSource(): void
    {
        $ids = array_map(static fn ($mirror): string => $mirror->id, DiviAdapters::mirrors());
        sort($ids);
        $expected = DiviReference::expected();

        self::assertSame($ids, array_keys($expected));
        foreach ($expected as $id => $hash) {
            self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash, $id);
            self::assertNotNull(DiviReference::readSource($id, (string) DiviReference::sourceDir()), $id);
        }
    }

    public function testWritesAndReadsExpectedFingerprintsSorted(): void
    {
        $file = (string) tempnam(sys_get_temp_dir(), 'lit');

        try {
            DiviReference::writeExpected(['b' => str_repeat('b', 64), 'a' => str_repeat('a', 64)], $file);

            self::assertSame(['a' => str_repeat('a', 64), 'b' => str_repeat('b', 64)], DiviReference::expected($file));
            self::assertStringEndsWith("\n", (string) file_get_contents($file));
        } finally {
            unlink($file);
        }
    }

    public function testMissingOrBrokenFileMeansNoExpectations(): void
    {
        self::assertSame([], DiviReference::expected('/nonexistent/fingerprints.json'));
    }
}

tests/Unit/Integrations/Divi/DiviCompatibilityTest.php (vollständig ersetzen):

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi;

use LIT\MCP\Integrations\Divi\DiviCompatibility;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use PHPUnit\Framework\TestCase;
use RuntimeException;

final class DiviCompatibilityTest extends TestCase
{
    public function testListsFailedAndThrowingChecks(): void
    {
        $compatibility = new DiviCompatibility([
            'A' => static fn (): bool => true,
            'B' => static fn (): bool => false,
            'C' => static function (): bool {
                throw new RuntimeException('Divi changed');
            },
        ]);

        self::assertSame(['B', 'C'], $compatibility->problems());
    }

    public function testDefaultsAreCodeRegistrationsAndFingerprints(): void
    {
        $check = new ManifestCheck(
            ['X' => new Manifest(
                Manifest::LIBRARY,
                calls: [new Call('strlen', 1)],
                postTypes: ['et_pb_layout'],
                mirrors: [new Mirror('m', 'a.php', 'a', null, 'Terme')]
            )],
            [],
            static fn (): ?string => null,
            static fn (): bool => true
        );

        self::assertSame(
            ['Bibliothek: strlen()', 'Bibliothek: Post-Typ et_pb_layout', 'Bibliothek: Nachbau von a() – Terme'],
            array_keys(DiviCompatibility::defaults($check))
        );
    }

    public function testRealDefaultsCoverEveryAdapterArea(): void
    {
        $labels = implode("\n", array_keys(DiviCompatibility::defaults()));

        foreach ([Manifest::PAGES, Manifest::LIBRARY, Manifest::THEME_BUILDER, Manifest::DESIGN, Manifest::CATALOG] as $area) {
            self::assertStringContainsString($area . ': ', $labels);
        }
        self::assertStringContainsString('Theme Builder: Nachbau von et_theme_builder_api_save()', $labels);
        self::assertStringContainsString('Katalog: Block-Typ divi/global-layout', $labels);
    }
}

tests/Unit/Integrations/Core/EnvironmentProbeTest.php: bleibt wie er ist (der Konstruktor von DiviCompatibility nimmt weiter label => check).

In DiviBoundaryTest: Konstante LEGACY, deren Prüfung in testOnlyAdaptersTouchDivi und den Test testLegacyFilesStillTouchDivi entfernen.

  • Step 2: Run tests to verify they fail

Run: vendor/bin/phpunit --filter 'ManifestCheckTest|DiviReferenceTest|DiviCompatibilityTest|DiviBoundaryTest' Expected: FAIL (Klassen fehlen; DiviBoundaryTest meldet DiviCompatibility.php).

  • Step 3: Implementation

src/Integrations/Divi/Release/DiviEnvironment.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/** The installed Divi theme: version (the parent's for a child theme) and directory; null when Divi is not the template. */
final class DiviEnvironment
{
    public const TEMPLATE = 'Divi';

    public static function version(): ?string
    {
        $theme = wp_get_theme();
        if ($theme->get_template() !== self::TEMPLATE) {
            return null;
        }
        $parent = $theme->parent();

        return (string) ($parent ? $parent->get('Version') : $theme->get('Version'));
    }

    public static function themeDir(): ?string
    {
        return wp_get_theme()->get_template() === self::TEMPLATE ? (string) get_template_directory() : null;
    }
}

src/Integrations/Divi/Release/DiviReference.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use RuntimeException;

defined('ABSPATH') || exit;

/**
 * The expected fingerprints of the mirrors (fingerprints.json, shipped with the plugin) and their reference sources
 * (tests/Fixtures/divi/reference/<id>.txt, development only – not in the plugin package). Written by
 * wp lit-mcp divi-check --update-reference.
 */
final class DiviReference
{
    public const FILE = __DIR__ . '/fingerprints.json';

    /** @return array<string, string> Mirror ID => sha256; [] when the file is missing or broken. */
    public static function expected(?string $file = null): array
    {
        $file ??= self::FILE;
        $raw = is_file($file) && is_readable($file) ? file_get_contents($file) : false;
        $data = is_string($raw) ? json_decode($raw, true) : null;
        if (! is_array($data)) {
            return [];
        }

        $expected = [];
        foreach ($data as $id => $hash) {
            if (is_string($id) && is_string($hash)) {
                $expected[$id] = $hash;
            }
        }
        ksort($expected);

        return $expected;
    }

    /**
     * @param array<string, string> $hashes Mirror ID => sha256.
     * @throws RuntimeException
     */
    public static function writeExpected(array $hashes, ?string $file = null): void
    {
        ksort($hashes);
        $json = json_encode($hashes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        if (! is_string($json) || file_put_contents($file ?? self::FILE, $json . "\n") === false) {
            throw new RuntimeException('fingerprints.json not written');
        }
    }

    /** tests/Fixtures/divi/reference of a development checkout; null in an installed plugin. */
    public static function sourceDir(): ?string
    {
        $dir = dirname(__DIR__, 4) . '/tests/Fixtures/divi/reference';

        return is_dir($dir) ? $dir : null;
    }

    public static function readSource(string $id, string $dir): ?string
    {
        $file = $dir . '/' . $id . '.txt';
        $source = is_file($file) ? file_get_contents($file) : false;

        return is_string($source) ? $source : null;
    }

    /** @throws RuntimeException */
    public static function writeSource(string $id, string $source, string $dir): void
    {
        if (file_put_contents($dir . '/' . $id . '.txt', $source . "\n") === false) {
            throw new RuntimeException(sprintf('reference source %s not written', $id));
        }
    }

    /**
     * Fingerprints of every mirror in the installed Divi.
     *
     * @return array<string, SourceFingerprint>
     * @throws RuntimeException When a mirrored function cannot be read.
     */
    public static function current(string $themeDir): array
    {
        $prints = [];
        foreach (DiviAdapters::mirrors() as $mirror) {
            $prints[$mirror->id] = SourceFingerprint::of($themeDir . '/' . $mirror->file, $mirror->function, $mirror->class);
        }

        return $prints;
    }
}

Hinweis: readSource() liefert den Text mit abschließendem Zeilenumbruch (so geschrieben); --diff (Task 14) vergleicht zeilenweise und ignoriert ihn.

src/Integrations/Divi/Release/ManifestCheck.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use Closure;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use ReflectionFunction;
use ReflectionMethod;
use Throwable;

defined('ABSPATH') || exit;

/**
 * The checks of all adapter manifests (spec §4.3), as label => check. Code checks (calls with the argument count
 * the adapter passes, constants) and mirror checks decide the release gate; registration checks (post types,
 * taxonomies, block types) depend on the request context (stage3 finding 6) and only show in reports.
 */
final class ManifestCheck
{
    /** @var array<string, Manifest> */
    private array $manifests;

    /** @var Closure(Mirror): ?string */
    private Closure $current;

    /** @var Closure(string): bool */
    private Closure $knowsBlock;

    /**
     * @param array<string, Manifest>|null     $manifests  null = DiviAdapters::manifests().
     * @param array<string, string>|null       $expected   Mirror ID => sha256; null = DiviReference::expected().
     * @param (Closure(Mirror): ?string)|null  $current    Hash of the installed mirror, null = unreadable;
     *                                                     null = SourceFingerprint in the Divi theme directory.
     * @param (Closure(string): bool)|null     $knowsBlock null = DiviModules::knowsBlock().
     */
    public function __construct(
        ?array $manifests = null,
        private ?array $expected = null,
        ?Closure $current = null,
        ?Closure $knowsBlock = null
    ) {
        $this->manifests = $manifests ?? DiviAdapters::manifests();
        $this->current = $current ?? self::installed(...);
        $this->knowsBlock = $knowsBlock ?? DiviModules::knowsBlock(...);
    }

    /** @return array<string, Closure(): bool> */
    public function codeChecks(): array
    {
        $checks = [];
        foreach ($this->manifests as $manifest) {
            foreach ($manifest->calls as $call) {
                $checks[$manifest->area . ': ' . $call->label()] = static fn (): bool => self::accepts($call);
            }
            foreach ($manifest->constants as $constant) {
                $checks[$manifest->area . ': Konstante ' . $constant] = static fn (): bool => defined($constant);
            }
        }

        return $checks;
    }

    /** @return array<string, Closure(): bool> */
    public function mirrorChecks(): array
    {
        $checks = [];
        foreach ($this->manifests as $manifest) {
            foreach ($manifest->mirrors as $mirror) {
                $label = sprintf('%s: Nachbau von %s – %s', $manifest->area, $mirror->label(), $mirror->what);
                $checks[$label] = function () use ($mirror): bool {
                    $expected = ($this->expected ??= DiviReference::expected())[$mirror->id] ?? null;

                    return $expected !== null && ($this->current)($mirror) === $expected;
                };
            }
        }

        return $checks;
    }

    /** @return array<string, Closure(): bool> */
    public function registrationChecks(): array
    {
        $checks = [];
        foreach ($this->manifests as $manifest) {
            foreach ($manifest->postTypes as $type) {
                $checks[$manifest->area . ': Post-Typ ' . $type] = static fn (): bool => post_type_exists($type);
            }
            foreach ($manifest->taxonomies as $taxonomy) {
                $checks[$manifest->area . ': Taxonomie ' . $taxonomy] = static fn (): bool => taxonomy_exists($taxonomy);
            }
            foreach ($manifest->blockTypes as $block) {
                $checks[$manifest->area . ': Block-Typ ' . $block] = fn (): bool => ($this->knowsBlock)($block);
            }
        }

        return $checks;
    }

    /**
     * @param array<string, callable(): bool> $checks
     * @return list<string> Labels of the checks that failed or threw.
     */
    public static function failed(array $checks): array
    {
        $failed = [];
        foreach ($checks as $label => $check) {
            try {
                $ok = (bool) $check();
            } catch (Throwable) {
                $ok = false;
            }
            if (! $ok) {
                $failed[] = (string) $label;
            }
        }

        return $failed;
    }

    /** The function or method exists and takes the number of arguments the adapter passes. */
    public static function accepts(Call $call): bool
    {
        $class = $call->className();
        if ($class === null) {
            if (! function_exists($call->member())) {
                return false;
            }
            $reflection = new ReflectionFunction($call->member());
        } else {
            if (! method_exists($class, $call->member())) {
                return false;
            }
            $reflection = new ReflectionMethod($class, $call->member());
        }

        return $call->args === null
            || ($reflection->getNumberOfRequiredParameters() <= $call->args
                && ($reflection->isVariadic() || $reflection->getNumberOfParameters() >= $call->args));
    }

    private static function installed(Mirror $mirror): ?string
    {
        $dir = DiviEnvironment::themeDir();

        return $dir === null
            ? null
            : SourceFingerprint::of($dir . '/' . $mirror->file, $mirror->function, $mirror->class)->hash;
    }
}

(installed() wirft bei unlesbarer Datei; failed() wertet das als rot – Test testUnreadableMirrorIsAFailedCheck.)

src/Integrations/Divi/DiviCompatibility.php (vollständig ersetzen):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi;

use LIT\MCP\Integrations\Divi\Release\ManifestCheck;

defined('ABSPATH') || exit;

/**
 * Live check of the Divi internals LIT MCP relies on (spec §4.3): every check of every adapter manifest – calls
 * with their argument count, constants, registrations and the fingerprints of the mirrors. lit-site-status shows
 * the failed checks, so a Divi update that moves one of them is visible before a tool reports "unsupported".
 */
final class DiviCompatibility
{
    /** @param array<string, callable(): bool>|null $checks Label => check; null = defaults(). */
    public function __construct(private ?array $checks = null)
    {
    }

    /** @return list<string> Labels of the checks that failed or threw. */
    public function problems(): array
    {
        return ManifestCheck::failed($this->checks ?? self::defaults());
    }

    /** @return array<string, callable(): bool> */
    public static function defaults(?ManifestCheck $check = null): array
    {
        $check ??= new ManifestCheck();

        return array_merge($check->codeChecks(), $check->registrationChecks(), $check->mirrorChecks());
    }
}

src/Integrations/Core/EnvironmentProbe.php: Import LIT\MCP\Integrations\Divi\Release\DiviEnvironment; in components():

        $diviVersion = DiviEnvironment::version();
        $isDivi = $diviVersion !== null;

(die Zeilen $theme = wp_get_theme(); $isDivi = …; $parent = …; $diviVersion = … entfallen).

  • Step 4: Soll-Fingerabdrücke und Referenzquellen gegen Divi 5.2.1 erzeugen
mkdir -p tests/Fixtures/divi/reference
cd /home/janniclas/Projekte/WordPress && ddev wp eval '
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
$prints = DiviReference::current((string) DiviEnvironment::themeDir());
DiviReference::writeExpected(array_map(static fn ($p) => $p->hash, $prints));
foreach ($prints as $id => $print) {
    DiviReference::writeSource($id, $print->source, (string) DiviReference::sourceDir());
    echo $id, " ", $print->startLine, "-", $print->endLine, "\n";
}'

Expected: fünf Zeilen (tb-api-save, tb-store-template, tb-load-setting-options, library-submit-layout, vb-sync-flags) mit plausiblen Zeilenbereichen (z. B. et_theme_builder_api_save ab Zeile 160 in api.php, SyncToServerController::update ab Zeile 56); src/Integrations/Divi/Release/fingerprints.json enthält fünf Hashes. Jede tests/Fixtures/divi/reference/<id>.txt beginnt mit function <name>( und endet mit }.

  • Step 5: Live-Prüfung in DDEV
cd /home/janniclas/Projekte/WordPress && ddev wp eval 'print_r((new LIT\MCP\Integrations\Divi\DiviCompatibility())->problems());'

Expected: Array ( ). Meldet sie einen Aufruf mit Argumentzahl (…: Klasse::methode()), die Signatur in Divi 5.2.1 nachlesen und die Argumentzahl im Manifest an den tatsächlichen Aufruf im Adapter angleichen (nicht umgekehrt). Taxonomien können unter WP-CLI fehlen (Befund 6) – dann dieselbe Prüfung über MCP bestätigen: timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/tb-smoke.sh prüft problems == [] in einer MCP-Anfrage.

  • Step 6: Run tests, Lint, Commit

Run: composer test && composer lint Expected: grün (auch DiviReferenceTest mit den erzeugten Dateien und DiviBoundaryTest ohne LEGACY).

git add src tests

Commit wie in den Global Constraints mit Nachricht feat: manifest checks, Divi compatibility from manifests, reference fingerprints.


Task 9: Freigabe-Zustand (Status, Store, Resolver, Gate)

Files:

  • Create: src/Integrations/Divi/Release/DiviReleases.php, ReleaseState.php, ReleaseStatus.php, ReleaseStore.php, ReleaseResolver.php, ReleaseGate.php, DiviLocked.php
  • Modify: tests/Unit/TestCase.php
  • Test: tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php, ReleaseStoreTest.php, ReleaseResolverTest.php, ReleaseGateTest.php

Interfaces:

  • Consumes: ManifestCheck (codeChecks(), mirrorChecks(), failed()), DiviEnvironment::version()/themeDir(), DiviReference::FILE, DiviAdapters::mirrors() (Task 8); Errors::make(); Plugin::VERSION.

  • Produces:

    • DiviReleases::TESTED = ['5.2.1'].
    • enum ReleaseState: string { Released = 'released'; Untested = 'untested'; Incompatible = 'incompatible'; }
    • ReleaseStatus::__construct(ReleaseState $state, string $version, ?string $source = null, array $problems = [], ?array $release = null), 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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;

final class ReleaseStatusTest extends TestCase
{
    public function testOnlyAReleasedVersionIsWritable(): void
    {
        self::assertTrue(ReleaseStatus::released('5.2.1')->writable());
        self::assertFalse((new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->writable());
        self::assertFalse((new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['x']))->writable());
    }

    public function testUntestedRefusalNamesTheVersionAndTheWayOut(): void
    {
        $payload = self::assertToolError((new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->refusal(), 'divi_untested');

        self::assertSame(
            'Achtung: Divi 5.3.0 ist auf dieser Website noch nicht für LIT MCP freigegeben. Schreibende '
                . 'Divi-Werkzeuge sind gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung '
                . 'ausführt und die Version freigibt. Es wurde nichts geändert.',
            $payload['message']
        );
        self::assertStringContainsString('lesende Divi-Werkzeuge funktionieren weiter', $payload['hint']);
    }

    public function testIncompatibleRefusalListsTheProblems(): void
    {
        $status = new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['Theme Builder: Nachbau von x()']);
        $payload = self::assertToolError($status->refusal(), 'divi_incompatible');

        self::assertSame(
            'Divi 5.3.0 hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind gesperrt, bis LIT MCP '
                . 'aktualisiert wurde. Es wurde nichts geändert.',
            $payload['message']
        );
        self::assertSame(['Theme Builder: Nachbau von x()'], $payload['problems']);
    }

    public function testNoticesForReadingTools(): void
    {
        self::assertSame(
            'Achtung: Divi 5.3.0 ist für LIT MCP ungetestet; Lesen funktioniert, Schreiben ist gesperrt.',
            (new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->notice()
        );
        self::assertSame(
            'Achtung: Divi (Version unbekannt) ist für LIT MCP inkompatibel; Lesen funktioniert, Schreiben ist gesperrt.',
            (new ReleaseStatus(ReleaseState::Incompatible, ''))->notice()
        );
    }

    public function testAReleasedStatusHasNoRefusal(): void
    {
        $this->expectException(LogicException::class);

        ReleaseStatus::released('5.2.1')->refusal();
    }

    public function testToArrayForSiteStatus(): void
    {
        $status = new ReleaseStatus(
            ReleaseState::Released,
            '5.3.0',
            ReleaseStatus::SOURCE_DATABASE,
            [],
            ['released_at' => '2026-10-01T08:00:00+00:00', 'user_id' => 1]
        );

        self::assertSame(
            ['state' => 'released', 'version' => '5.3.0', 'source' => 'database',
                'released_at' => '2026-10-01T08:00:00+00:00', 'released_by' => 1, 'problems' => []],
            $status->toArray()
        );
    }
}

tests/Unit/Integrations/Divi/Release/ReleaseStoreTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Tests\Unit\TestCase;

final class ReleaseStoreTest extends TestCase
{
    /** @var array<string, mixed> */
    private array $options = [];

    protected function setUp(): void
    {
        parent::setUp();
        $this->options = [];
        Functions\when('get_option')->alias(fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default);
        Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool {
            $this->options[$name] = $value;
            self::assertFalse($autoload, 'releases are read only by the gate – no autoload');

            return true;
        });
    }

    public function testAddsFindsAndWithdrawsPerDiviAndPluginVersion(): void
    {
        $store = new ReleaseStore();
        $store->add('5.3.0', '0.6.0', 1, 'r1');
        $store->add('5.3.0', '0.6.0', 2, 'r2');

        self::assertCount(1, $store->entries(), 'one entry per version pair');
        self::assertSame(2, $store->find('5.3.0', '0.6.0')['user_id'] ?? null);
        self::assertNull($store->find('5.3.0', '0.7.0'), 'another plugin version needs its own check');
        self::assertTrue($store->withdraw('5.3.0', '0.6.0'));
        self::assertFalse($store->withdraw('5.3.0', '0.6.0'));
        self::assertSame([], $store->entries());
    }

    public function testIgnoresBrokenRows(): void
    {
        $this->options[ReleaseStore::OPTION] = ['x', ['divi_version' => '5.3.0'], [
            'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3, 'released_at' => 'd', 'report_id' => 'r',
        ]];

        self::assertSame(['5.2.2'], array_column((new ReleaseStore())->entries(), 'divi_version'));
    }
}

tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class ReleaseResolverTest extends TestCase
{
    /** @var array<string, mixed> */
    private array $transients = [];

    protected function setUp(): void
    {
        parent::setUp();
        $this->transients = [];
        Functions\when('get_transient')->alias(fn (string $key): mixed => $this->transients[$key] ?? false);
        Functions\when('set_transient')->alias(function (string $key, mixed $value): bool {
            $this->transients[$key] = $value;

            return true;
        });
    }

    /**
     * @param array<string, callable(): bool> $code
     * @param array<string, callable(): bool> $mirrors
     */
    private function resolver(string $version, array $code = [], array $mirrors = [], ?array $entry = null): ReleaseResolver
    {
        $check = Mockery::mock(ManifestCheck::class);
        $check->allows('codeChecks')->andReturn($code);
        $check->allows('mirrorChecks')->andReturn($mirrors);
        $store = Mockery::mock(ReleaseStore::class);
        $store->allows('find')->with($version, Plugin::VERSION)->andReturn($entry);

        return new ReleaseResolver($check, $store, static fn (): ?string => $version, static fn (string $v): string => 'key-' . $v);
    }

    public function testATestedVersionWithGreenChecksIsReleasedFromCode(): void
    {
        $status = $this->resolver('5.2.1')->resolve();

        self::assertSame(ReleaseState::Released, $status->state);
        self::assertSame('code', $status->source);
    }

    public function testAnUnknownVersionIsUntestedUnlessTheDatabaseReleasedIt(): void
    {
        self::assertSame(ReleaseState::Untested, $this->resolver('5.3.0')->resolve()->state);

        $released = $this->resolver('5.3.0', entry: ['user_id' => 1, 'released_at' => 'd'])->resolve();
        self::assertSame(ReleaseState::Released, $released->state);
        self::assertSame('database', $released->source);
    }

    public function testAFailedCheckMakesEvenATestedVersionIncompatible(): void
    {
        $status = $this->resolver('5.2.1', ['Seiten: et_x()' => static fn (): bool => false])->resolve();

        self::assertSame(ReleaseState::Incompatible, $status->state);
        self::assertSame(['Seiten: et_x()'], $status->problems);
    }

    public function testMirrorResultsAreCachedByStateKey(): void
    {
        $calls = 0;
        $mirrors = ['Theme Builder: Nachbau von y()' => static function () use (&$calls): bool {
            ++$calls;

            return false;
        }];

        $first = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();
        $second = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();

        self::assertSame(1, $calls, 'the second resolve reads the transient');
        self::assertSame(ReleaseState::Incompatible, $second->state);
        self::assertSame($first->problems, $second->problems);
        self::assertSame('key-5.2.1', $this->transients[ReleaseResolver::CACHE]['key']);

        $this->resolver('5.2.2', mirrors: $mirrors)->resolve();
        self::assertSame(2, $calls, 'another state key checks again');
    }

    public function testWithoutDiviTheVersionIsEmptyAndUntested(): void
    {
        self::assertSame(ReleaseState::Untested, $this->resolver('')->resolve()->state);
    }
}

(Der Test testWithoutDiviTheVersionIsEmptyAndUntested gibt '' als Version – resolver() übergibt die Closure static fn (): ?string => $version; der Resolver macht aus null ebenfalls ''.)

tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;

final class ReleaseGateTest extends TestCase
{
    private function untested(): void
    {
        ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
    }

    public function testTheTestCaseDefaultIsReleased(): void
    {
        self::assertTrue(ReleaseGate::status()->writable());
        ReleaseGate::assertWritable(7);
    }

    public function testAssertWritableThrowsTheLockWithTheStatus(): void
    {
        $this->untested();

        try {
            ReleaseGate::assertWritable(7);
            self::fail('an untested Divi must lock writes');
        } catch (DiviLocked $locked) {
            self::assertSame('5.3.0', $locked->status->version);
            self::assertToolError($locked->toError(), 'divi_untested');
        }
    }

    public function testProbingAllowsOnlyThatPostAndOnlyMeanwhile(): void
    {
        $this->untested();

        $result = ReleaseGate::whileProbing(41, static function (): string {
            ReleaseGate::assertWritable(41);

            return 'written';
        });
        self::assertSame('written', $result);

        foreach ([static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertWritable(42)),
            static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertWritable()),
            static fn () => ReleaseGate::assertWritable(41)] as $attempt) {
            try {
                $attempt();
                self::fail('only post 41, only while probing');
            } catch (DiviLocked) {
                self::addToAssertionCount(1);
            }
        }
    }

    public function testProbingEndsEvenWhenTheWriteThrows(): void
    {
        $this->untested();

        try {
            ReleaseGate::whileProbing(41, static function (): never {
                throw new RuntimeException('boom');
            });
        } catch (RuntimeException) {
        }

        $this->expectException(DiviLocked::class);
        ReleaseGate::assertWritable(41);
    }

    public function testStatusIsComputedOncePerRequestUntilForgotten(): void
    {
        $calls = 0;
        ReleaseGate::useResolver(static function () use (&$calls): ReleaseStatus {
            ++$calls;

            return ReleaseStatus::released('5.2.1');
        });

        ReleaseGate::status();
        ReleaseGate::status();
        self::assertSame(1, $calls);

        ReleaseGate::forget();
        ReleaseGate::status();
        self::assertSame(2, $calls);
    }

    public function testAFailingResolverLocksWrites(): void
    {
        ReleaseGate::useResolver(static function (): never {
            throw new RuntimeException('theme API gone');
        });
        $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
        $previous = ini_set('error_log', $log);

        try {
            $status = ReleaseGate::status();
        } finally {
            ini_set('error_log', (string) $previous);
        }

        self::assertSame(ReleaseState::Incompatible, $status->state);
        self::assertStringContainsString('theme API gone', (string) file_get_contents($log));
        unlink($log);
        $this->expectException(DiviLocked::class);
        ReleaseGate::assertWritable();
    }

    public function testFindsTheLockInsideAWrappedFailure(): void
    {
        $locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
        $wrapped = WriteFailed::rolledBack('Nicht gespeichert.', new RuntimeException('draft', 0, $locked));

        self::assertSame($locked, DiviLocked::in($wrapped));
        self::assertNull(DiviLocked::in(new RuntimeException('other')));
    }
}
  • Step 2: Run tests to verify they fail

Run: vendor/bin/phpunit --filter 'ReleaseStatusTest|ReleaseStoreTest|ReleaseResolverTest|ReleaseGateTest' Expected: FAIL (Klassen fehlen).

  • Step 3: Implementation

src/Integrations/Divi/Release/DiviReleases.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/**
 * Divi versions checked with this plugin version (spec §5.3, source "code"): add a version after
 * wp lit-mcp divi-check ran green against it. Any other version needs a release in the Divi tab of each site.
 */
final class DiviReleases
{
    public const TESTED = ['5.2.1'];
}

src/Integrations/Divi/Release/ReleaseState.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/** Spec §5.2. */
enum ReleaseState: string
{
    case Released = 'released';
    case Untested = 'untested';
    case Incompatible = 'incompatible';
}

src/Integrations/Divi/Release/ReleaseStatus.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use LIT\MCP\Abilities\Errors;
use LogicException;
use WP_Error;

defined('ABSPATH') || exit;

/** Whether LIT MCP may write with the installed Divi (spec §5.2), with the texts of spec §5.6. */
final class ReleaseStatus
{
    public const SOURCE_CODE = 'code';
    public const SOURCE_DATABASE = 'database';

    private const UNTESTED = 'Achtung: %s ist auf dieser Website noch nicht für LIT MCP freigegeben. Schreibende '
        . 'Divi-Werkzeuge sind gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung '
        . 'ausführt und die Version freigibt. Es wurde nichts geändert.';
    private const UNTESTED_HINT = 'Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben; '
        . 'lesende Divi-Werkzeuge funktionieren weiter.';
    private const INCOMPATIBLE = '%s hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind '
        . 'gesperrt, bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.';
    private const INCOMPATIBLE_HINT = 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss; lesende '
        . 'Divi-Werkzeuge funktionieren weiter.';
    private const NOTICE = 'Achtung: %s ist für LIT MCP %s; Lesen funktioniert, Schreiben ist gesperrt.';

    /**
     * @param string                    $version  Divi version, '' when unknown (Divi is not the active template).
     * @param string|null               $source   SOURCE_CODE or SOURCE_DATABASE for a released version.
     * @param list<string>              $problems Failed code and mirror checks (ManifestCheck labels).
     * @param array<string, mixed>|null $release  The database entry of a SOURCE_DATABASE release.
     */
    public function __construct(
        public readonly ReleaseState $state,
        public readonly string $version,
        public readonly ?string $source = null,
        public readonly array $problems = [],
        public readonly ?array $release = null
    ) {
    }

    public static function released(string $version, string $source = self::SOURCE_CODE): self
    {
        return new self(ReleaseState::Released, $version, $source);
    }

    public function writable(): bool
    {
        return $this->state === ReleaseState::Released;
    }

    public function versionLabel(): string
    {
        return $this->version === '' ? 'Divi (Version unbekannt)' : 'Divi ' . $this->version;
    }

    /** The error of a refused write. @throws LogicException For a released status. */
    public function refusal(): WP_Error
    {
        return match ($this->state) {
            ReleaseState::Untested => Errors::make(
                'divi_untested',
                sprintf(self::UNTESTED, $this->versionLabel()),
                self::UNTESTED_HINT,
                423
            ),
            ReleaseState::Incompatible => Errors::make(
                'divi_incompatible',
                sprintf(self::INCOMPATIBLE, $this->versionLabel()),
                self::INCOMPATIBLE_HINT,
                423,
                ['problems' => $this->problems]
            ),
            ReleaseState::Released => throw new LogicException('a released Divi refuses nothing'),
        };
    }

    /** The warning a reading Divi tool carries while writes are locked. */
    public function notice(): string
    {
        return sprintf(
            self::NOTICE,
            $this->versionLabel(),
            $this->state === ReleaseState::Incompatible ? 'inkompatibel' : 'ungetestet'
        );
    }

    /**
     * @return array{state: string, version: string, source: ?string, released_at: ?string, released_by: ?int,
     *     problems: list<string>}
     */
    public function toArray(): array
    {
        return [
            'state' => $this->state->value,
            'version' => $this->version,
            'source' => $this->source,
            'released_at' => is_string($this->release['released_at'] ?? null) ? $this->release['released_at'] : null,
            'released_by' => is_int($this->release['user_id'] ?? null) ? $this->release['user_id'] : null,
            'problems' => $this->problems,
        ];
    }
}

src/Integrations/Divi/Release/ReleaseStore.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/**
 * Divi versions released on this site after a green admin check (spec §5.3, source "database"). An entry holds for
 * the plugin version that ran the check only.
 */
class ReleaseStore
{
    public const OPTION = 'lit_mcp_divi_releases';

    /** @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string, report_id: string}> */
    public function entries(): array
    {
        $raw = get_option(self::OPTION, []);
        $entries = [];
        foreach (is_array($raw) ? $raw : [] as $row) {
            if (
                is_array($row)
                && is_string($row['divi_version'] ?? null)
                && is_string($row['plugin_version'] ?? null)
                && is_int($row['user_id'] ?? null)
                && is_string($row['released_at'] ?? null)
                && is_string($row['report_id'] ?? null)
            ) {
                $entries[] = [
                    'divi_version' => $row['divi_version'],
                    'plugin_version' => $row['plugin_version'],
                    'user_id' => $row['user_id'],
                    'released_at' => $row['released_at'],
                    'report_id' => $row['report_id'],
                ];
            }
        }

        return $entries;
    }

    /** @return array{divi_version: string, plugin_version: string, user_id: int, released_at: string, report_id: string}|null */
    public function find(string $diviVersion, string $pluginVersion): ?array
    {
        foreach ($this->entries() as $entry) {
            if ($entry['divi_version'] === $diviVersion && $entry['plugin_version'] === $pluginVersion) {
                return $entry;
            }
        }

        return null;
    }

    public function add(string $diviVersion, string $pluginVersion, int $userId, string $reportId): void
    {
        $entries = $this->without($diviVersion, $pluginVersion);
        $entries[] = [
            'divi_version' => $diviVersion,
            'plugin_version' => $pluginVersion,
            'user_id' => $userId,
            'released_at' => gmdate('c'),
            'report_id' => $reportId,
        ];
        update_option(self::OPTION, $entries, false);
    }

    public function withdraw(string $diviVersion, string $pluginVersion): bool
    {
        if ($this->find($diviVersion, $pluginVersion) === null) {
            return false;
        }
        update_option(self::OPTION, $this->without($diviVersion, $pluginVersion), false);

        return true;
    }

    /** @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string, report_id: string}> */
    private function without(string $diviVersion, string $pluginVersion): array
    {
        return array_values(array_filter(
            $this->entries(),
            static fn (array $entry): bool => $entry['divi_version'] !== $diviVersion
                || $entry['plugin_version'] !== $pluginVersion
        ));
    }
}

ReleaseStore ist nicht final, damit Tests ihn mocken können (wie DiviSaver).

src/Integrations/Divi/Release/ReleaseResolver.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use Closure;
use LIT\MCP\Plugin;

defined('ABSPATH') || exit;

/**
 * Computes the release status (spec §5.2–§5.4): code checks live (cheap), mirror checks cached in a transient under
 * stateKey() – Divi version, plugin version, fingerprints.json and the modification times of the mirrored Divi
 * files, so a Divi update or hotfix is checked again at once.
 */
final class ReleaseResolver
{
    public const CACHE = 'lit_mcp_divi_state';

    /**
     * @param (Closure(): ?string)|null      $version  null = DiviEnvironment::version().
     * @param (Closure(string): string)|null $stateKey null = self::stateKey().
     */
    public function __construct(
        private ?ManifestCheck $check = null,
        private ?ReleaseStore $store = null,
        private ?Closure $version = null,
        private ?Closure $stateKey = null
    ) {
    }

    public function resolve(): ReleaseStatus
    {
        $version = ($this->version ?? DiviEnvironment::version(...))() ?? '';
        $check = $this->check ?? new ManifestCheck();
        $problems = array_merge(ManifestCheck::failed($check->codeChecks()), $this->mirrorProblems($check, $version));

        if ($problems !== []) {
            return new ReleaseStatus(ReleaseState::Incompatible, $version, null, $problems);
        }
        if (in_array($version, DiviReleases::TESTED, true)) {
            return ReleaseStatus::released($version);
        }
        $entry = ($this->store ?? new ReleaseStore())->find($version, Plugin::VERSION);

        return $entry !== null
            ? new ReleaseStatus(ReleaseState::Released, $version, ReleaseStatus::SOURCE_DATABASE, [], $entry)
            : new ReleaseStatus(ReleaseState::Untested, $version);
    }

    /** What the cached mirror results and a check report are valid for. */
    public static function stateKey(string $version, ?string $themeDir = null): string
    {
        $themeDir ??= DiviEnvironment::themeDir();
        $parts = [$version, Plugin::VERSION, is_readable(DiviReference::FILE) ? (string) md5_file(DiviReference::FILE) : ''];
        foreach (DiviAdapters::mirrors() as $mirror) {
            $file = $themeDir === null ? '' : $themeDir . '/' . $mirror->file;
            $parts[] = $mirror->file . '@' . ($file !== '' && is_file($file) ? (string) filemtime($file) : '-');
        }

        return hash('sha256', implode('|', $parts));
    }

    /** @return list<string> */
    private function mirrorProblems(ManifestCheck $check, string $version): array
    {
        $key = ($this->stateKey ?? self::stateKey(...))($version);
        $cached = get_transient(self::CACHE);
        if (is_array($cached) && ($cached['key'] ?? null) === $key && is_array($cached['problems'] ?? null)) {
            return array_values(array_filter($cached['problems'], 'is_string'));
        }

        $problems = ManifestCheck::failed($check->mirrorChecks());
        set_transient(self::CACHE, ['key' => $key, 'problems' => $problems], DAY_IN_SECONDS);

        return $problems;
    }
}

DAY_IN_SECONDS ist in Unit-Tests nicht definiert: in tests/bootstrap.php nach der ABSPATH-Zeile ergänzen, falls nicht vorhanden:

if (! defined('DAY_IN_SECONDS')) {
    define('DAY_IN_SECONDS', 86400);
}

ManifestCheck ist final; damit ReleaseResolverTest ihn mocken kann, final bei ManifestCheck entfernen und den Docblock um „Not final so the resolver can be tested with a mock.“ ergänzen.

src/Integrations/Divi/Release/DiviLocked.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use RuntimeException;
use Throwable;
use WP_Error;

defined('ABSPATH') || exit;

/** A write refused because the installed Divi is not released (spec §5.5); AbilityRegistrar returns toError(). */
final class DiviLocked extends RuntimeException
{
    public function __construct(public readonly ReleaseStatus $status)
    {
        parent::__construct(sprintf(
            '%s is %s for LIT MCP – write refused',
            $status->versionLabel(),
            $status->state->value
        ));
    }

    public function toError(): WP_Error
    {
        return $this->status->refusal();
    }

    /** The lock anywhere in $e's chain of previous exceptions (callers wrap failures in WriteFailed). */
    public static function in(Throwable $e): ?self
    {
        for ($cause = $e; $cause !== null; $cause = $cause->getPrevious()) {
            if ($cause instanceof self) {
                return $cause;
            }
        }

        return null;
    }
}

src/Integrations/Divi/Release/ReleaseGate.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use Closure;
use LIT\MCP\Log;
use Throwable;

defined('ABSPATH') || exit;

/**
 * The release status of this request and the lock every writing Divi path asks (spec §5.5). whileProbing() lets the
 * admin check write its throwaway page whatever the status – only that post, only during the call, and only from
 * code (there is no parameter or option for it).
 */
final class ReleaseGate
{
    private static ?ReleaseStatus $status = null;

    /** @var (Closure(): ReleaseStatus)|null */
    private static ?Closure $resolver = null;

    private static ?int $probe = null;

    /** Spec §9: a status that cannot be determined locks writes like an incompatible Divi (and is logged). */
    public static function status(): ReleaseStatus
    {
        if (self::$status !== null) {
            return self::$status;
        }

        try {
            self::$status = (self::$resolver ?? static fn (): ReleaseStatus => (new ReleaseResolver())->resolve())();
        } catch (Throwable $e) {
            Log::failure('divi release status', $e);
            self::$status = new ReleaseStatus(
                ReleaseState::Incompatible,
                '',
                null,
                ['Der Freigabe-Zustand ließ sich nicht ermitteln (Details im PHP-Error-Log).']
            );
        }

        return self::$status;
    }

    /** @param (Closure(): ReleaseStatus)|null $resolver null = ReleaseResolver; tests set their own. */
    public static function useResolver(?Closure $resolver): void
    {
        self::$resolver = $resolver;
        self::$status = null;
        self::$probe = null;
    }

    /** The next status() computes again (after a check, a release or a withdrawal). */
    public static function forget(): void
    {
        self::$status = null;
    }

    /** @throws DiviLocked */
    public static function assertWritable(?int $postId = null): void
    {
        if (self::$probe !== null && $postId === self::$probe) {
            return;
        }

        $status = self::status();
        if (! $status->writable()) {
            throw new DiviLocked($status);
        }
    }

    public static function whileProbing(int $postId, callable $write): mixed
    {
        $previous = self::$probe;
        self::$probe = $postId;

        try {
            return $write();
        } finally {
            self::$probe = $previous;
        }
    }
}

tests/Unit/TestCase.php: Importe LIT\MCP\Integrations\Divi\Release\ReleaseGate und …\ReleaseStatus; in setUp() nach Functions\stubs([...]):

        // Divi is released unless a test says otherwise (the release gate would ask WordPress and Divi).
        ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseStatus::released('5.2.1'));

in tearDown() vor Monkey\tearDown();:

        ReleaseGate::useResolver(null);
  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'ReleaseStatusTest|ReleaseStoreTest|ReleaseResolverTest|ReleaseGateTest|ManifestCheckTest' Expected: PASS.

  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün. Commit wie in den Global Constraints mit Nachricht feat: Divi release status, store, resolver and gate.


Task 10: Zentrale Sperre im AbilityRegistrar

Files:

  • Create: src/Integrations/Divi/Release/DiviToolGate.php
  • Modify: src/Abilities/AbilityRegistrar.php, src/Plugin.php, src/Admin/LogListTable.php, src/Admin/LogTab.php
  • Test: tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php (neu), tests/Unit/Abilities/AbilityRegistrarTest.php, tests/Unit/Admin/LogListTableTest.php

Interfaces:

  • Consumes: ReleaseGate::status(), ReleaseStatus::refusal()/notice()/versionLabel(), DiviLocked::in() (Task 9); IntegrationRegistry::groupMap(); LibraryAccess::GROUP ('divi-library'), ThemeBuilderAccess::GROUP ('divi-theme-builder'); ChangeLog::record().

  • Produces: DiviToolGate::GROUPS, __construct(Closure $groups, ?ChangeLog $log = null), covers(Ability $ability): bool, refusal(Ability $ability): ?WP_Error, annotate(Ability $ability, mixed $result): mixed, outputSchema(Ability $ability, ?array $schema): ?array; AbilityRegistrar::__construct(IntegrationRegistry $registry, ?ExposurePolicy $policy = null, ?DiviToolGate $divi = null); Protokoll-Ergebnis refused.

  • Step 1: Write the failing tests

tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;

final class DiviToolGateTest extends TestCase
{
    private const GROUPS = [
        'lit/divi-insert' => 'divi',
        'lit/divi-outline' => 'divi',
        'lit/library-save' => 'divi-library',
        'lit/tb-create-template' => 'divi-theme-builder',
        'lit/content-create' => 'wordpress',
    ];

    private function ability(string $name, bool $readonly): Ability
    {
        $ability = Mockery::mock(Ability::class);
        $ability->allows('name')->andReturn($name);
        $ability->allows('annotations')->andReturn(['readonly' => $readonly]);

        return $ability;
    }

    private function gate(?FakeChangeLog $log = null): DiviToolGate
    {
        return new DiviToolGate(static fn (): array => self::GROUPS, $log);
    }

    private function untested(): void
    {
        ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
    }

    public function testReleasedDiviRefusesNothingAndAddsNothing(): void
    {
        $gate = $this->gate(FakeChangeLog::forbidden());

        self::assertNull($gate->refusal($this->ability('lit/divi-insert', false)));
        self::assertSame(['ok' => true], $gate->annotate($this->ability('lit/divi-outline', true), ['ok' => true]));
    }

    public function testWriteToolOfEveryDiviGroupIsRefused(): void
    {
        $this->untested();
        $log = new FakeChangeLog();
        $gate = $this->gate($log);

        foreach (['lit/divi-insert', 'lit/library-save', 'lit/tb-create-template'] as $name) {
            self::assertToolError($gate->refusal($this->ability($name, false)), 'divi_untested', $name);
        }
        self::assertNull($gate->refusal($this->ability('lit/content-create', false)), 'not a Divi tool (adapter lock)');
        self::assertNull($gate->refusal($this->ability('lit/divi-outline', true)), 'reading stays allowed');
        self::assertSame(['refused', 'refused', 'refused'], array_column($log->records, 'result'));
        self::assertSame('Abgelehnt: Divi 5.3.0 ist nicht freigegeben.', $log->records[0]['summary']);
    }

    public function testReadResultsKeepTheirShapeAndGetTheNotice(): void
    {
        $this->untested();
        $gate = $this->gate();
        $read = $this->ability('lit/divi-outline', true);
        $notice = 'Achtung: Divi 5.3.0 ist für LIT MCP ungetestet; Lesen funktioniert, Schreiben ist gesperrt.';

        self::assertSame(['post_id' => 7, 'warnings' => [$notice]], $gate->annotate($read, ['post_id' => 7]));
        self::assertSame(
            ['warnings' => ['alt', $notice]],
            $gate->annotate($read, ['warnings' => ['alt']])
        );
        self::assertSame([1, 2], $gate->annotate($read, [1, 2]), 'a list stays a list');
        $error = new WP_Error('lit_not_found', 'x');
        self::assertSame($error, $gate->annotate($read, $error));
        self::assertSame(['post_id' => 7], $gate->annotate($this->ability('lit/content-create', true), ['post_id' => 7]));
    }

    public function testOutputSchemaAllowsWarnings(): void
    {
        $gate = $this->gate();
        $read = $this->ability('lit/divi-outline', true);

        $schema = $gate->outputSchema($read, ['type' => 'object', 'properties' => ['post_id' => ['type' => 'integer']]]);
        self::assertSame(['type' => 'array', 'items' => ['type' => 'string']], $schema['properties']['warnings'] ?? null);

        $own = ['type' => 'object', 'properties' => ['warnings' => ['type' => 'array']]];
        self::assertSame($own, $gate->outputSchema($read, $own), 'an existing warnings property stays');
        self::assertNull($gate->outputSchema($read, null));
        self::assertSame(
            ['type' => 'object'],
            $gate->outputSchema($this->ability('lit/content-create', true), ['type' => 'object'])
        );
    }
}

In tests/Unit/Abilities/AbilityRegistrarTest.php ergänzen (Importe LIT\MCP\Integrations\Divi\Release\DiviLocked, …\DiviToolGate, …\ReleaseGate, …\ReleaseState, …\ReleaseStatus):

    private function diviRegistrar(Ability $ability): AbilityRegistrar
    {
        $integration = Mockery::mock(Integration::class);
        $integration->allows('id')->andReturn('divi');
        $integration->allows('isAvailable')->andReturn(true);
        $integration->allows('abilities')->andReturn([$ability]);
        $registry = new IntegrationRegistry($integration);

        return new AbilityRegistrar($registry, null, new DiviToolGate($registry->groupMap(...)));
    }

    public function testUntestedDiviRefusesAWritingDiviToolBeforeItRuns(): void
    {
        ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
        $ability = $this->ability(false);
        $ability->expects('execute')->never();

        $args = $this->diviRegistrar($ability)->argsFor($ability);

        self::assertToolError(($args['execute_callback'])([]), 'divi_untested');
    }

    public function testALockThrownInsideAWriteBecomesTheLockError(): void
    {
        $locked = new DiviLocked(new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['x']));
        $ability = $this->ability();
        $ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $locked));

        self::assertToolError($this->executeLogged($ability), 'divi_incompatible');
    }

    public function testAPartialWriteStaysPartialEvenWithALockInside(): void
    {
        $locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
        $ability = $this->ability();
        $ability->allows('execute')->andThrow(WriteFailed::partial('Teilweise gespeichert.', $locked));

        self::assertToolError($this->executeLogged($ability), 'partial_write');
    }

    /** Runs the execute callback with error_log() redirected (a PHP internal, not stubbable – like the tests above). */
    private function executeLogged(Ability $ability): mixed
    {
        $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
        $previous = ini_set('error_log', $log);

        try {
            return ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]);
        } finally {
            ini_set('error_log', (string) $previous);
            unlink($log);
        }
    }

    public function testDiviReadToolsGetWarningsInTheirSchema(): void
    {
        $ability = $this->ability();

        $args = $this->diviRegistrar($ability)->argsFor($ability);

        self::assertArrayHasKey('warnings', $args['output_schema']['properties']);
    }

Den bestehenden Helfer ability() in derselben Datei um den Lese-Schalter erweitern (alle bisherigen Aufrufe bleiben gültig):

    private function ability(bool $readonly = true): Mockery\MockInterface
    {
        $ability = Mockery::mock(Ability::class);
        $ability->allows('name')->andReturn('lit/test');
        $ability->allows('label')->andReturn('Test');
        $ability->allows('description')->andReturn('Test ability');
        $ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]);
        $ability->allows('outputSchema')->andReturn(['type' => 'object']);
        $ability->allows('annotations')->andReturn(['readonly' => $readonly]);
        $ability->allows('isDirect')->andReturn(true);

        return $ability;
    }

In tests/Unit/Admin/LogListTableTest.php im Datenlieferanten notOk() nach ['partial', 'warning', 'teilweise'], ergänzen:

            ['refused', 'warning', 'abgelehnt'],
  • Step 2: Run tests to verify they fail

Run: vendor/bin/phpunit --filter 'DiviToolGateTest|AbilityRegistrarTest|LogListTableTest' Expected: FAIL.

  • Step 3: Implementation

src/Integrations/Divi/Release/DiviToolGate.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use Closure;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
use WP_Error;

defined('ABSPATH') || exit;

/**
 * The central release lock of the Divi tools (spec §5.5): a writing tool of the groups Divi, Divi-Bibliothek or Divi
 * Theme Builder is refused before it runs while Divi is not released (logged with result "refused"); a reading tool
 * runs and gets the notice in warnings. Other tools that reach Divi (lit/content-create with divi) meet the adapter
 * lock (ReleaseGate::assertWritable()).
 */
final class DiviToolGate
{
    public const GROUPS = ['divi', LibraryAccess::GROUP, ThemeBuilderAccess::GROUP];

    /** @var Closure(): array<string, string> */
    private Closure $groups;

    /** @param Closure(): array<string, string> $groups Ability name => integration ID (IntegrationRegistry::groupMap). */
    public function __construct(Closure $groups, private ?ChangeLog $log = null)
    {
        $this->groups = $groups;
    }

    public function covers(Ability $ability): bool
    {
        return in_array(($this->groups)()[$ability->name()] ?? '', self::GROUPS, true);
    }

    public function refusal(Ability $ability): ?WP_Error
    {
        if (! $this->covers($ability) || self::readsOnly($ability)) {
            return null;
        }

        $status = ReleaseGate::status();
        if ($status->writable()) {
            return null;
        }

        $this->log?->record(
            $ability->name(),
            'divi',
            $status->version,
            sprintf(
                'Abgelehnt: %s ist %s.',
                $status->versionLabel(),
                $status->state === ReleaseState::Incompatible ? 'inkompatibel' : 'nicht freigegeben'
            ),
            result: 'refused'
        );

        return $status->refusal();
    }

    public function annotate(Ability $ability, mixed $result): mixed
    {
        if (! is_array($result) || array_is_list($result) || ! $this->covers($ability) || ! self::readsOnly($ability)) {
            return $result;
        }

        $status = ReleaseGate::status();
        if ($status->writable()) {
            return $result;
        }

        $warnings = is_array($result['warnings'] ?? null) ? array_values($result['warnings']) : [];
        $warnings[] = $status->notice();
        $result['warnings'] = $warnings;

        return $result;
    }

    /**
     * @param array<string, mixed>|null $schema
     * @return array<string, mixed>|null
     */
    public function outputSchema(Ability $ability, ?array $schema): ?array
    {
        if ($schema === null || ($schema['type'] ?? null) !== 'object' || ! $this->covers($ability)) {
            return $schema;
        }

        $properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
        $properties['warnings'] ??= ['type' => 'array', 'items' => ['type' => 'string']];
        $schema['properties'] = $properties;

        return $schema;
    }

    private static function readsOnly(Ability $ability): bool
    {
        return ($ability->annotations()['readonly'] ?? false) === true;
    }
}

src/Abilities/AbilityRegistrar.php:

  • Importe LIT\MCP\Integrations\Divi\Release\DiviLocked, LIT\MCP\Integrations\Divi\Release\DiviToolGate.
  • Klassen-Docblock ergänzen: „… with the role gate, the Divi release lock (DiviToolGate) and neutral error handling.“
  • Konstruktor:
    public function __construct(
        private IntegrationRegistry $registry,
        private ?ExposurePolicy $policy = null,
        private ?DiviToolGate $divi = null
    ) {
    }
  • In argsFor() als erste Zeile $divi = $this->divi ?? new DiviToolGate($this->registry->groupMap(...));, der execute_callback wird
            'execute_callback' => static function (mixed $input = null) use ($ability, $divi): mixed {
                $refusal = $divi->refusal($ability);
                if ($refusal !== null) {
                    return $refusal;
                }

                try {
                    return $divi->annotate($ability, $ability->execute(is_array($input) ? $input : []));
                } catch (Throwable $e) {
                    Log::failure($ability->name(), $e);

                    return self::errorFor($e);
                }
            },
  • und statt $output = $ability->outputSchema(); steht $output = $divi->outputSchema($ability, $ability->outputSchema());.
  • Neue Methode:
    /**
     * A partial write stays partial_write; otherwise a Divi release lock anywhere in the cause chain is its refusal
     * (spec §5.5); a rolled-back write is write_failed; anything else internal_error.
     */
    private static function errorFor(Throwable $e): WP_Error
    {
        if ($e instanceof WriteFailed && ! $e->rolledBack) {
            return $e->toError();
        }
        $locked = DiviLocked::in($e);
        if ($locked !== null) {
            return $locked->toError();
        }

        return $e instanceof WriteFailed ? $e->toError() : Errors::internal();
    }

src/Plugin.php: Import LIT\MCP\Integrations\Divi\Release\DiviToolGate; die Zeile (new AbilityRegistrar($registry, $policy))->register(); wird

        (new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register();

src/Admin/LogListTable.php, status(): nach 'partial' => … die Zeile 'refused' => ['warning', __('abgelehnt', 'wp-lit-mcp')],; Docblock der Methode „Marks rows whose write did not complete“ um „or was refused (Divi release lock)“ ergänzen.

src/Admin/LogTab.php: RESULTS = ['ok', 'partial', 'refused']; im Filter $labels um 'refused' => __('abgelehnt', 'wp-lit-mcp') ergänzen.

  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'DiviToolGateTest|AbilityRegistrarTest|LogListTableTest|LogTabTest' Expected: PASS.

  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün. Commit wie in den Global Constraints mit Nachricht feat: central Divi release lock for the Divi tools.


Task 11: Sperre in den Adaptern, lit-content-create, lit-site-status

Files:

  • Modify: src/Integrations/Divi/Pages/DiviSaver.php, src/Integrations/Divi/Library/DiviLibraryStore.php, src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php, src/Integrations/WordPress/Content/ContentComposer.php, src/Integrations/Divi/Pages/DiviContentComposer.php, src/Integrations/WordPress/Content/ContentCreateAbility.php, src/Integrations/Core/EnvironmentProbe.php, src/Integrations/Core/SiteStatusAbility.php, src/Plugin.php
  • Test: tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php (neu), tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php, tests/Unit/Integrations/Core/EnvironmentProbeTest.php, weitere Tests 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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;

/** Spec §5.5: every writing adapter method refuses before its first write while Divi is not released. */
final class ReleaseGuardsTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
        // Nothing may be written: every write function WordPress offers fails the test.
        foreach (['wp_update_post', 'wp_insert_post', 'update_post_meta', 'add_post_meta', 'delete_post_meta',
            'update_option', 'delete_option', 'wp_set_object_terms'] as $write) {
            Functions\when($write)->alias(static function () use ($write): never {
                self::fail($write . ' ran although Divi is locked');
            });
        }
        Functions\when('current_user_can')->justReturn(true);
    }

    /** @return iterable<string, array{callable(): mixed}> */
    public static function writes(): iterable
    {
        $post = new WP_Post(['ID' => 7, 'post_content' => '', 'post_type' => 'page']);
        $spec = new LibraryItemSpec('Hero', 'section', false, 'regular', null, 'page');

        yield 'save' => [static fn () => (new DiviSaver())->save($post, '<!-- wp:divi/section /-->')];
        yield 'saveNew' => [static fn () => (new DiviSaver())->saveNew(7, '<!-- wp:divi/section /-->')];
        yield 'library create' => [static fn () => (new DiviLibraryStore())->create(
            $spec,
            new NewDraft(null, FakeChangeLog::forbidden(), 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag')
        )];
        yield 'tb insertLayout' => [static fn () => (new DiviThemeBuilder())->insertLayout('header', 'Kopf')];
        yield 'tb storeTemplate' => [static fn () => (new DiviThemeBuilder())->storeTemplate(3, [
            'title' => 'V', 'layouts' => [], 'use_on' => [], 'exclude_from' => [],
        ])];
        yield 'tb attach' => [static fn () => (new DiviThemeBuilder())->attach(3, 9)];
        yield 'tb detach' => [static fn () => (new DiviThemeBuilder())->detach(3, 9)];
        yield 'tb setConditions' => [static fn () => (new DiviThemeBuilder())->setConditions(9, ['singular'], [])];
        yield 'tb setEnabled' => [static fn () => (new DiviThemeBuilder())->setEnabled(9, true)];
        yield 'tb unmarkUnused' => [static fn () => (new DiviThemeBuilder())->unmarkUnused(9)];
    }

    /** @dataProvider writes */
    public function testRefusesBeforeTheFirstWrite(callable $write): void
    {
        $this->expectException(DiviLocked::class);

        $write();
    }

    public function testTheCheckPageMayBeWrittenWhileProbing(): void
    {
        Functions\when('wp_update_post')->justReturn(41);
        Functions\when('update_post_meta')->justReturn(true);
        Functions\when('get_post_meta')->alias(static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on');
        Functions\when('get_post')->justReturn(new WP_Post(['ID' => 41, 'post_content' => 'x']));
        Functions\when('has_filter')->justReturn(false);

        $warnings = ReleaseGate::whileProbing(41, static fn () => (new DiviSaver())->saveNew(41, 'x'));

        self::assertSame([], $warnings);
    }
}

In tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php: die Hilfsmethode composer() bekommt einen dritten Parameter ?WP_Error $refusal = null und $composer->allows('refusal')->andReturn($refusal);. Neuer Test (Import LIT\MCP\Integrations\Divi\Release\ReleaseState, …\ReleaseStatus):

    public function testLockedDiviRefusesBeforeTheDraftExists(): void
    {
        $refusal = (new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->refusal();
        Functions\expect('wp_insert_post')->never();

        $result = $this->ability($this->composer(true, true, $refusal))->execute(self::INPUT);

        self::assertToolError($result, 'divi_untested');
    }

Alle übrigen Mocks von ContentComposer im Test-Verzeichnis (grep -rln "mock(ContentComposer::class)" tests) um ->allows('refusal')->andReturn(null) ergänzen, wo der Mock writable erlaubt.

In tests/Unit/Integrations/Core/EnvironmentProbeTest.php ergänzen:

    public function testDiviComponentCarriesTheReleaseStatus(): void
    {
        $theme = Mockery::mock();
        $theme->allows('get_template')->andReturn('Divi');
        $theme->allows('parent')->andReturn(false);
        $theme->allows('get')->with('Version')->andReturn('5.2.1');
        Functions\when('wp_get_theme')->justReturn($theme);

        $components = array_column(
            (new EnvironmentProbe(new DiviCompatibility([])))->components(),
            null,
            'id'
        );

        self::assertSame('released', $components['divi']['release']['state'] ?? null);
        self::assertNull($components['pods']['release'] ?? null);
    }
  • Step 2: Run tests to verify they fail

Run: vendor/bin/phpunit --filter 'ReleaseGuardsTest|ContentCreateDiviTest|EnvironmentProbeTest' Expected: FAIL (keine Ausnahme DiviLocked; refusal unbekannt; kein release).

  • Step 3: Implementation

src/Integrations/Divi/Pages/DiviSaver.php – Import LIT\MCP\Integrations\Divi\Release\ReleaseGate; erste Zeile in save() nach $postId = (int) $post->ID;:

        ReleaseGate::assertWritable($postId);

erste Zeile in saveNew():

        ReleaseGate::assertWritable($postId);

Beide Docblocks um @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. ergänzen (Import …\Release\DiviLocked nur für den Docblock ist in diesem Code üblich; sonst voll qualifiziert im Docblock).

src/Integrations/Divi/Library/DiviLibraryStore.php: erste Zeile in create(): ReleaseGate::assertWritable(); (Import).

src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php: erste Zeile in insertLayout(), storeTemplate(), attach(), detach(), setConditions(), setEnabled(), unmarkUnused(): ReleaseGate::assertWritable(); (Import). In insertLayout() vor der LAYOUT_TYPES-Prüfung.

src/Integrations/WordPress/Content/ContentComposer.php – neue Methode der Schnittstelle nach writable():

    /**
     * Why writing such content is refused right now although it is enabled (e.g. the installed Divi version is not
     * released for LIT MCP, spec §5.5); null = allowed.
     */
    public function refusal(): ?WP_Error;

(Import WP_Error ergänzen, falls nicht vorhanden.)

src/Integrations/Divi/Pages/DiviContentComposer.php (Import LIT\MCP\Integrations\Divi\Release\ReleaseGate):

    public function refusal(): ?WP_Error
    {
        $status = ReleaseGate::status();

        return $status->writable() ? null : $status->refusal();
    }

src/Integrations/WordPress/Content/ContentCreateAbility.php, direkt nach dem writable()-Block:

        $locked = $withDivi ? $this->divi->refusal() : null;
        if ($locked !== null) {
            return $locked;
        }

src/Integrations/Core/EnvironmentProbe.php – Import LIT\MCP\Integrations\Divi\Release\ReleaseGate; Rückgabetyp im Docblock um release: ?array<string, mixed> erweitern; im Divi-Eintrag 'release' => $isDivi ? ReleaseGate::status()->toArray() : null,, bei Pods und The Events Calendar 'release' => null,.

src/Integrations/Core/SiteStatusAbility.php, description(): „Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.“ → „Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, und release, ob LIT MCP mit dieser Divi-Version schreiben darf (released, untested, incompatible).“

src/Plugin.php, in boot() nach der ChangeLog-Einrichtung (Import LIT\MCP\Integrations\Divi\Release\ReleaseResolver):

        // Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too).
        add_action('upgrader_process_complete', static function (): void {
            delete_transient(ReleaseResolver::CACHE);
        });
  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'ReleaseGuardsTest|ContentCreate|EnvironmentProbeTest|SiteStatusAbilityTest|DiviSaverTest|DiviLibraryStoreTest|DiviThemeBuilderTest' Expected: PASS. Scheitert testRefusesBeforeTheFirstWrite für eine Methode mit „… ran although Divi is locked“, steht die Sperre dort nicht in der ersten Zeile.

  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün. Commit wie in den Global Constraints mit Nachricht feat: adapter-level Divi release lock, content-create and site status.


Task 12: Prüfschritte der Admin-Prüfung (Katalog, Format, Schreibtest, Bericht)

Files:

  • Create: src/Integrations/Divi/Schema/ShortformCheck.php, src/Integrations/Divi/Release/FormatCheck.php, reference-page.html (Kopie 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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Schema;

use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;

final class ShortformCheckTest extends TestCase
{
    public function testAnEmptyCatalogKnowsNoShortformModule(): void
    {
        $problems = ShortformCheck::problems(new ModuleCatalog([]));

        self::assertCount(count(ShortformMap::types()), $problems);
        self::assertContains('divi/text: unbekanntes Modul', $problems);
    }

    /** The fixture catalog (metadata-sample.json) lacks some modules, but every path of the modules it has is right. */
    public function testFixtureCatalogHasNoWrongPathsOrChildren(): void
    {
        foreach (ShortformCheck::problems(DiviFixtures::catalog()) as $problem) {
            self::assertStringEndsWith(': unbekanntes Modul', $problem);
        }
        self::assertNotContains('divi/text: unbekanntes Modul', ShortformCheck::problems(DiviFixtures::catalog()));
    }
}

tests/Unit/Integrations/Divi/Release/FormatCheckTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Release\FormatCheck;
use PHPUnit\Framework\TestCase;

final class FormatCheckTest extends TestCase
{
    /** parse_blocks() as WordPress would answer for well-formed Divi markup: the same names and nesting. */
    private static function wordPress(): \Closure
    {
        $convert = static function (array $blocks) use (&$convert): array {
            return array_map(static fn (Block $block): array => [
                'blockName' => $block->name(),
                'innerBlocks' => $convert($block->children()),
            ], $blocks);
        };

        return static fn (string $content): array => $convert((new Parser())->parse($content)->children());
    }

    public function testTheShippedReferenceRoundTripsAndMatchesWordPress(): void
    {
        self::assertSame([], (new FormatCheck(null, self::wordPress()))->problems());
        self::assertFileEquals(dirname(__DIR__, 5) . '/tests/Fixtures/divi/converted-landing.html', FormatCheck::REFERENCE);
    }

    public function testADifferentStructureInWordPressIsReported(): void
    {
        $check = new FormatCheck(null, static fn (): array => [['blockName' => 'divi/section', 'innerBlocks' => []]]);

        self::assertSame(['WordPress liest eine andere Blockstruktur als LIT MCP.'], $check->problems());
    }

    public function testAMissingReferenceIsReported(): void
    {
        self::assertSame(
            ['Der Referenzinhalt der Prüfung fehlt im Plugin.'],
            (new FormatCheck('/nonexistent/reference.html', self::wordPress()))->problems()
        );
    }
}

tests/Unit/Integrations/Divi/Release/WriteProbeTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Post;

final class WriteProbeTest extends TestCase
{
    /** @var list<int> */
    private array $deleted = [];

    protected function setUp(): void
    {
        parent::setUp();
        // The probe must work while Divi is untested – that is when it runs.
        ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
        $this->deleted = [];
        Functions\when('wp_insert_post')->justReturn(41);
        Functions\when('get_post')->justReturn(new WP_Post(['ID' => 41, 'post_content' => '<!-- wp:divi/section /-->']));
        Functions\when('get_post_meta')->alias(
            static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on'
        );
        Functions\when('wp_delete_post')->alias(function (int $id, bool $force): WP_Post {
            self::assertTrue($force, 'never to the trash');
            $this->deleted[] = $id;

            return new WP_Post(['ID' => $id]);
        });
    }

    private function composer(?callable $write = null): ContentComposer
    {
        $composer = Mockery::mock(ContentComposer::class);
        $composer->allows('write')->andReturnUsing($write ?? static function (int $id, string $type, array $sections): array {
            // The composer writes through the saver, which asks the gate: the probe must have opened it for 41.
            ReleaseGate::assertWritable($id);

            return ['warnings' => [], 'outline' => ''];
        });

        return $composer;
    }

    private function renderer(string $html): PageRenderer
    {
        $renderer = Mockery::mock(PageRenderer::class);
        $renderer->allows('render')->andReturn($html);

        return $renderer;
    }

    public function testGreenWhenEveryModuleRendersAndThePageIsGone(): void
    {
        $probe = new WriteProbe($this->composer(), $this->renderer(implode(' ', WriteProbe::TEXTS)), '/nonexistent-divi');

        self::assertSame([], $probe->run());
        self::assertSame([41], $this->deleted);
    }

    public function testMissingTextsAndFlagsAreReported(): void
    {
        Functions\when('get_post_meta')->justReturn('');

        $problems = (new WriteProbe($this->composer(), $this->renderer('litcheck-heading'), '/nonexistent-divi'))->run();

        self::assertContains('Builder-Kennzeichen _et_pb_use_builder fehlt.', $problems);
        self::assertContains('„litcheck-button“ fehlt in der gerenderten Seite.', $problems);
        self::assertNotContains('„litcheck-heading“ fehlt in der gerenderten Seite.', $problems);
        self::assertSame([41], $this->deleted);
    }

    public function testDeletesThePageEvenWhenWritingThrows(): void
    {
        $composer = $this->composer(static function (): never {
            throw new RuntimeException('Composer kaputt');
        });

        $problems = (new WriteProbe($composer, $this->renderer(''), '/nonexistent-divi'))->run();

        self::assertSame(['Schreibtest abgebrochen: Composer kaputt'], $problems);
        self::assertSame([41], $this->deleted);
        $this->expectException(\LIT\MCP\Integrations\Divi\Release\DiviLocked::class);
        ReleaseGate::assertWritable(41);
    }

    public function testReportsAPageThatCouldNotBeDeleted(): void
    {
        Functions\when('wp_delete_post')->justReturn(false);

        $problems = (new WriteProbe($this->composer(), $this->renderer(implode(' ', WriteProbe::TEXTS)), '/nonexistent-divi'))->run();

        self::assertSame(
            ['Die Testseite 41 konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.'],
            $problems
        );
    }

    public function testCreatesAMarkedDraftPage(): void
    {
        Functions\expect('wp_insert_post')->once()->with(Mockery::on(static fn (array $post): bool =>
            $post['post_type'] === 'page'
            && $post['post_status'] === 'draft'
            && str_starts_with($post['post_title'], '[LIT MCP Prüfung] ')
            && $post['meta_input'] === ['_lit_mcp_check_page' => '1']), true)->andReturn(41);

        (new WriteProbe($this->composer(), $this->renderer(implode(' ', WriteProbe::TEXTS)), '/nonexistent-divi'))->run();
    }

    public function testRemovesOrphanedCheckPages(): void
    {
        Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $query): bool =>
            $query['meta_key'] === '_lit_mcp_check_page'
            && $query['post_status'] === 'any'
            && $query['fields'] === 'ids'
            && ($query['date_query'][0]['before'] ?? null) === '10 minutes ago'))->andReturn([51, 52]);

        self::assertSame([51, 52], WriteProbe::removeOrphans());
        self::assertSame([51, 52], $this->deleted);
    }
}

tests/Unit/Integrations/Divi/Release/CheckReportTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use PHPUnit\Framework\TestCase;

final class CheckReportTest extends TestCase
{
    private static function report(array $steps): CheckReport
    {
        return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, '5.3.0', '0.6.0', 'key', $steps);
    }

    private static function green(): array
    {
        return array_map(
            static fn (string $key): CheckStep => new CheckStep($key, []),
            [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
        );
    }

    public function testGreenOnlyWithEveryStepGreenAndNoneSkipped(): void
    {
        self::assertTrue(self::report(self::green())->green());
        self::assertFalse(self::report([])->green());

        $red = self::green();
        $red[2] = new CheckStep(CheckStep::CATALOG, ['divi/text: unbekanntes Modul']);
        self::assertFalse(self::report($red)->green());

        $skipped = self::green();
        $skipped[4] = new CheckStep(CheckStep::WRITE, [], true);
        self::assertFalse(self::report($skipped)->green());
    }

    public function testFreshOnlyForTheSameVersionsAndStateKey(): void
    {
        $report = self::report(self::green());

        self::assertTrue($report->freshFor('5.3.0', '0.6.0', 'key'));
        self::assertFalse($report->freshFor('5.3.1', '0.6.0', 'key'), 'Divi updated');
        self::assertFalse($report->freshFor('5.3.0', '0.7.0', 'key'), 'plugin updated');
        self::assertFalse($report->freshFor('5.3.0', '0.6.0', 'other'), 'a Divi file changed');
    }

    public function testRoundTripsThroughAnArray(): void
    {
        $steps = self::green();
        $steps[1] = new CheckStep(CheckStep::MIRRORS, ['Theme Builder: Nachbau von x() – y']);
        $report = self::report($steps);

        self::assertEquals($report, CheckReport::fromArray($report->toArray()));
        self::assertNull(CheckReport::fromArray('broken'));
        self::assertNull(CheckReport::fromArray(['id' => 'r1']));
        self::assertSame('Fingerabdrücke nachgebauter Divi-Funktionen', $report->step(CheckStep::MIRRORS)?->label());
    }
}

tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\FormatCheck;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;

final class CompatibilityCheckTest extends TestCase
{
    private function check(array $code, array $mirrors, ?WriteProbe $probe = null, ?\Closure $catalog = null): CompatibilityCheck
    {
        Functions\when('wp_generate_uuid4')->justReturn('uuid-1');
        $manifests = Mockery::mock(ManifestCheck::class);
        $manifests->allows('codeChecks')->andReturn($code);
        $manifests->allows('registrationChecks')->andReturn([]);
        $manifests->allows('mirrorChecks')->andReturn($mirrors);
        $format = Mockery::mock(FormatCheck::class);
        $format->allows('problems')->andReturn([]);
        if ($probe === null) {
            $probe = Mockery::mock(WriteProbe::class);
            $probe->allows('run')->andReturn([]);
        }

        return new CompatibilityCheck(
            $manifests,
            $catalog ?? static fn (): array => [],
            $format,
            $probe,
            static fn (): string => '5.3.0',
            static fn (string $version): string => 'key-' . $version
        );
    }

    public function testAGreenRunNamesVersionsAndStateKey(): void
    {
        $report = $this->check([], [])->run(7);

        self::assertTrue($report->green());
        self::assertSame(['uuid-1', 7, '5.3.0', Plugin::VERSION, 'key-5.3.0'], [
            $report->id, $report->userId, $report->diviVersion, $report->pluginVersion, $report->stateKey,
        ]);
        self::assertSame(
            ['manifest', 'mirrors', 'catalog', 'format', 'write'],
            array_map(static fn (CheckStep $step): string => $step->key, $report->steps)
        );
    }

    public function testNoWriteTestWhenDiviFunctionsAreMissing(): void
    {
        $probe = Mockery::mock(WriteProbe::class);
        $probe->expects('run')->never();

        $report = $this->check(['Seiten: et_x()' => static fn (): bool => false], [], $probe)->run(7);

        self::assertSame(['Seiten: et_x()'], $report->step(CheckStep::MANIFEST)?->problems);
        self::assertTrue($report->step(CheckStep::WRITE)?->skipped);
    }

    public function testChangedFingerprintsStillRunTheWriteTest(): void
    {
        $probe = Mockery::mock(WriteProbe::class);
        $probe->expects('run')->once()->andReturn([]);

        $report = $this->check([], ['TB: Nachbau von y()' => static fn (): bool => false], $probe)->run(7);

        self::assertFalse($report->green());
        self::assertTrue($report->step(CheckStep::WRITE)?->green());
    }

    public function testAThrowingStepIsARedStep(): void
    {
        $report = $this->check([], [], null, static function (): never {
            throw new RuntimeException('Katalog weg');
        })->run(7);

        self::assertSame(['Prüfschritt abgebrochen: Katalog weg'], $report->step(CheckStep::CATALOG)?->problems);
    }
}
  • Step 2: Run tests to verify they fail

Run: vendor/bin/phpunit --filter 'ShortformCheckTest|FormatCheckTest|WriteProbeTest|CheckReportTest|CompatibilityCheckTest' Expected: FAIL (Klassen fehlen).

  • Step 3: Implementation
cp tests/Fixtures/divi/converted-landing.html src/Integrations/Divi/Release/reference-page.html

src/Integrations/Divi/Schema/ShortformCheck.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Schema;

defined('ABSPATH') || exit;

/**
 * The curated short form against a module catalog (spec §6.2 step 2): every module exists, every field path is a
 * known attribute path, every item type is an allowed child. Pure PHP; the admin check passes Divi's live catalog.
 */
final class ShortformCheck
{
    /** @return list<string> German problems; [] = the short form matches. */
    public static function problems(ModuleCatalog $catalog): array
    {
        $problems = [];
        foreach (ShortformMap::types() as $type) {
            $name = ModuleCatalog::normalize($type);
            if (! $catalog->has($name)) {
                $problems[] = $name . ': unbekanntes Modul';
                continue;
            }

            foreach (ShortformMap::fields($type) as $field => $spec) {
                if (! $catalog->isKnownPath($name, (string) $spec['path'])) {
                    $problems[] = sprintf(
                        '%s.%s: unbekannter Pfad %s (ähnlich: %s)',
                        $type,
                        $field,
                        $spec['path'],
                        implode(', ', $catalog->suggestions($name, (string) $spec['path']))
                    );
                }
            }

            $child = ShortformMap::childType($type);
            if ($child !== null && ! in_array(ModuleCatalog::normalize($child), $catalog->childrenOf($name), true)) {
                $problems[] = sprintf(
                    '%s: %s ist kein erlaubtes Kind (%s)',
                    $type,
                    $child,
                    implode(', ', $catalog->childrenOf($name))
                );
            }
        }

        return $problems;
    }
}

tests/Integration/divi-shortform-check.php – der Schleifenblock ab $problems = []; bis vor if ($problems !== []) wird $problems = ShortformCheck::problems($catalog); (Import LIT\MCP\Integrations\Divi\Schema\ShortformCheck; ShortformMap bleibt für die Erfolgsmeldung).

src/Integrations/Divi/Release/FormatCheck.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use Closure;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;

defined('ABSPATH') || exit;

/**
 * Spec §6.2 step 3: a builder-made Divi 5 page (reference-page.html, from the converted fixture) parses and
 * serializes byte-identically, and WordPress' parse_blocks() sees the same block names and nesting as the plugin's
 * parser. Nothing is written. Not final so CompatibilityCheck can be tested with a mock.
 */
class FormatCheck
{
    public const REFERENCE = __DIR__ . '/reference-page.html';

    /** @param (Closure(string): array<int, mixed>)|null $wpBlocks null = parse_blocks(). */
    public function __construct(private ?string $file = null, private ?Closure $wpBlocks = null)
    {
    }

    /** @return list<string> */
    public function problems(): array
    {
        $file = $this->file ?? self::REFERENCE;
        $content = is_file($file) ? file_get_contents($file) : false;
        if (! is_string($content) || $content === '') {
            return ['Der Referenzinhalt der Prüfung fehlt im Plugin.'];
        }

        $document = (new Parser())->parse($content);
        $problems = [];
        if (Serializer::document($document) !== $content) {
            $problems[] = 'Parser und Serializer geben den Referenzinhalt nicht byte-gleich zurück.';
        }
        $wordPress = ($this->wpBlocks ?? static fn (string $markup): array => parse_blocks($markup))($content);
        if (self::structure($document->children()) !== self::wpStructure($wordPress)) {
            $problems[] = 'WordPress liest eine andere Blockstruktur als LIT MCP.';
        }

        return $problems;
    }

    /**
     * @param list<Block> $blocks
     * @return list<array{0: string, 1: list<mixed>}>
     */
    private static function structure(array $blocks): array
    {
        return array_map(static fn (Block $block): array => [$block->name(), self::structure($block->children())], $blocks);
    }

    /**
     * @param array<int, mixed> $blocks parse_blocks() result; freeform parts (blockName null) are left out.
     * @return list<array{0: string, 1: list<mixed>}>
     */
    private static function wpStructure(array $blocks): array
    {
        $structure = [];
        foreach ($blocks as $block) {
            if (is_array($block) && is_string($block['blockName'] ?? null)) {
                $inner = is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : [];
                $structure[] = [$block['blockName'], self::wpStructure($inner)];
            }
        }

        return $structure;
    }
}

src/Integrations/Divi/Release/WriteProbe.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use Throwable;
use WP_Error;
use WP_Post;

defined('ABSPATH') || exit;

/**
 * Spec §6.2 step 4: a throwaway draft page written through the normal Divi write path (ContentComposer → Composer →
 * Serializer → DiviSaver, with the release lock opened for this page only), checked (content, builder flags, Divi
 * renders every module without a PHP warning from Divi's files) and always deleted for good – in finally, not to the
 * trash, with its revisions and meta. Pages a dead check left behind carry MARKER; removeOrphans() deletes them.
 * No change log entry. Not final so CompatibilityCheck can be tested with a mock.
 */
class WriteProbe
{
    public const MARKER = '_lit_mcp_check_page';

    public const TITLE = '[LIT MCP Prüfung]';

    /** Everyday modules without media, each with a text to find in the rendered page. */
    public const SECTIONS = [[
        'type' => 'section',
        'label' => 'LIT MCP Prüfung',
        'rows' => [
            [
                'columns' => '1_2,1_2',
                'modules' => [
                    [
                        ['type' => 'heading', 'text' => 'litcheck-heading', 'level' => 'h2'],
                        ['type' => 'text', 'content' => '<p>litcheck-text</p>'],
                        ['type' => 'button', 'text' => 'litcheck-button', 'url' => '/'],
                    ],
                    [
                        ['type' => 'blurb', 'title' => 'litcheck-blurb', 'content' => '<p>litcheck-blurb-text</p>'],
                        ['type' => 'cta', 'title' => 'litcheck-cta', 'button_text' => 'litcheck-cta-button', 'button_url' => '/'],
                        ['type' => 'divider'],
                    ],
                ],
            ],
            [
                'columns' => '4_4',
                'modules' => [
                    ['type' => 'toggle', 'title' => 'litcheck-toggle', 'content' => '<p>litcheck-toggle-text</p>'],
                    ['type' => 'accordion', 'items' => [['title' => 'litcheck-accordion', 'content' => '<p>a</p>']]],
                    ['type' => 'tabs', 'items' => [['title' => 'litcheck-tab', 'content' => '<p>t</p>']]],
                    ['type' => 'icon-list', 'items' => [['text' => 'litcheck-icon-list']]],
                ],
            ],
        ],
    ]];

    public const TEXTS = [
        'litcheck-heading', 'litcheck-text', 'litcheck-button', 'litcheck-blurb', 'litcheck-cta', 'litcheck-toggle',
        'litcheck-accordion', 'litcheck-tab', 'litcheck-icon-list',
    ];

    /** Only pages older than this are orphans – a running check's page is younger. */
    private const ORPHAN_AGE = '10 minutes ago';

    /** @param string|null $diviDir PHP warnings from files below it fail the check; null = DiviEnvironment::themeDir(). */
    public function __construct(
        private ContentComposer $composer,
        private ?PageRenderer $renderer = null,
        private ?string $diviDir = null
    ) {
    }

    /** @return list<string> German problems; [] = green. */
    public function run(): array
    {
        $id = wp_insert_post([
            'post_type' => 'page',
            'post_status' => 'draft',
            'post_title' => self::TITLE . ' ' . gmdate('Y-m-d H:i'),
            'post_content' => '',
            'meta_input' => [self::MARKER => '1'],
        ], true);
        if ($id instanceof WP_Error || (int) $id <= 0) {
            return ['Die Testseite konnte nicht angelegt werden: '
                . ($id instanceof WP_Error ? $id->get_error_message() : 'keine ID')];
        }
        $id = (int) $id;

        try {
            $problems = $this->exercise($id);
        } catch (Throwable $e) {
            $problems = ['Schreibtest abgebrochen: ' . $e->getMessage()];
        } finally {
            if (! self::delete($id)) {
                $problems[] = sprintf(
                    'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.',
                    $id
                );
            }
        }

        return $problems;
    }

    /** @return list<int> IDs of check pages a dead check left behind, deleted now. */
    public static function removeOrphans(): array
    {
        $ids = get_posts([
            'post_type' => 'page',
            'post_status' => 'any',
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked pages at most.
            'meta_key' => self::MARKER,
            'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']],
            'fields' => 'ids',
            'posts_per_page' => 20,
            'no_found_rows' => true,
            'suppress_filters' => true,
        ]);

        $removed = [];
        foreach (is_array($ids) ? $ids : [] as $id) {
            if (self::delete((int) $id)) {
                $removed[] = (int) $id;
            }
        }

        return $removed;
    }

    /** @return list<string> */
    private function exercise(int $id): array
    {
        $written = ReleaseGate::whileProbing(
            $id,
            fn (): array|WP_Error => $this->composer->write($id, 'page', self::SECTIONS)
        );
        if ($written instanceof WP_Error) {
            return ['Die Kurzform der Testseite wurde abgelehnt: ' . Errors::clientMessage($written)];
        }

        $post = get_post($id);
        if (! $post instanceof WP_Post) {
            return ['Die Testseite ist nach dem Schreiben nicht lesbar.'];
        }

        $problems = [];
        if (! str_contains((string) $post->post_content, '<!-- wp:divi/')) {
            $problems[] = 'Es wurde kein Divi-Inhalt gespeichert.';
        }
        foreach (DiviPages::FLAGS as $key => $value) {
            if (get_post_meta($id, $key, true) !== $value) {
                $problems[] = sprintf('Builder-Kennzeichen %s fehlt.', $key);
            }
        }

        [$html, $warnings] = $this->render($post);
        foreach (self::TEXTS as $text) {
            if (! str_contains($html, $text)) {
                $problems[] = sprintf('„%s“ fehlt in der gerenderten Seite.', $text);
            }
        }
        foreach ($warnings as $warning) {
            $problems[] = 'PHP-Warnung aus Divi beim Rendern: ' . $warning;
        }

        return $problems;
    }

    /** @return array{0: string, 1: list<string>} The rendered content and PHP warnings raised in Divi's files. */
    private function render(WP_Post $post): array
    {
        $dir = $this->diviDir ?? DiviEnvironment::themeDir();
        $warnings = [];
        set_error_handler(
            static function (int $level, string $message, string $file = '', int $line = 0) use ($dir, &$warnings): bool {
                if ($dir !== null && str_starts_with($file, $dir)) {
                    $warnings[] = sprintf('%s (%s:%d)', $message, basename($file), $line);
                }

                return false;
            },
            E_WARNING | E_USER_WARNING
        );

        try {
            $html = ($this->renderer ?? new PageRenderer())->render($post);
        } finally {
            restore_error_handler();
        }

        return [$html, $warnings];
    }

    private static function delete(int $id): bool
    {
        return wp_delete_post($id, true) instanceof WP_Post;
    }
}

Hinweis: $problems ist nach catch und finally immer gesetzt – im try durch exercise(), sonst im catch. Die Tests übergeben diviDir ausdrücklich, damit DiviEnvironment (WordPress-Theme-API) nicht gefragt wird. PHP-interne Funktionen wie gmdate() oder time() lassen sich in diesem Projekt nicht stubben (kein patchwork.json); Tests prüfen deshalb nur den festen Teil solcher Werte.

src/Integrations/Divi/Release/CheckStep.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/** One step of the admin check (spec §6.2); skipped = not run (the write test after missing Divi functions). */
final class CheckStep
{
    public const MANIFEST = 'manifest';
    public const MIRRORS = 'mirrors';
    public const CATALOG = 'catalog';
    public const FORMAT = 'format';
    public const WRITE = 'write';

    private const LABELS = [
        self::MANIFEST => 'Divi-Funktionen, Klassen und Registrierungen',
        self::MIRRORS => 'Fingerabdrücke nachgebauter Divi-Funktionen',
        self::CATALOG => 'Modulkatalog (Kurzform-Pfade)',
        self::FORMAT => 'Format im Speicher (Parser und Serializer)',
        self::WRITE => 'Schreibtest auf einer Wegwerfseite',
    ];

    /** @param list<string> $problems */
    public function __construct(public readonly string $key, public readonly array $problems, public readonly bool $skipped = false)
    {
    }

    public function green(): bool
    {
        return ! $this->skipped && $this->problems === [];
    }

    public function label(): string
    {
        return self::LABELS[$this->key] ?? $this->key;
    }

    /** @return array{key: string, problems: list<string>, skipped: bool} */
    public function toArray(): array
    {
        return ['key' => $this->key, 'problems' => $this->problems, 'skipped' => $this->skipped];
    }

    public static function fromArray(mixed $data): ?self
    {
        if (! is_array($data) || ! is_string($data['key'] ?? null) || ! is_array($data['problems'] ?? null)) {
            return null;
        }

        return new self(
            $data['key'],
            array_values(array_filter($data['problems'], 'is_string')),
            ($data['skipped'] ?? false) === true
        );
    }
}

src/Integrations/Divi/Release/CheckReport.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/** The result of one admin check (spec §6.3); a release needs a fresh green one. */
final class CheckReport
{
    /** @param list<CheckStep> $steps */
    public function __construct(
        public readonly string $id,
        public readonly string $createdAt,
        public readonly int $userId,
        public readonly string $diviVersion,
        public readonly string $pluginVersion,
        public readonly string $stateKey,
        public readonly array $steps
    ) {
    }

    public function green(): bool
    {
        if ($this->steps === []) {
            return false;
        }
        foreach ($this->steps as $step) {
            if (! $step->green()) {
                return false;
            }
        }

        return true;
    }

    /** Green and made for exactly this Divi version, plugin version and Divi files (ReleaseResolver::stateKey()). */
    public function freshFor(string $diviVersion, string $pluginVersion, string $stateKey): bool
    {
        return $this->green()
            && $this->diviVersion === $diviVersion
            && $this->pluginVersion === $pluginVersion
            && $this->stateKey === $stateKey;
    }

    public function step(string $key): ?CheckStep
    {
        foreach ($this->steps as $step) {
            if ($step->key === $key) {
                return $step;
            }
        }

        return null;
    }

    /** @return array<string, mixed> */
    public function toArray(): array
    {
        return [
            'id' => $this->id,
            'created_at' => $this->createdAt,
            'user_id' => $this->userId,
            'divi_version' => $this->diviVersion,
            'plugin_version' => $this->pluginVersion,
            'state_key' => $this->stateKey,
            'steps' => array_map(static fn (CheckStep $step): array => $step->toArray(), $this->steps),
        ];
    }

    public static function fromArray(mixed $data): ?self
    {
        if (! is_array($data)) {
            return null;
        }
        foreach (['id', 'created_at', 'divi_version', 'plugin_version', 'state_key'] as $key) {
            if (! is_string($data[$key] ?? null)) {
                return null;
            }
        }
        if (! is_int($data['user_id'] ?? null) || ! is_array($data['steps'] ?? null)) {
            return null;
        }

        $steps = [];
        foreach ($data['steps'] as $step) {
            $parsed = CheckStep::fromArray($step);
            if ($parsed === null) {
                return null;
            }
            $steps[] = $parsed;
        }

        return new self(
            $data['id'],
            $data['created_at'],
            $data['user_id'],
            $data['divi_version'],
            $data['plugin_version'],
            $data['state_key'],
            $steps
        );
    }
}

src/Integrations/Divi/Release/CheckReportStore.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/** The latest admin check report of this site (spec §6.3). Not final so the Divi tab can be tested with a mock. */
class CheckReportStore
{
    public const OPTION = 'lit_mcp_divi_check_report';

    public function latest(): ?CheckReport
    {
        return CheckReport::fromArray(get_option(self::OPTION, null));
    }

    public function save(CheckReport $report): void
    {
        update_option(self::OPTION, $report->toArray(), false);
    }
}

src/Integrations/Divi/Release/CompatibilityCheck.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use Closure;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Plugin;
use Throwable;

defined('ABSPATH') || exit;

/**
 * The admin check (spec §6.2) – also wp lit-mcp divi-check: manifests (code and registrations), fingerprints,
 * module catalog, format round trip and the write test. A step that throws is a red step; the write test is skipped
 * only when Divi functions are missing (it would fail anyway), not when only fingerprints changed – so
 * --update-reference can see whether everything else still works. Not final so the route and the command can be
 * tested with a mock.
 */
class CompatibilityCheck
{
    /**
     * @param Closure(): list<string>  $catalog  Short-form problems against Divi's live catalog.
     * @param Closure(): string        $version  Installed Divi version ('' = unknown).
     * @param Closure(string): string  $stateKey ReleaseResolver::stateKey().
     */
    public function __construct(
        private ManifestCheck $manifests,
        private Closure $catalog,
        private FormatCheck $format,
        private WriteProbe $probe,
        private Closure $version,
        private Closure $stateKey
    ) {
    }

    public static function create(ContentComposer $composer): self
    {
        return new self(
            new ManifestCheck(),
            static fn (): array => DiviModules::isAvailable()
                ? ShortformCheck::problems((new DiviModules())->catalog())
                : ['Divi 5 ist in dieser Anfrage nicht geladen.'],
            new FormatCheck(),
            new WriteProbe($composer),
            static fn (): string => DiviEnvironment::version() ?? '',
            static fn (string $version): string => ReleaseResolver::stateKey($version)
        );
    }

    public function run(int $userId): CheckReport
    {
        $manifest = self::guarded(fn (): array => ManifestCheck::failed(
            array_merge($this->manifests->codeChecks(), $this->manifests->registrationChecks())
        ));
        $steps = [
            new CheckStep(CheckStep::MANIFEST, $manifest),
            new CheckStep(CheckStep::MIRRORS, self::guarded(
                fn (): array => ManifestCheck::failed($this->manifests->mirrorChecks())
            )),
            new CheckStep(CheckStep::CATALOG, self::guarded($this->catalog)),
            new CheckStep(CheckStep::FORMAT, self::guarded(fn (): array => $this->format->problems())),
            $manifest === []
                ? new CheckStep(CheckStep::WRITE, self::guarded(fn (): array => $this->probe->run()))
                : new CheckStep(CheckStep::WRITE, [], true),
        ];
        $version = ($this->version)();

        return new CheckReport(
            wp_generate_uuid4(),
            gmdate('c'),
            $userId,
            $version,
            Plugin::VERSION,
            ($this->stateKey)($version),
            $steps
        );
    }

    /** @return list<string> */
    private static function guarded(Closure $step): array
    {
        try {
            return array_values(array_filter((array) $step(), 'is_string'));
        } catch (Throwable $e) {
            return ['Prüfschritt abgebrochen: ' . $e->getMessage()];
        }
    }
}

FormatCheck, WriteProbe, CheckReportStore und CompatibilityCheck sind nicht final (Mocks in den Tests), ManifestCheck seit Task 9 ebenfalls nicht.

  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'ShortformCheckTest|FormatCheckTest|WriteProbeTest|CheckReportTest|CompatibilityCheckTest' Expected: PASS.

  • Step 5: Prüfung in DDEV gegen echtes Divi (ohne Oberfläche)
cd /home/janniclas/Projekte/WordPress && ddev wp --user="$(ddev wp user list --role=administrator --field=user_login | head -1)" eval '
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
$divi = new LIT\MCP\Integrations\Divi\Pages\DiviContentComposer(
    new LIT\MCP\Integrations\WordPress\Schema\ContentSchema([]),
    new LIT\MCP\Integrations\Divi\Pages\DiviComposition(new LIT\MCP\Integrations\Divi\Schema\DiviModules(), new LIT\MCP\Integrations\Divi\Design\DesignSystemReader()),
    new LIT\MCP\Integrations\Divi\Pages\DiviSaver(),
    new LIT\MCP\Integrations\Divi\Design\DesignSystemReader()
);
echo wp_json_encode(CompatibilityCheck::create($divi)->run(get_current_user_id())->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);'
ddev wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count

Expected: alle fünf Schritte mit "problems": [] und "skipped": false; danach 0 Prüfseiten. Meldet der Schreibtest eine abgelehnte Kurzform, die Fehlermeldung lesen und WriteProbe::SECTIONS an die Kurzform anpassen (Guide docs/guides/divi-shortform.md), Unit-Tests erneut laufen lassen. Meldet er fehlende Texte, die gerenderte Seite unter WP-CLI prüfen – der endgültige Nachweis im REST-Kontext folgt im Smoke (Task 15).

  • Step 6: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün. Commit wie in den Global Constraints mit Nachricht feat: admin check steps with throwaway write test and reports.


Task 13: REST-Route der Prüfung, Reiter „Divi“, Verdrahtung, Deinstallation

Files:

  • Create: src/Integrations/Divi/Release/DiviCheckRoute.php, src/Admin/DiviTab.php, tests/Stubs/WpRestResponse.php
  • Modify: src/Admin/SettingsPage.php, src/Plugin.php, src/Uninstaller.php, uninstall.php (Docblock), tests/bootstrap.php
  • Test: tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php, tests/Unit/Admin/DiviTabTest.php, tests/Unit/Admin/SettingsPageTest.php, tests/Unit/UninstallerTest.php

Interfaces:

  • Consumes: CompatibilityCheck::create()/run(), CheckReportStore, CheckReport::freshFor() (Task 12); ReleaseStore, ReleaseGate, ReleaseResolver::stateKey()/CACHE, DiviEnvironment::version() (Tasks 8/9); WriteProbe::removeOrphans(); AdminPostAction, SettingsPage::url(), AdminTab; DiviIntegration::contentComposer().

  • Produces: DiviCheckRoute::NAMESPACE = 'lit-mcp/v1', ROUTE = '/divi-check', LOCK = 'lit_mcp_divi_check_lock', __construct(Closure $check, ?CheckReportStore $reports = null), register(), registerRoute(), handle(): WP_REST_Response, 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

declare(strict_types=1);

if (! class_exists('WP_REST_Response')) {
    class WP_REST_Response
    {
        /** @param array<string, string> $headers */
        public function __construct(public mixed $data = null, public int $status = 200, public array $headers = [])
        {
        }

        public function get_status(): int
        {
            return $this->status;
        }

        /** @return array<string, string> */
        public function get_headers(): array
        {
            return $this->headers;
        }
    }
}

In tests/bootstrap.php nach der WpRestRequest-Zeile: require_once __DIR__ . '/Stubs/WpRestResponse.php';.

tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class DiviCheckRouteTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://k.de/wp-admin/' . $path);
        Functions\when('add_query_arg')->alias(
            static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
        );
        Functions\when('get_current_user_id')->justReturn(1);
    }

    public function testRegistersAPostRouteForAdministratorsOnly(): void
    {
        Functions\expect('register_rest_route')->once()->with(
            'lit-mcp/v1',
            '/divi-check',
            Mockery::on(static function (array $args): bool {
                Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'manage_options');
                $allowed = ($args['permission_callback'])();
                Functions\when('current_user_can')->justReturn(false);

                return $args['methods'] === 'POST' && $allowed === true && ($args['permission_callback'])() === false;
            })
        );

        (new DiviCheckRoute(static fn () => null))->registerRoute();
    }

    public function testRunsTheCheckStoresTheReportAndRedirectsToTheTab(): void
    {
        $report = new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', []);
        $check = Mockery::mock(CompatibilityCheck::class);
        $check->expects('run')->with(1)->andReturn($report);
        $reports = Mockery::mock(CheckReportStore::class);
        $reports->expects('save')->with($report);
        Functions\expect('add_option')->once()->with(DiviCheckRoute::LOCK, Mockery::type('int'), '', false)->andReturn(true);
        Functions\expect('delete_option')->once()->with(DiviCheckRoute::LOCK);

        $response = (new DiviCheckRoute(static fn () => $check, $reports))->handle();

        self::assertSame(303, $response->get_status());
        self::assertStringContainsString('tab=divi', $response->get_headers()['Location']);
        self::assertStringContainsString('lit_mcp_notice=divi_checked', $response->get_headers()['Location']);
    }

    public function testASecondCheckWaitsForTheFirst(): void
    {
        Functions\when('add_option')->justReturn(false);
        Functions\when('get_option')->alias(static fn (): int => time());
        Functions\expect('delete_option')->never();
        $reports = Mockery::mock(CheckReportStore::class);
        $reports->expects('save')->never();

        $response = (new DiviCheckRoute(static fn () => self::fail('no second check'), $reports))->handle();

        self::assertStringContainsString('lit_mcp_notice=divi_check_running', $response->get_headers()['Location']);
    }

    public function testAStaleLockIsTakenOver(): void
    {
        Functions\when('add_option')->alias(static function (): bool {
            static $calls = 0;

            return ++$calls > 1;
        });
        Functions\when('get_option')->justReturn(1);
        Functions\expect('delete_option')->twice()->with(DiviCheckRoute::LOCK);
        $check = Mockery::mock(CompatibilityCheck::class);
        $check->expects('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', []));
        $reports = Mockery::mock(CheckReportStore::class);
        $reports->expects('save');

        (new DiviCheckRoute(static fn () => $check, $reports))->handle();
    }
}

tests/Unit/Admin/DiviTabTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Admin;

use Brain\Monkey\Functions;
use LIT\MCP\Admin\AdminPostAction;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class DiviTabTest extends TestCase
{
    private ?string $redirect = null;

    protected function setUp(): void
    {
        parent::setUp();
        $this->redirect = null;
        Functions\stubEscapeFunctions();
        Functions\stubTranslationFunctions();
        Functions\when('current_user_can')->justReturn(true);
        Functions\when('check_admin_referer')->justReturn(1);
        Functions\when('get_current_user_id')->justReturn(1);
        Functions\when('wp_safe_redirect')->alias(function (string $url): bool {
            $this->redirect = $url;

            return true;
        });
        Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://k.de/wp-admin/' . $path);
        Functions\when('add_query_arg')->alias(
            static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
        );
    }

    private static function report(bool $green = true, string $version = '5.3.0', string $key = 'key'): CheckReport
    {
        $steps = array_map(
            static fn (string $step): CheckStep => new CheckStep($step, $green ? [] : ['rot']),
            [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
        );

        return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, $version, Plugin::VERSION, $key, $steps);
    }

    private function tab(?CheckReport $report, ReleaseStore $releases, ?FakeChangeLog $log = null): DiviTab
    {
        $reports = Mockery::mock(CheckReportStore::class);
        $reports->allows('latest')->andReturn($report);

        return new DiviTab(
            $reports,
            $releases,
            $log ?? FakeChangeLog::forbidden(),
            new AdminPostAction(static function (): void {
            }),
            static fn (): string => '5.3.0',
            static fn (string $version): string => 'key'
        );
    }

    public function testReleaseNeedsAFreshGreenReport(): void
    {
        foreach ([null, self::report(false), self::report(true, '5.2.9'), self::report(true, '5.3.0', 'old')] as $report) {
            $releases = Mockery::mock(ReleaseStore::class);
            $releases->expects('add')->never();

            $this->tab($report, $releases)->release();

            self::assertStringContainsString('lit_mcp_notice=divi_release_refused', (string) $this->redirect);
        }
    }

    public function testReleaseStoresTheEntryAndLogsIt(): void
    {
        $releases = Mockery::mock(ReleaseStore::class);
        $releases->expects('add')->once()->with('5.3.0', Plugin::VERSION, 1, 'r1');
        $log = new FakeChangeLog();

        $this->tab(self::report(), $releases, $log)->release();

        self::assertStringContainsString('lit_mcp_notice=divi_released', (string) $this->redirect);
        self::assertSame('lit/divi-release', $log->records[0]['ability']);
        self::assertStringContainsString('Divi 5.3.0', $log->records[0]['summary']);
    }

    public function testWithdrawRemovesTheEntryOfTheInstalledVersion(): void
    {
        $releases = Mockery::mock(ReleaseStore::class);
        $releases->expects('withdraw')->once()->with('5.3.0', Plugin::VERSION)->andReturn(true);
        $log = new FakeChangeLog();

        $this->tab(null, $releases, $log)->withdraw();

        self::assertStringContainsString('lit_mcp_notice=divi_withdrawn', (string) $this->redirect);
        self::assertSame('lit/divi-release', $log->records[0]['ability']);
    }

    public function testActionsNeedNonceAndManageOptions(): void
    {
        Functions\when('current_user_can')->justReturn(false);
        Functions\expect('wp_die')->twice()->andReturnUsing(static function (): never {
            throw new \RuntimeException('died');
        });
        $releases = Mockery::mock(ReleaseStore::class);
        $releases->expects('add')->never();
        $releases->expects('withdraw')->never();
        $tab = $this->tab(self::report(), $releases);

        foreach (['release', 'withdraw'] as $method) {
            try {
                $tab->{$method}();
                self::fail($method . ' without manage_options');
            } catch (\RuntimeException $e) {
                self::assertSame('died', $e->getMessage());
            }
        }

    }

    public function testReleaseChecksTheNonce(): void
    {
        Functions\when('check_admin_referer')->alias(static function (string $action): never {
            throw new \RuntimeException('bad nonce for ' . $action);
        });
        $releases = Mockery::mock(ReleaseStore::class);
        $releases->expects('add')->never();

        $this->expectExceptionMessage('bad nonce for lit_mcp_divi_release');
        $this->tab(self::report(), $releases)->release();
    }

    /** @return string The rendered tab. */
    private function rendered(ReleaseStatus $status, ?CheckReport $report): string
    {
        ReleaseGate::useResolver(static fn (): ReleaseStatus => $status);
        Functions\when('get_posts')->justReturn([]);
        Functions\when('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check');
        Functions\when('wp_create_nonce')->justReturn('n');
        Functions\when('wp_nonce_field')->justReturn('');
        Functions\when('submit_button')->alias(static function (string $text): void {
            echo '<button>' . $text . '</button>';
        });
        Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan']);
        Functions\when('date_i18n')->justReturn('01.10.2026 10:00');
        Functions\when('get_option')->justReturn('d.m.Y');

        ob_start();
        $this->tab($report, Mockery::mock(ReleaseStore::class))->render();

        return (string) ob_get_clean();
    }

    public function testUntestedWithAFreshGreenReportOffersTheRelease(): void
    {
        $html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report());

        self::assertStringContainsString('ungetestet', $html);
        self::assertStringContainsString('Prüfung starten', $html);
        self::assertStringContainsString('Divi 5.3.0 freigeben', $html);
        self::assertStringContainsString('name="_wpnonce" value="n"', $html);
    }

    public function testUntestedWithAStaleReportAsksForANewCheck(): void
    {
        $html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report(true, '5.2.9'));

        self::assertStringNotContainsString('Divi 5.3.0 freigeben', $html);
        self::assertStringContainsString('Prüfung erneut ausführen', $html);
    }

    public function testReleasedFromCodeOffersNeitherReleaseNorWithdrawal(): void
    {
        $html = $this->rendered(ReleaseStatus::released('5.3.0'), self::report());

        self::assertStringContainsString('freigegeben', $html);
        self::assertStringContainsString('im Plugin als getestet hinterlegt', $html);
        self::assertStringNotContainsString('freigeben</button>', $html);
        self::assertStringNotContainsString('Freigabe zurückziehen', $html);
    }

    public function testADatabaseReleaseCanBeWithdrawn(): void
    {
        $status = new ReleaseStatus(ReleaseState::Released, '5.3.0', ReleaseStatus::SOURCE_DATABASE, [], [
            'released_at' => '2026-10-01T08:00:00+00:00', 'user_id' => 1,
        ]);

        self::assertStringContainsString('Freigabe zurückziehen', $this->rendered($status, self::report()));
    }

    public function testIncompatibleListsTheProblems(): void
    {
        $html = $this->rendered(
            new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['Theme Builder: Nachbau von x() – y']),
            null
        );

        self::assertStringContainsString('inkompatibel', $html);
        self::assertStringContainsString('Theme Builder: Nachbau von x() – y', $html);
        self::assertStringContainsString('Plugin-Update', $html);
    }
}

In tests/Unit/Admin/SettingsPageTest.php ergänzen (Aufbau wie testHeaderEndMarkerSitsBetweenTitleAndTabsWithNoticesAfterIt):

    /** @dataProvider diviNotices */
    public function testDiviNoticesAreShown(string $key, string $class, string $text): void
    {
        Functions\when('current_user_can')->justReturn(true);
        $_GET['lit_mcp_notice'] = $key;

        ob_start();
        (new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->render();
        $html = (string) ob_get_clean();

        self::assertStringContainsString('notice-' . $class, $html);
        self::assertStringContainsString($text, $html);
    }

    /** @return list<list<string>> */
    public static function diviNotices(): array
    {
        return [
            ['divi_checked', 'success', 'Divi-Prüfung abgeschlossen'],
            ['divi_check_running', 'warning', 'bereits eine Divi-Prüfung'],
            ['divi_released', 'success', 'für LIT MCP freigegeben'],
            ['divi_release_refused', 'error', 'Prüfung erneut ausführen'],
            ['divi_withdrawn', 'success', 'Freigabe zurückgezogen'],
            ['divi_withdraw_failed', 'error', 'keine Freigabe'],
        ];
    }

In tests/Unit/UninstallerTest.php: in beiden Tests delete_transient mit Functions\when('delete_transient')->alias(...) sammeln statt expect(...)->once(), und erwarten [ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE] (einmal je Website); die erwartete Optionsliste des ersten Tests wird [Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK].

  • Step 2: Run tests to verify they fail

Run: vendor/bin/phpunit --filter 'DiviCheckRouteTest|DiviTabTest|SettingsPageTest|UninstallerTest|UninstallTest' Expected: FAIL.

  • Step 3: Implementation

src/Integrations/Divi/Release/DiviCheckRoute.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use Closure;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Admin\SettingsPage;
use WP_REST_Response;

defined('ABSPATH') || exit;

/**
 * "Prüfung starten" of the Divi tab (spec §6.1): POST /wp-json/lit-mcp/v1/divi-check. A REST request on purpose –
 * not admin-post – so the check runs in the same context as MCP calls (Divi renders its blocks in REST requests,
 * lit-page-render-summary). The form sends the wp_rest nonce as _wpnonce (cookie authentication); only manage_options.
 * One check at a time: the option LOCK (add_option is atomic); a lock older than LOCK_TTL is a dead check's and taken
 * over. Answers 303 back to the tab.
 */
final class DiviCheckRoute
{
    public const NAMESPACE = 'lit-mcp/v1';
    public const ROUTE = '/divi-check';
    public const LOCK = 'lit_mcp_divi_check_lock';

    private const LOCK_TTL = 600;

    /** @param Closure(): CompatibilityCheck $check */
    public function __construct(private Closure $check, private ?CheckReportStore $reports = null)
    {
    }

    public function register(): void
    {
        add_action('rest_api_init', [$this, 'registerRoute']);
    }

    public function registerRoute(): void
    {
        register_rest_route(self::NAMESPACE, self::ROUTE, [
            'methods' => 'POST',
            'callback' => [$this, 'handle'],
            'permission_callback' => static fn (): bool => current_user_can('manage_options'),
        ]);
    }

    public static function url(): string
    {
        return rest_url(self::NAMESPACE . self::ROUTE);
    }

    public function handle(): WP_REST_Response
    {
        $notice = 'divi_check_running';
        if (self::lock()) {
            try {
                $report = ($this->check)()->run(get_current_user_id());
                ($this->reports ?? new CheckReportStore())->save($report);
                ReleaseGate::forget();
                $notice = 'divi_checked';
            } finally {
                delete_option(self::LOCK);
            }
        }

        return new WP_REST_Response(
            null,
            303,
            ['Location' => SettingsPage::url(DiviTab::SLUG, ['lit_mcp_notice' => $notice])]
        );
    }

    private static function lock(): bool
    {
        if (add_option(self::LOCK, time(), '', false)) {
            return true;
        }

        $since = (int) get_option(self::LOCK, 0);
        if ($since > 0 && time() - $since < self::LOCK_TTL) {
            return false;
        }
        delete_option(self::LOCK);

        return (bool) add_option(self::LOCK, time(), '', false);
    }
}

src/Admin/DiviTab.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

use Closure;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin;

defined('ABSPATH') || exit;

/**
 * Tab "Divi" (spec §6): release status of the installed Divi, the latest check report, "Prüfung starten" (REST,
 * DiviCheckRoute), "Divi <Version> freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (only
 * for a database release). Opening the tab removes check pages a dead check left behind.
 */
final class DiviTab implements AdminTab
{
    public const SLUG = 'divi';
    public const RELEASE_ACTION = 'lit_mcp_divi_release';
    public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw';

    private const STATES = [
        'released' => 'freigegeben',
        'untested' => 'ungetestet',
        'incompatible' => 'inkompatibel',
    ];

    /**
     * @param (Closure(): string)|null        $version  Installed Divi version ('' = none); null = DiviEnvironment.
     * @param (Closure(string): string)|null  $stateKey null = ReleaseResolver::stateKey().
     */
    public function __construct(
        private ?CheckReportStore $reports = null,
        private ?ReleaseStore $releases = null,
        private ?ChangeLog $log = null,
        private ?AdminPostAction $action = null,
        private ?Closure $version = null,
        private ?Closure $stateKey = null
    ) {
    }

    public function slug(): string
    {
        return self::SLUG;
    }

    public function label(): string
    {
        return __('Divi', 'wp-lit-mcp');
    }

    public function registerSettings(): void
    {
    }

    public function registerActions(): void
    {
        add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']);
        add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
    }

    public function release(): void
    {
        $action = $this->action ?? new AdminPostAction();
        $action->guard();
        check_admin_referer(self::RELEASE_ACTION);

        $version = $this->version();
        $report = $this->reports()->latest();
        if ($version === '' || $report === null || ! $report->freshFor($version, Plugin::VERSION, $this->stateKey($version))) {
            $action->redirect(self::SLUG, 'divi_release_refused');

            return;
        }

        ($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id);
        ReleaseGate::forget();
        $this->log?->record(
            'lit/divi-release',
            'divi',
            $version,
            sprintf('Divi %s für LIT MCP %s freigegeben (Prüfbericht %s).', $version, Plugin::VERSION, $report->id)
        );
        $action->redirect(self::SLUG, 'divi_released');
    }

    public function withdraw(): void
    {
        $action = $this->action ?? new AdminPostAction();
        $action->guard();
        check_admin_referer(self::WITHDRAW_ACTION);

        $version = $this->version();
        if (! ($this->releases ?? new ReleaseStore())->withdraw($version, Plugin::VERSION)) {
            $action->redirect(self::SLUG, 'divi_withdraw_failed');

            return;
        }

        ReleaseGate::forget();
        $this->log?->record('lit/divi-release', 'divi', $version, sprintf('Freigabe von Divi %s zurückgezogen.', $version));
        $action->redirect(self::SLUG, 'divi_withdrawn');
    }

    public function render(): void
    {
        $removed = WriteProbe::removeOrphans();
        $status = ReleaseGate::status();
        $report = $this->reports()->latest();

        echo '<h2>' . esc_html__('Divi-Freigabe', 'wp-lit-mcp') . '</h2>';
        echo '<p>' . esc_html__(
            'LIT MCP schreibt nur mit Divi-Versionen, die geprüft sind. Nach einem Divi-Update hier die Prüfung '
                . 'starten und die Version freigeben; bis dahin lesen die Divi-Werkzeuge nur.',
            'wp-lit-mcp'
        ) . '</p>';
        if ($removed !== []) {
            printf(
                '<div class="notice notice-warning inline"><p>%s</p></div>',
                esc_html(sprintf(
                    /* translators: %s: comma separated page IDs */
                    __('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'),
                    implode(', ', $removed)
                ))
            );
        }

        $this->renderStatus($status);
        $this->renderForms($status, $report);
        if ($report !== null) {
            $this->renderReport($report);
        }
    }

    private function renderStatus(ReleaseStatus $status): void
    {
        echo '<table class="form-table" role="presentation"><tbody>';
        printf(
            '<tr><th scope="row">%s</th><td>%s</td></tr>',
            esc_html__('Divi-Version', 'wp-lit-mcp'),
            esc_html($status->version === '' ? __('nicht erkannt', 'wp-lit-mcp') : $status->version)
        );
        printf(
            '<tr><th scope="row">%s</th><td><strong>%s</strong> – %s</td></tr>',
            esc_html__('Zustand', 'wp-lit-mcp'),
            esc_html(self::STATES[$status->state->value]),
            esc_html($this->explanation($status))
        );
        if ($status->problems !== []) {
            echo '<tr><th scope="row">' . esc_html__('Geändert in Divi', 'wp-lit-mcp') . '</th><td><ul>';
            foreach ($status->problems as $problem) {
                echo '<li>' . esc_html($problem) . '</li>';
            }
            echo '</ul></td></tr>';
        }
        echo '</tbody></table>';
    }

    private function explanation(ReleaseStatus $status): string
    {
        if ($status->state === ReleaseState::Incompatible) {
            return __('Divi hat Stellen geändert, die LIT MCP nutzt. Schreiben bleibt gesperrt, bis ein Plugin-Update '
                . 'von LIT MCP eingespielt ist.', 'wp-lit-mcp');
        }
        if ($status->state === ReleaseState::Untested) {
            return __('Diese Version ist nicht geprüft. Die Divi-Werkzeuge lesen nur, bis sie freigegeben ist.', 'wp-lit-mcp');
        }
        if ($status->source === ReleaseStatus::SOURCE_CODE) {
            return __('Diese Version ist im Plugin als getestet hinterlegt.', 'wp-lit-mcp');
        }

        $user = get_userdata((int) ($status->release['user_id'] ?? 0));

        return sprintf(
            /* translators: 1: date, 2: user name */
            __('Auf dieser Website freigegeben am %1$s von %2$s.', 'wp-lit-mcp'),
            date_i18n((string) get_option('date_format'), (int) strtotime((string) ($status->release['released_at'] ?? ''))),
            is_object($user) && isset($user->display_name) ? (string) $user->display_name : '?'
        );
    }

    private function renderForms(ReleaseStatus $status, ?CheckReport $report): void
    {
        printf('<form method="post" action="%s">', esc_url(DiviCheckRoute::url()));
        printf('<input type="hidden" name="_wpnonce" value="%s">', esc_attr(wp_create_nonce('wp_rest')));
        submit_button(__('Prüfung starten', 'wp-lit-mcp'), 'secondary', 'lit-mcp-divi-check', false);
        echo '</form>';

        if ($status->state === ReleaseState::Untested && $status->version !== '') {
            if ($report !== null && $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version))) {
                $this->actionForm(
                    self::RELEASE_ACTION,
                    /* translators: %s: Divi version */
                    sprintf(__('Divi %s freigeben', 'wp-lit-mcp'), $status->version),
                    'primary'
                );
            } else {
                echo '<p>' . esc_html__(
                    'Freigeben geht erst nach einer vollständig grünen Prüfung dieser Divi-Version – Prüfung erneut '
                        . 'ausführen.',
                    'wp-lit-mcp'
                ) . '</p>';
            }
        }

        if ($status->state === ReleaseState::Released && $status->source === ReleaseStatus::SOURCE_DATABASE) {
            $this->actionForm(self::WITHDRAW_ACTION, __('Freigabe zurückziehen', 'wp-lit-mcp'), 'secondary');
        }
    }

    private function actionForm(string $action, string $label, string $type): void
    {
        printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
        printf('<input type="hidden" name="action" value="%s">', esc_attr($action));
        wp_nonce_field($action);
        submit_button($label, $type, 'submit', false);
        echo '</form>';
    }

    private function renderReport(CheckReport $report): void
    {
        printf(
            '<h3>%s</h3><p>%s</p><ul class="lit-mcp-divi-report">',
            esc_html__('Letzte Prüfung', 'wp-lit-mcp'),
            esc_html(sprintf(
                /* translators: 1: Divi version, 2: plugin version, 3: date */
                __('Divi %1$s mit LIT MCP %2$s, %3$s', 'wp-lit-mcp'),
                $report->diviVersion,
                $report->pluginVersion,
                date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt))
            ))
        );
        foreach ($report->steps as $step) {
            $mark = $step->skipped ? '–' : ($step->green() ? '✓' : '✗');
            printf('<li>%s %s', esc_html($mark), esc_html($step->label()));
            if ($step->skipped) {
                echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')';
            }
            if ($step->problems !== []) {
                echo '<ul>';
                foreach ($step->problems as $problem) {
                    echo '<li>' . esc_html($problem) . '</li>';
                }
                echo '</ul>';
            }
            echo '</li>';
        }
        echo '</ul><p class="description">' . esc_html__(
            'Optional: eine Divi-Seite im Visual Builder öffnen und speichern.',
            'wp-lit-mcp'
        ) . '</p>';
    }

    private function version(): string
    {
        return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')();
    }

    private function stateKey(string $version): string
    {
        return ($this->stateKey ?? static fn (string $v): string => ReleaseResolver::stateKey($v))($version);
    }

    private function reports(): CheckReportStore
    {
        return $this->reports ?? new CheckReportStore();
    }
}

src/Admin/SettingsPage.php, in renderNotice() in den match vor default:

            'divi_checked' => ['success', __('Divi-Prüfung abgeschlossen; das Ergebnis steht unten.', 'wp-lit-mcp')],
            'divi_check_running' => [
                'warning',
                __('Es läuft bereits eine Divi-Prüfung; bitte in ein paar Minuten erneut versuchen.', 'wp-lit-mcp'),
            ],
            'divi_released' => ['success', __('Die Divi-Version ist für LIT MCP freigegeben.', 'wp-lit-mcp')],
            'divi_release_refused' => [
                'error',
                __('Freigabe nicht möglich: Der letzte Prüfbericht ist nicht grün oder passt nicht mehr zur installierten '
                    . 'Divi- oder Plugin-Version. Bitte die Prüfung erneut ausführen.', 'wp-lit-mcp'),
            ],
            'divi_withdrawn' => [
                'success',
                __('Freigabe zurückgezogen; schreibende Divi-Werkzeuge sind wieder gesperrt.', 'wp-lit-mcp'),
            ],
            'divi_withdraw_failed' => [
                'error',
                __('Für die installierte Divi-Version gibt es auf dieser Website keine Freigabe.', 'wp-lit-mcp'),
            ],

src/Plugin.php – Importe LIT\MCP\Admin\DiviTab, LIT\MCP\Integrations\Divi\Release\CompatibilityCheck, LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; nach dem Aufbau von $registry:

        // Spec §6: the admin check runs as a REST request (same context as MCP calls).
        $diviCheck = static fn (): CompatibilityCheck => CompatibilityCheck::create($divi->contentComposer());
        (new DiviCheckRoute($diviCheck))->register();

und im Tab-Array nach new LimitsTab(),: new DiviTab(log: $changeLog),.

src/Uninstaller.php: Importe der Klassen sind im Deinstallations-Pfad nicht garantiert (Autoloader, siehe Docblock von uninstall.php) – deshalb Literale:

    private const OPTIONS = [
        'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version',
        'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
    ];
    private const TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state'];

und in removeSiteData() delete_transient(self::TRANSIENT); → foreach (self::TRANSIENTS as $transient) { delete_transient($transient); }. Klassen-Docblock (Liste „Removed“) und uninstall.php-Docblock um die neuen Optionen und den Transient ergänzen. UninstallerTest vergleicht die Literale mit den Konstanten ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK, ReleaseResolver::CACHE (Test hält sie synchron).

  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'DiviCheckRouteTest|DiviTabTest|SettingsPageTest|UninstallerTest|UninstallTest' Expected: PASS.

  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün. Commit wie in den Global Constraints mit Nachricht feat: Divi tab with REST check, release and withdrawal.


Task 14: WP-CLI wp lit-mcp divi-check

Files:

  • Create: src/Integrations/Divi/Release/LineDiff.php, src/Cli/DiviCheckCommand.php
  • Modify: src/Plugin.php
  • Test: tests/Unit/Integrations/Divi/Release/LineDiffTest.php, tests/Unit/Cli/DiviCheckCommandTest.php

Interfaces:

  • Consumes: CompatibilityCheck, CheckReport, CheckStep, CheckReportStore (Task 12); DiviReference, DiviEnvironment, SourceFingerprint, DiviAdapters (Tasks 1/2/8); ReleaseGate::forget(), ReleaseResolver::CACHE.

  • Produces: LineDiff::lines(array $old, array $new, int $context = 2): list<string>; DiviCheckCommand::__construct(Closure $check, ?CheckReportStore $reports = null, ?Closure $out = null, ?Closure $themeDir = null, ?string $expectedFile = null, ?Closure $sourceDir = null), run(array $assoc): int, __invoke(array $args, array $assoc): void, statisch register(Closure $check): void.

  • Step 1: Write the failing tests

tests/Unit/Integrations/Divi/Release/LineDiffTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use LIT\MCP\Integrations\Divi\Release\LineDiff;
use PHPUnit\Framework\TestCase;

final class LineDiffTest extends TestCase
{
    public function testShowsChangedLinesWithTwoLinesOfContext(): void
    {
        $old = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'];
        $new = ['a', 'b', 'c', 'd', 'E', 'f', 'g', 'h', 'i', 'j'];

        self::assertSame(
            ['  c', '  d', '- e', '+ E', '  f', '  g', '  h', '  i', '+ j'],
            LineDiff::lines($old, $new)
        );
    }

    public function testMarksAGapBetweenDistantChanges(): void
    {
        $old = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
        $new = ['A', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'J'];

        self::assertSame(['- a', '+ A', '  b', '  c', '…', '  h', '  i', '- j', '+ J'], LineDiff::lines($old, $new));
    }

    public function testEqualInputHasNoLines(): void
    {
        self::assertSame([], LineDiff::lines(['a', 'b'], ['a', 'b']));
    }
}

tests/Unit/Cli/DiviCheckCommandTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Cli;

use Brain\Monkey\Functions;
use LIT\MCP\Cli\DiviCheckCommand;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class DiviCheckCommandTest extends TestCase
{
    /** @var list<string> */
    private array $lines = [];

    private string $themeDir = '';
    private string $sourceDir = '';
    private string $expectedFile = '';

    protected function setUp(): void
    {
        parent::setUp();
        $this->lines = [];
        $base = sys_get_temp_dir() . '/lit-cli-' . uniqid();
        $this->themeDir = $base . '/Divi';
        $this->sourceDir = $base . '/reference';
        mkdir($this->themeDir . '/includes/builder', 0777, true);
        mkdir($this->sourceDir, 0777, true);
        $this->expectedFile = $base . '/fingerprints.json';
        Functions\when('current_user_can')->justReturn(true);
        Functions\when('get_current_user_id')->justReturn(1);
        Functions\when('delete_transient')->justReturn(true);
    }

    protected function tearDown(): void
    {
        exec('rm -rf ' . escapeshellarg(dirname($this->themeDir)));
        parent::tearDown();
    }

    /** @param array<string, list<string>> $problems Step key => problems. */
    private function command(array $problems = []): DiviCheckCommand
    {
        $steps = array_map(
            static fn (string $key): CheckStep => new CheckStep($key, $problems[$key] ?? []),
            [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
        );
        $check = Mockery::mock(CompatibilityCheck::class);
        $check->allows('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', $steps));
        $reports = Mockery::mock(CheckReportStore::class);
        $reports->allows('save');

        return new DiviCheckCommand(
            static fn () => $check,
            $reports,
            function (string $line): void {
                $this->lines[] = $line;
            },
            fn (): ?string => $this->themeDir,
            $this->expectedFile,
            fn (): ?string => $this->sourceDir
        );
    }

    public function testNeedsAnAdministrator(): void
    {
        Functions\when('current_user_can')->justReturn(false);

        self::assertSame(1, $this->command()->run([]));
        self::assertStringContainsString('--user=', implode("\n", $this->lines));
    }

    public function testPrintsTheStepsAndSucceedsWhenGreen(): void
    {
        self::assertSame(0, $this->command()->run([]));
        self::assertContains('✓ Schreibtest auf einer Wegwerfseite', $this->lines);
    }

    public function testFailsWhenAStepIsRed(): void
    {
        self::assertSame(1, $this->command([CheckStep::CATALOG => ['divi/text: unbekanntes Modul']])->run([]));
        self::assertContains('✗ Modulkatalog (Kurzform-Pfade)', $this->lines);
        self::assertContains('    divi/text: unbekanntes Modul', $this->lines);
    }

    public function testUpdateReferenceRefusesWhenMoreThanFingerprintsIsRed(): void
    {
        $code = $this->command([CheckStep::FORMAT => ['kaputt']])->run(['update-reference' => true]);

        self::assertSame(1, $code);
        self::assertFileDoesNotExist($this->expectedFile);
    }

    public function testDiffShowsWhatChangedInAMirroredFunction(): void
    {
        file_put_contents(
            $this->themeDir . '/includes/builder/shortcode-core.php',
            "<?php\nfunction et_pb_submit_layout( \$args ) {\n\t\$a = 1;\n\treturn 2;\n}\n"
        );
        DiviReference::writeExpected(['library-submit-layout' => str_repeat('0', 64)], $this->expectedFile);
        DiviReference::writeSource(
            'library-submit-layout',
            "function et_pb_submit_layout( \$args ) {\n\t\$a = 1;\n\treturn 1;\n}",
            $this->sourceDir
        );

        $this->command([CheckStep::MIRRORS => ['Bibliothek: Nachbau von et_pb_submit_layout() – Terme']])
            ->run(['diff' => true]);

        $output = implode("\n", $this->lines);
        self::assertStringContainsString('library-submit-layout', $output);
        self::assertStringContainsString("- \treturn 1;", $output);
        self::assertStringContainsString("+ \treturn 2;", $output);
    }

    public function testUpdateReferenceWritesNothingWhenAMirrorIsUnreadable(): void
    {
        file_put_contents(
            $this->themeDir . '/includes/builder/shortcode-core.php',
            "<?php\nfunction et_pb_submit_layout( \$args ) {\n\treturn 2;\n}\n"
        );

        $code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]);

        // The other mirrored Divi files are missing in the temp theme.
        self::assertSame(1, $code);
        self::assertStringContainsString('nicht lesbar', implode("\n", $this->lines));
        self::assertFileDoesNotExist($this->expectedFile, 'all or nothing');
    }

    public function testUpdateReferenceWritesFingerprintsAndSources(): void
    {
        $this->writeDivi();

        $code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]);

        self::assertSame(0, $code);
        self::assertSame(
            ['library-submit-layout', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags'],
            array_keys(DiviReference::expected($this->expectedFile))
        );
        self::assertStringStartsWith(
            'function et_pb_submit_layout(',
            (string) DiviReference::readSource('library-submit-layout', $this->sourceDir)
        );
    }

    /** Minimal stand-ins for the five mirrored Divi functions at their real paths. */
    private function writeDivi(): void
    {
        $files = [
            'includes/builder/shortcode-core.php' => "function et_pb_submit_layout( \$args ) {\n\treturn 1;\n}",
            'includes/builder/frontend-builder/theme-builder/api.php' => "function et_theme_builder_api_save() {\n}",
            'includes/builder/frontend-builder/theme-builder/theme-builder.php' =>
                "function et_theme_builder_store_template( \$a, \$b, \$c ) {\n}\n"
                . "function et_theme_builder_load_template_setting_options( \$ids ) {\n}",
            'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php' =>
                "class SyncToServerController {\n\tpublic static function update( \$request ) {\n\t}\n}",
        ];
        foreach ($files as $path => $code) {
            $file = $this->themeDir . '/' . $path;
            if (! is_dir(dirname($file))) {
                mkdir(dirname($file), 0777, true);
            }
            file_put_contents($file, "<?php\n" . $code . "\n");
        }
    }
}
  • Step 2: Run tests to verify they fail

Run: vendor/bin/phpunit --filter 'LineDiffTest|DiviCheckCommandTest' Expected: FAIL.

  • Step 3: Implementation

src/Integrations/Divi/Release/LineDiff.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/** A small line diff (longest common subsequence) for wp lit-mcp divi-check --diff. */
final class LineDiff
{
    /**
     * @param list<string> $old
     * @param list<string> $new
     * @return list<string> "  kept", "- removed", "+ added"; "…" between changes more than 2 × $context lines apart.
     */
    public static function lines(array $old, array $new, int $context = 2): array
    {
        $operations = self::operations($old, $new);
        $keep = [];
        foreach ($operations as $i => [$op]) {
            if ($op === ' ') {
                continue;
            }
            for ($j = max(0, $i - $context); $j <= min(count($operations) - 1, $i + $context); ++$j) {
                $keep[$j] = true;
            }
        }

        $lines = [];
        $last = null;
        foreach ($operations as $i => [$op, $line]) {
            if (! isset($keep[$i])) {
                continue;
            }
            if ($last !== null && $i > $last + 1) {
                $lines[] = '…';
            }
            $lines[] = $op . ' ' . $line;
            $last = $i;
        }

        return $lines;
    }

    /**
     * @param list<string> $a
     * @param list<string> $b
     * @return list<array{0: string, 1: string}>
     */
    private static function operations(array $a, array $b): array
    {
        $n = count($a);
        $m = count($b);
        $lcs = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
        for ($i = $n - 1; $i >= 0; --$i) {
            for ($j = $m - 1; $j >= 0; --$j) {
                $lcs[$i][$j] = $a[$i] === $b[$j] ? $lcs[$i + 1][$j + 1] + 1 : max($lcs[$i + 1][$j], $lcs[$i][$j + 1]);
            }
        }

        $operations = [];
        $i = 0;
        $j = 0;
        while ($i < $n && $j < $m) {
            if ($a[$i] === $b[$j]) {
                $operations[] = [' ', $a[$i]];
                ++$i;
                ++$j;
            } elseif ($lcs[$i + 1][$j] >= $lcs[$i][$j + 1]) {
                $operations[] = ['-', $a[$i++]];
            } else {
                $operations[] = ['+', $b[$j++]];
            }
        }
        while ($i < $n) {
            $operations[] = ['-', $a[$i++]];
        }
        while ($j < $m) {
            $operations[] = ['+', $b[$j++]];
        }

        return $operations;
    }
}

src/Cli/DiviCheckCommand.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Cli;

use Closure;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
use LIT\MCP\Integrations\Divi\Release\LineDiff;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\SourceFingerprint;
use RuntimeException;

defined('ABSPATH') || exit;

/**
 * wp lit-mcp divi-check (spec §7): the admin check from the command line, for a developer checking a new Divi
 * version. Never releases anything – a green version goes into DiviReleases::TESTED by hand.
 */
final class DiviCheckCommand
{
    /** @var Closure(string): void */
    private Closure $out;

    /**
     * @param Closure(): CompatibilityCheck $check
     * @param (Closure(string): void)|null  $out       null = WP_CLI::line().
     * @param (Closure(): ?string)|null     $themeDir  null = DiviEnvironment::themeDir().
     * @param string|null                   $expectedFile null = DiviReference::FILE.
     * @param (Closure(): ?string)|null     $sourceDir null = DiviReference::sourceDir().
     */
    public function __construct(
        private Closure $check,
        private ?CheckReportStore $reports = null,
        ?Closure $out = null,
        private ?Closure $themeDir = null,
        private ?string $expectedFile = null,
        private ?Closure $sourceDir = null
    ) {
        $this->out = $out ?? static function (string $line): void {
            \WP_CLI::line($line);
        };
    }

    /** @param Closure(): CompatibilityCheck $check */
    public static function register(Closure $check): void
    {
        if (defined('WP_CLI') && WP_CLI) {
            \WP_CLI::add_command('lit-mcp divi-check', new self($check));
        }
    }

    /**
     * Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format
     * und Schreibtest auf einer Wegwerfseite. Gibt nichts frei.
     *
     * ## OPTIONS
     *
     * [--diff]
     * : Zeigt für jede geänderte nachgebaute Divi-Funktion den Unterschied zur Referenz.
     *
     * [--update-reference]
     * : Schreibt Soll-Fingerabdrücke und Referenzquellen neu – nur wenn alle übrigen Schritte grün sind.
     *
     * ## EXAMPLES
     *
     *     wp --user=admin lit-mcp divi-check --diff
     *
     * @param list<string>         $args
     * @param array<string, mixed> $assoc
     */
    public function __invoke(array $args, array $assoc): void
    {
        $code = $this->run($assoc);
        if ($code !== 0) {
            \WP_CLI::halt($code);
        }
    }

    /** @param array<string, mixed> $assoc */
    public function run(array $assoc): int
    {
        if (! current_user_can('manage_options')) {
            ($this->out)('Bitte mit --user=<Administrator> ausführen: der Schreibtest legt eine Seite an.');

            return 1;
        }

        $report = ($this->check)()->run(get_current_user_id());
        ($this->reports ?? new CheckReportStore())->save($report);
        ($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->diviVersion ?: '?', $report->pluginVersion));
        foreach ($report->steps as $step) {
            ($this->out)(($step->skipped ? '–' : ($step->green() ? '✓' : '✗')) . ' ' . $step->label());
            foreach ($step->problems as $problem) {
                ($this->out)('    ' . $problem);
            }
        }

        if (isset($assoc['diff'])) {
            $this->diff();
        }
        if (isset($assoc['update-reference'])) {
            return $this->updateReference($report);
        }

        return $report->green() ? 0 : 1;
    }

    private function diff(): void
    {
        $themeDir = ($this->themeDir ?? DiviEnvironment::themeDir(...))();
        $sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))();
        $expected = DiviReference::expected($this->expectedFile);
        foreach (DiviAdapters::mirrors() as $mirror) {
            try {
                $print = SourceFingerprint::of((string) $themeDir . '/' . $mirror->file, $mirror->function, $mirror->class);
            } catch (RuntimeException $e) {
                ($this->out)(sprintf('%s: nicht lesbar (%s)', $mirror->id, $e->getMessage()));
                continue;
            }
            if (($expected[$mirror->id] ?? null) === $print->hash) {
                continue;
            }

            ($this->out)(sprintf('%s: %s, Zeilen %d–%d', $mirror->id, $mirror->file, $print->startLine, $print->endLine));
            $reference = $sourceDir === null ? null : DiviReference::readSource($mirror->id, $sourceDir);
            if ($reference === null) {
                ($this->out)('    keine Referenzquelle (nur in einer Entwicklungskopie mit tests/)');
                continue;
            }
            foreach (LineDiff::lines(explode("\n", rtrim($reference, "\n")), explode("\n", $print->source)) as $line) {
                ($this->out)('    ' . $line);
            }
        }
    }

    private function updateReference(CheckReport $report): int
    {
        foreach ($report->steps as $step) {
            if ($step->key !== CheckStep::MIRRORS && ! $step->green()) {
                ($this->out)('Referenz nicht aktualisiert: nicht nur Fingerabdrücke sind rot (siehe oben).');

                return 1;
            }
        }

        $themeDir = ($this->themeDir ?? DiviEnvironment::themeDir(...))();
        try {
            $prints = DiviReference::current((string) $themeDir);
        } catch (RuntimeException $e) {
            ($this->out)('Referenz nicht aktualisiert – eine nachgebaute Divi-Funktion ist nicht lesbar: ' . $e->getMessage());

            return 1;
        }

        DiviReference::writeExpected(
            array_map(static fn (SourceFingerprint $print): string => $print->hash, $prints),
            $this->expectedFile
        );
        $sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))();
        foreach ($sourceDir === null ? [] : $prints as $id => $print) {
            DiviReference::writeSource($id, $print->source, $sourceDir);
        }
        delete_transient(ReleaseResolver::CACHE);
        ReleaseGate::forget();
        ($this->out)(sprintf('Referenz aktualisiert (%d Fingerabdrücke). Smoke-Skripte laufen lassen, dann committen.', count($prints)));

        return 0;
    }
}

DiviReference::current() wirft beim ersten unlesbaren Mirror; der Befehl schreibt dann nichts (testUpdateReferenceWritesNothingWhenAMirrorIsUnreadable).

src/Plugin.php nach der Registrierung der Route: Import LIT\MCP\Cli\DiviCheckCommand;

        DiviCheckCommand::register($diviCheck);
  • Step 4: Run tests to verify they pass

Run: vendor/bin/phpunit --filter 'LineDiffTest|DiviCheckCommandTest' Expected: PASS.

  • Step 5: In DDEV ausprobieren
cd /home/janniclas/Projekte/WordPress && ddev wp --user="$(ddev wp user list --role=administrator --field=user_login | head -1)" lit-mcp divi-check --diff; echo "exit=$?"

Expected: fünf Zeilen mit ✓, keine Diff-Ausgabe, exit=0. Ohne --user: Hinweis auf --user= und exit=1.

  • Step 6: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün. Commit wie in den Global Constraints mit Nachricht feat: wp lit-mcp divi-check with diff and reference update.


Task 15: Version 0.6.0, Doku, Smoke-Skript, Abnahme

Files:

  • Create: tests/Integration/divi-release-smoke.sh, docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md
  • Modify: wp-lit-mcp.php (Version), src/Plugin.php (VERSION = '0.6.0'), readme.txt, docs/guides/overview.md

Interfaces:

  • Consumes: alles Vorherige.

  • Produces: Release 0.6.0.

  • Step 1: Version und Doku

  • wp-lit-mcp.php: * Version: 0.6.0; src/Plugin.php: public const VERSION = '0.6.0';; readme.txt: Stable tag: 0.6.0.

  • readme.txt, neuer Abschnitt vor == Changelog ==:

== Divi-Freigabe ==

LIT MCP schreibt nur mit Divi-Versionen, die für diese Plugin-Version geprüft sind. Nach einem Divi-Update lesen die Divi-Werkzeuge weiter; schreibende Divi-Werkzeuge antworten mit „divi_untested“, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung startet und die Version freigibt. Die Prüfung legt eine Entwurfsseite an, schreibt und rendert sie und löscht sie danach endgültig. Hat Divi Stellen geändert, die LIT MCP nachbildet, ist die Version „inkompatibel“ (divi_incompatible) – dann hilft nur ein Update von LIT MCP. Für Entwickler: `wp --user=<admin> lit-mcp divi-check [--diff] [--update-reference]`.
  • readme.txt, Changelog:
= 0.6.0 =
* Divi-Absicherung: Divi-Zugriffe gebündelt in Adaptern mit Manifest; Fingerabdrücke der nachgebildeten Divi-Funktionen.
* Ungetestete oder veränderte Divi-Versionen sperren alle schreibenden Divi-Wege (divi_untested / divi_incompatible); lesende Divi-Werkzeuge melden es in warnings.
* Neuer Reiter „Divi“: Prüfung mit Wegwerfseite, Freigabe nach grüner Prüfung, Freigabe zurückziehen.
* lit-site-status zeigt den Freigabe-Zustand (release).
* WP-CLI: wp lit-mcp divi-check mit --diff und --update-reference.
  • docs/guides/overview.md: im Abschnitt zu Fehlern (oder am Ende) ergänzen:
## Divi-Version nicht freigegeben

Antwortet ein schreibendes Divi-Werkzeug mit `divi_untested`, ist die installierte Divi-Version für LIT MCP noch nicht
geprüft; bei `divi_incompatible` hat Divi Stellen geändert, die LIT MCP nutzt. In beiden Fällen wurde nichts
geändert. Lesende Divi-Werkzeuge funktionieren weiter und melden den Zustand in `warnings`; `lit-site-status` zeigt
ihn unter `components` → `divi` → `release`. Den Nutzer bitten, unter Einstellungen → LIT MCP → Divi die Prüfung
auszuführen und die Version freizugeben (`divi_untested`) bzw. LIT MCP zu aktualisieren (`divi_incompatible`).
  • Step 2: Smoke-Skript schreiben

tests/Integration/divi-release-smoke.sh:

#!/usr/bin/env bash
# End-to-end checks of the Divi release gate (spec 2026-09-29-divi-absicherung).
# Runs INSIDE the DDEV web container (Divi 5.2.1 must be the active theme):
#   cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh
# Leaves nothing behind: pages, the check report, the check lock, the state transient and the application password
# are removed again.
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh

ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
CREATED_POSTS=()
BASE=http://localhost

cleanup() {
  for id in "${CREATED_POSTS[@]}"; do
    wp post delete "$id" --force >/dev/null 2>&1 || true
  done
  wp option delete lit_mcp_divi_check_report lit_mcp_divi_check_lock >/dev/null 2>&1 || true
  wp transient delete lit_mcp_divi_state >/dev/null 2>&1 || true
  cleanup_app_passwords_for "$ADMIN_LOGIN"
}
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT

check_pages() { wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count; }

[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"

# 1. Code, registrations and fingerprints match Divi 5.2.1
[ "$(wp eval 'echo wp_json_encode((new LIT\MCP\Integrations\Divi\DiviCompatibility())->problems());')" = "[]" ] \
  || fail "Divi compatibility problems under WP-CLI"
pass "manifests and fingerprints match the installed Divi"

connect "$ADMIN_LOGIN"
STATUS=$(tool lit-site-status '{}')
echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .problems == [] and .release.state == "released" and .release.source == "code"' >/dev/null \
  || fail "site status must show Divi released from code: $STATUS"
pass "lit-site-status: Divi 5.2.1 released (code), no problems in an MCP request"

# 2. Admin check through the REST route (application password instead of the cookie nonce)
APP_PASSWORD=$(wp user application-password create "$ADMIN_LOGIN" lit-mcp-integration-test --porcelain)
HEADERS=$(curl -s -o /dev/null -D - -X POST -H "Host: $HOST" -u "$ADMIN_LOGIN:$APP_PASSWORD" "$BASE/wp-json/lit-mcp/v1/divi-check")
echo "$HEADERS" | grep -qi '^HTTP/[0-9.]* 303' || fail "check route must answer 303: $HEADERS"
echo "$HEADERS" | grep -qi '^location:.*tab=divi.*lit_mcp_notice=divi_checked' || fail "redirect to the tab: $HEADERS"
REPORT=$(wp option get lit_mcp_divi_check_report --format=json)
echo "$REPORT" | jq -e '[.steps[] | (.problems == [] and .skipped == false)] | all and length == 5' >/dev/null \
  || fail "every check step must be green: $REPORT"
[ "$(check_pages)" = "0" ] || fail "the throwaway page must be gone"
[ "$(wp option get lit_mcp_divi_check_lock 2>/dev/null || true)" = "" ] || fail "the check lock must be released"
pass "admin check (REST): five green steps, throwaway page deleted, lock released"

UNAUTH=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Host: $HOST" "$BASE/wp-json/lit-mcp/v1/divi-check")
[ "$UNAUTH" = "401" ] || [ "$UNAUTH" = "403" ] || fail "anonymous check must be refused, got $UNAUTH"
pass "check route refuses anonymous requests"

# 3. Incompatible Divi (simulated: a failed fingerprint in the state cache) locks every write path
wp eval '
$key = LIT\MCP\Integrations\Divi\Release\ReleaseResolver::stateKey((string) LIT\MCP\Integrations\Divi\Release\DiviEnvironment::version());
set_transient(LIT\MCP\Integrations\Divi\Release\ReleaseResolver::CACHE, ["key" => $key, "problems" => ["Smoke: Nachbau geändert"]], 600);'

PAGE_ID=$(wp --user="$ADMIN_LOGIN" post create --post_type=page --post_status=draft --post_title="LIT Freigabe Smoke" --porcelain)
CREATED_POSTS+=("$PAGE_ID")
wp post meta update "$PAGE_ID" _et_pb_use_builder on >/dev/null
wp post meta update "$PAGE_ID" _et_pb_use_divi_5 on >/dev/null
BEFORE=$(wp post get "$PAGE_ID" --field=post_content)

OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}")
echo "$OUTLINE" | jq -e '.warnings | any(test("inkompatibel"))' >/dev/null || fail "read tool must carry the notice: $OUTLINE"
pass "reading Divi tool runs and warns"

INSERT=$(tool lit-divi-insert "{\"post_id\":$PAGE_ID,\"tree_rev\":$(echo "$OUTLINE" | jq '.tree_rev'),\"nodes\":[{\"type\":\"section\",\"modules\":[{\"type\":\"text\",\"content\":\"<p>x</p>\"}]}]}")
echo "$INSERT" | jq -e '.__error | fromjson | .code == "divi_incompatible"' >/dev/null || fail "write must be refused: $INSERT"
[ "$(wp post get "$PAGE_ID" --field=post_content)" = "$BEFORE" ] || fail "nothing may be written"
pass "writing Divi tool refused with divi_incompatible, page unchanged"

CREATE=$(tool lit-content-create '{"post_type":"page","title":"LIT Freigabe Smoke Neu","divi":[{"type":"section","modules":[{"type":"text","content":"<p>x</p>"}]}]}')
echo "$CREATE" | jq -e '.__error | fromjson | .code == "divi_incompatible"' >/dev/null || fail "content-create with divi must be refused: $CREATE"
[ "$(wp post list --post_type=page --post_status=any --title='LIT Freigabe Smoke Neu' --format=count)" = "0" ] \
  || fail "no draft may remain"
pass "lit-content-create with divi refused before a draft exists"

STATUS=$(tool lit-site-status '{}')
echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .release.state == "incompatible"' >/dev/null \
  || fail "site status must show incompatible: $STATUS"
[ "$(wp db query "SELECT COUNT(*) FROM $(wp db prefix)lit_mcp_log WHERE result = 'refused' AND ability = 'lit/divi-insert'" --skip-column-names)" -ge 1 ] \
  || fail "the refusal must be in the change log"
pass "site status incompatible, refusal logged"

# 4. Back to released: writes work again
wp transient delete lit_mcp_divi_state >/dev/null
OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}")
INSERT=$(tool lit-divi-insert "{\"post_id\":$PAGE_ID,\"tree_rev\":$(echo "$OUTLINE" | jq '.tree_rev'),\"nodes\":[{\"type\":\"section\",\"modules\":[{\"type\":\"text\",\"content\":\"<p>x</p>\"}]}]}")
echo "$INSERT" | jq -e '.ok == true' >/dev/null || fail "write must work when released: $INSERT"
pass "released again: writing works"

# 5. WP-CLI
wp --user="$ADMIN_LOGIN" lit-mcp divi-check >/dev/null || fail "wp lit-mcp divi-check must succeed"
[ "$(check_pages)" = "0" ] || fail "the CLI check must delete its page"
pass "wp lit-mcp divi-check is green and leaves no page"

finish

(Vor dem ersten Lauf die Parameternamen von lit-divi-insert und lit-content-create mit tests/Integration/divi-write-smoke.sh abgleichen – dort stehen gültige Aufrufe; abweichende Schlüssel übernehmen. Hinweis: Die Tabelle heißt <prefix>lit_mcp_log.)

  • Step 3: Smokes laufen lassen
cd /home/janniclas/Projekte/WordPress
for s in mcp-smoke wp-basis-smoke media-admin-smoke divi-read-smoke divi-write-smoke divi-library-smoke tb-smoke divi-release-smoke; do
  echo "== $s"; timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/$s.sh | tail -3
done

Expected: jedes Skript endet mit ALL CHECKS PASSED. (Die Skripte, die Pods brauchen, nennen das selbst; wie in den früheren Etappen den Benutzer fragen, bevor Pods aktiviert wird.)

  • Step 4: Abnahme-Notiz schreiben

docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md mit den manuellen Punkten (vom Benutzer im Browser abzuhaken):

# Divi-Absicherung – manuelle Abnahme

Stand <Datum>, Branch `divi-absicherung`, Version 0.6.0.

1. Einstellungen → LIT MCP → Divi: Zustand „freigegeben – im Plugin als getestet hinterlegt“, Divi 5.2.1.
2. „Prüfung starten“: nach einigen Sekunden Hinweis „Divi-Prüfung abgeschlossen“, fünf ✓; unter Seiten keine
   „[LIT MCP Prüfung]“-Seite, auch nicht im Papierkorb.
3. Ungetestete Version simulieren (nur lokal, nicht committen): in `src/Integrations/Divi/Release/DiviReleases.php`
   `TESTED = []` setzen. Reiter zeigt „ungetestet“; ein MCP-Client bekommt beim Schreiben `divi_untested` mit dem
   Hinweistext, beim Lesen den Hinweis in `warnings`.
4. „Prüfung starten“, dann „Divi 5.2.1 freigeben“: Zustand „freigegeben – auf dieser Website freigegeben am … von …“;
   `lit-site-status` zeigt `release.source = database`; Protokoll zeigt den Eintrag `lit/divi-release`.
5. „Freigabe zurückziehen“: wieder „ungetestet“, Schreiben gesperrt, Protokoll-Eintrag. Danach `TESTED` zurücksetzen
   (`git diff` leer).
6. Optional: eine vom Plugin geschriebene Seite im Visual Builder öffnen und speichern.
  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint – Expected: grün.

git add wp-lit-mcp.php src/Plugin.php readme.txt docs/guides/overview.md tests/Integration/divi-release-smoke.sh docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md

Commit wie in den Global Constraints mit Nachricht chore: release 0.6.0 with Divi release gate, smoke test and acceptance notes.

Danach übernimmt der Controller wie in den früheren Etappen: Gesamt-Review des Branches, Security-Audit (Notiz docs/superpowers/notes/2026-09-30-divi-absicherung-security-audit.md), manuelle Abnahme durch den Benutzer, dann superpowers:finishing-a-development-branch.