From 94b8c7b3387bca9b9f0a003698ce0bb60f0c9785 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 00:00:23 +0200 Subject: [PATCH 01/22] docs: Divi safeguard design spec (version gate, Divi boundary, admin check) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2026-09-29-divi-absicherung-design.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-29-divi-absicherung-design.md diff --git a/docs/superpowers/specs/2026-09-29-divi-absicherung-design.md b/docs/superpowers/specs/2026-09-29-divi-absicherung-design.md new file mode 100644 index 0000000..644bc8e --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-divi-absicherung-design.md @@ -0,0 +1,210 @@ +# Divi-Absicherung – Design-Spezifikation + +- **Datum:** 2026-09-29 +- **Status:** Entwurf zur Freigabe +- **Autor:** Jan-Niclas Loosen (Loosen-IT), erarbeitet mit Claude +- **Einordnung:** eigene Etappe vor Etappe 4 (Designsystem schreiben); Etappe 4 baut darauf auf. + +## 1. Ziel und Kontext + +### 1.1 Problem + +Die Divi-Integration hängt an Divi-Interna, nicht an einer zugesicherten Schnittstelle (Divi 5 bietet zum Schreiben keine an). Stand 0.5.0: + +- 19 Dateien rufen Divi direkt auf (`et_*`-Funktionen, `ET\…`-Klassen, `_et_*`-Meta, Option `et_divi`), auch außerhalb von `Integrations/Divi/` (`MediaUsageFinder`, `PostPresenter`, `ContentSchema`). +- Einige Stellen **bilden Divi-Speicherlogik nach** (Theme-Builder-Templateliste/Bedingungen/`_et_enabled` nach `et_theme_builder_api_save()`, Bibliothekseinträge nach `et_pb_submit_layout()`, Builder-Kennzeichen nach `SyncToServerController`). Ändert Divi diese Logik, schreibt das Plugin still falsche Daten. +- Welche Divi-Teile genutzt werden, steht an drei Orten (`DiviCompatibility`, `DiviThemeBuilder::FUNCTIONS`, Docblocks mit Zeilennummern aus Divi 5.2.1). `DiviCompatibility` prüft nur Existenz, nicht Verhalten. +- Eine Divi-Version wird nicht geprüft. + +### 1.2 Ziele (Vorgabe Auftraggeber) + +1. **Weniger Aufwand pro Divi-Update.** +2. **Ungetestete Divi-Version → nichts schreiben, klare Antwort.** Lesende Divi-Tools laufen weiter mit Hinweis „Achtung, ungetestet“; schreibende Divi-Tools sind gesperrt und sagen warum. +3. **Freigabe ohne Plugin-Update möglich:** getestete Versionen stehen fest im Code; zusätzlich kann ein Administrator auf einer Website im WP-Admin eine Prüfung laufen lassen und die Version danach freigeben (Eintrag in der Datenbank). Freigeben geht nur nach bestandener Prüfung. +4. **Wartbarkeit:** Divi-Zugriffe an einer Stelle bündeln (bestehender Code wird umgebaut, nicht nur neuer Code). + +### 1.3 Erfolgskriterien + +1. Außerhalb der fünf Divi-Adapter (§3) gibt es keinen direkten Divi-Zugriff; ein Unit-Test erzwingt das. +2. Jede genutzte Divi-Funktion/-Klasse/-Methode, jeder Post-Typ, jede Taxonomie, jeder Meta-Schlüssel und jede Option steht im Manifest ihres Adapters; nachgebaute Divi-Funktionen haben einen Fingerabdruck. +3. Mit einer nicht freigegebenen Divi-Version schreibt kein Divi-Tool; die Antwort trägt `divi_untested` bzw. `divi_incompatible` und einen deutschen Hinweis. Lesende Divi-Tools liefern ihr Ergebnis mit Hinweis in `warnings`. +4. Die Admin-Prüfung läuft auf einer Live-Website ohne sichtbare Spuren: die Wegwerfseite ist danach in jedem Fall gelöscht. +5. Alle bestehenden Unit-Tests und Smoke-Skripte sind nach dem Umbau unverändert grün (Umbau ohne Verhaltensänderung). +6. Eine neue Divi-Version lokal prüfen ist ein Befehl (`wp lit-mcp divi-check`); bei Rot zeigt `--diff` die geänderten Divi-Funktionen. + +### 1.4 Nicht Teil dieser Etappe + +- Sperren für Pods (nutzt nur die öffentliche Pods-API) oder den MCP-Adapter. +- Automatisierter Browsertest des Visual Builders. +- Schreiben ins Designsystem (Etappe 4 – dort gleich mit Manifest). + +## 2. Begriffe + +- **Adapter:** Klasse, die als einzige Divi direkt anspricht (§3). +- **Manifest:** Beschreibung der Divi-Abhängigkeiten eines Adapters (§4). +- **Nachbau (Mirror):** Plugin-Code, der Speicherlogik einer Divi-Funktion wiederholt, statt sie aufzurufen. +- **Fingerabdruck:** SHA-256 des normalisierten Quelltexts einer Divi-Funktion/-Methode (§4.2). +- **Live-Prüfung:** Abgleich aller Manifeste mit dem installierten Divi (Existenz + Fingerabdrücke), zur Laufzeit, zwischengespeichert (§5.4). +- **Admin-Prüfung:** vollständige Prüfung aus dem WP-Admin inkl. Schreibtest (§6). + +## 3. Divi-Grenze + +Nur fünf Adapter greifen auf Divi zu. Vorhandene Klassen werden wo möglich zu Adaptern bzw. in sie hineingezogen, damit der Umbau klein bleibt. + +| Adapter | Übernimmt (Stand 0.5.0) | +|---|---| +| **Seiten** | `DiviSaver` (Divi-Teil), `DiviFlags`, CSS-Cache `ET_Core_PageResource`, `WpLinkTargets` (Divi-Teil) | +| **Bibliothek** | `DiviLibraryStore`, `LibraryItemSpec`, `NewLibraryEntry`, `DiviRoles`, `LibraryAccess` (Divi-Teil), `GlobalLayout` | +| **Theme Builder** | `DiviThemeBuilder`, `Template`, `TemplateConditions` (Divi-Teile) | +| **Designsystem** | `DesignSystemReader`, `DesignSystem` (Divi-Teil), Option `et_divi` inkl. `divi_logo` für `MediaUsageFinder` | +| **Katalog** | `DiviModules`, `DynamicContentMap`, Divi-Post-Typen/Flags für `PostPresenter` und `ContentSchema` | + +Die genaue Klassenaufteilung legt der Plan fest; Regel: jede Divi-Berührung liegt in genau einem Adapter, der Rest des Plugins nutzt nur dessen Methoden. + +Parser, Serializer und `Compose/` bleiben, wo sie sind: sie kennen das Divi-**Format** (Blockmarkup, Attributpfade), rufen aber keine Divi-**Funktionen** auf. Ihr Schutz ist die Katalog- und Formatprüfung (§6.2 Schritte 2–3). + +**Architekturtest (Unit):** durchsucht `src/`; außerhalb der Adapter-Dateien sind `et_*(`-Aufrufe, `ET\…`- und `ET_…`-Klassen sowie `'_et_…'`/`'et_divi'`-Schlüssel ein Fehler. Innerhalb der Adapter ist jede solche Fundstelle ein Fehler, die nicht im Manifest des Adapters steht. Während des Umbaus gibt es eine Ausnahmeliste für Altbestand; am Ende der Etappe ist sie leer und wird entfernt. + +## 4. Manifest + +### 4.1 Inhalt + +Jeder Adapter liefert ein Manifest (festes PHP-Wertobjekt, keine Konfigurationsdatei) mit: + +- `functions`: Divi-Funktionen, die aufgerufen werden; +- `methods`: Klasse + Methode (+ erwartete Mindest-Parameterzahl), z. B. `ET\Builder\Packages\GlobalData\GlobalData::get_global_colors`; +- `classes`: Klassen, deren Existenz genügt; +- `post_types`, `taxonomies`, `block_types`; +- `meta_keys`, `options`: Divi-Schlüssel, die gelesen oder geschrieben werden (dokumentarisch; Architekturtest nutzt sie); +- `mirrors`: nachgebaute Divi-Funktionen/-Methoden mit Fingerabdruck und Kurzbeschreibung, was nachgebaut wird; +- `sources`: Quellenangaben (Divi-Datei, Funktion) – ersetzt die Zeilennummern in Docblocks. Zeilennummern entfallen. + +### 4.2 Fingerabdruck + +`sha256` über den Quelltext der Funktion bzw. Methode (per `ReflectionFunction`/`ReflectionMethod`: Datei, Start- und Endzeile), normalisiert mit `token_get_all()`: `T_COMMENT`, `T_DOC_COMMENT`, `T_WHITESPACE` entfernt, übrige Tokens aneinandergehängt. Kommentar- und Formatänderungen ändern den Fingerabdruck nicht; Codeänderungen schon. + +Die Soll-Fingerabdrücke werden mit dem Plugin ausgeliefert (erzeugte Datei im Plugin, z. B. JSON neben den Manifesten). Die normalisierten Soll-Quelltexte liegen nur für die Entwicklung unter `tests/Fixtures/divi/reference/` (per `.gitattributes` nicht im Plugin-Paket) und dienen `--diff` (§7). + +### 4.3 `DiviCompatibility` + +Verliert ihre handgepflegte Liste und prüft alle Manifeste: Existenz von Funktionen/Klassen/Methoden (inkl. Parameterzahl), Post-Typen, Taxonomien, Block-Typen; Fingerabdrücke der Mirrors. Ergebnis: Liste roter Punkte mit Adapter, Divi-Symbol, Fundort in der installierten Version. Die bisherigen Sonderprüfungen (z. B. `divi/global-layout` registriert oder in den Core-Metadaten) werden Manifest-Einträge mit eigener Prüfregel. + +## 5. Freigabe und Sperre zur Laufzeit + +### 5.1 Divi-Version + +Aus dem aktiven Theme wie bisher (`EnvironmentProbe`: Template `Divi`, bei Child-Theme Version des Eltern-Themes). + +### 5.2 Zustände + +| Zustand | Bedingung | Schreibende Divi-Tools | Lesende Divi-Tools | +|---|---|---|---| +| `released` | Version freigegeben (§5.3) **und** Live-Prüfung grün | laufen | laufen | +| `untested` | Live-Prüfung grün, Version nicht freigegeben | gesperrt, `divi_untested` | laufen, Hinweis | +| `incompatible` | Live-Prüfung rot | gesperrt, `divi_incompatible` | laufen, Hinweis | + +Die Live-Prüfung zählt immer, auch für Versionen aus der Code-Liste (erkennt z. B. Hotfixes mit gleicher Versionsnummer). Ist Divi nicht aktiv, gibt es wie bisher keine Divi-Tools; der Zustand ist dann ohne Bedeutung. + +### 5.3 Quellen der Freigabe + +Eine Divi-Version ist freigegeben, wenn sie + +1. **im Code** steht: feste Liste getesteter Versionen (PHP-Konstante), vom Entwickler gepflegt; **oder** +2. **in der Datenbank der Website** steht: Option `lit_mcp_divi_releases`, Liste von Einträgen `{divi_version, plugin_version, user_id, released_at, report_id}`. Ein Eintrag entsteht nur über die Admin-Prüfung nach grünem Bericht (§6.3) und gilt **nur für die Plugin-Version, mit der geprüft wurde**. Nach einem Plugin-Update muss eine nur per Datenbank freigegebene Divi-Version neu geprüft werden, außer sie steht inzwischen in der Code-Liste. Ein Administrator kann Einträge zurückziehen. + +Multisite: pro Website (Optionen der jeweiligen Website). + +### 5.4 Live-Prüfung und Zwischenspeicher + +Die Live-Prüfung (Manifeste, §4.3) wird im Transient `lit_mcp_divi_state` zwischengespeichert, Schlüssel aus Divi-Version, Plugin-Version und `filemtime` der Dateien aller Mirrors und Methoden. Weicht der Schlüssel ab oder lief ein Theme-Update (`upgrader_process_complete` für das Divi-Theme), wird neu berechnet. Kosten ohne Cache: Reflection + Tokenisieren von rund zehn Funktionen. + +### 5.5 Sperre + +**Zentral (`AbilityRegistrar`):** Für jedes Tool der Divi-Integration wird vor `execute` der Zustand ermittelt. + +- Schreibendes Tool (Annotation nicht `readonly`) und Zustand ≠ `released`: sofort `WP_Error` mit Code `divi_untested` bzw. `divi_incompatible`, keine Ausführung, Protokolleintrag als abgelehnter Aufruf. +- Lesendes Tool und Zustand ≠ `released`: normale Ausführung, Hinweis in `warnings` des Ergebnisses. + +Welche Tools „Divi-Tools“ sind, ergibt sich aus der Integrations-Zugehörigkeit (`IntegrationRegistry::groupMap()`); Divi-lastige Tools anderer Integrationen deklarieren sich zusätzlich (Ability-Eigenschaft „nutzt Divi“), ein Unit-Test prüft die Zuordnung. + +**In den Adaptern:** Jede schreibende Adapter-Methode prüft den Zustand selbst und wirft bei ≠ `released` eine eigene Ausnahme, die auf dieselben Fehlercodes abgebildet wird; bestehendes Rollback greift. Das deckt Wege ab, die Divi-Interna indirekt nutzen (z. B. Content-Update einer Divi-Seite über `DiviSaver`). Generisches Kopieren/Wiederherstellen von Posts (`PostCloner`, Revisionen) berührt keine Divi-Interna und bleibt ungesperrt. + +**Prüfmodus:** Der Schreibtest der Admin-Prüfung (§6.2 Schritt 4) muss trotz `untested` schreiben. Er setzt dafür eine request-lokale Ausnahme, die ausschließlich für die ID der Wegwerfseite gilt; sie ist nicht von außen setzbar (kein Parameter, keine Option). + +### 5.6 Hinweistexte + +- `divi_untested`: „Achtung: Divi {version} 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.“ +- `divi_incompatible`: „Divi {version} hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind gesperrt, bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.“ +- Lesehinweis (`warnings`): „Achtung: Divi {version} ist für LIT MCP ungetestet (bzw. inkompatibel); Lesen funktioniert, Schreiben ist gesperrt.“ + +### 5.7 `lit-site-status` + +Zeigt für Divi zusätzlich: Zustand, Quelle der Freigabe (`code`/`database` mit Datum und Benutzer), rote Punkte der Live-Prüfung. + +## 6. Admin-Prüfung + +### 6.1 Oberfläche und Rechte + +Neuer Reiter **„Divi“** unter Einstellungen → LIT MCP: Zustand, Divi-Version, Quelle der Freigabe, letzter Prüfbericht, Knöpfe „Prüfung starten“, „Divi {version} freigeben“, „Freigabe zurückziehen“. Alle Aktionen: `manage_options`, Nonce, `admin-post`-Aktionen wie die übrigen Reiter (`AdminPostAction`). Eine Sperre (Transient) verhindert zwei gleichzeitige Prüfungen. + +### 6.2 Prüfschritte + +1. **Manifeste:** Live-Prüfung (§4.3), ohne Cache. +2. **Modulkatalog:** Alle Attributpfade aus `ShortformMap` existieren im Live-Katalog von Divi. Die Logik von `tests/Integration/divi-shortform-check.php` wandert dafür ins Plugin; das Skript nutzt danach die Plugin-Klasse. +3. **Format im Speicher:** Ein mit dem Plugin ausgelieferter Referenzinhalt (Divi-5-Blockmarkup, deckt die Alltagsmodule ab) wird mit dem Plugin-Parser gelesen und serialisiert und muss byte-identisch herauskommen; `parse_blocks()` muss dieselbe Blockstruktur (Namen, Verschachtelung) liefern. +4. **Schreibtest auf Wegwerfseite:** privater Entwurf „[LIT MCP Prüfung] {Datum}“ (Post-Typ `page`); über den normalen Schreibweg (Composer → Serializer → Seiten-Adapter) je ein Exemplar der Alltagsmodule schreiben; prüfen: Inhalt gespeichert wie serialisiert, Builder-Kennzeichen gesetzt, Rendern über Divi ohne PHP-Fehler/Warnung und mit den erwarteten Texten in der Ausgabe. Kein Eintrag im Änderungsprotokoll. Im `finally` immer `wp_delete_post($id, true)` (endgültig, mit Revisionen und Meta); schlägt das fehl, nennt der Bericht die ID zum Löschen von Hand und der Bericht ist rot. + +Jeder Schritt meldet grün/rot mit Einzelpunkten. Ein Fehler in einem Schritt bricht die übrigen nicht ab (außer Schritt 4 bei rotem Schritt 1: dann kein Schreibtest). + +### 6.3 Bericht und Freigabe + +Der Bericht wird in der Option `lit_mcp_divi_check_report` gespeichert (letzter Bericht; ID, Zeit, Benutzer, Divi-Version, Plugin-Version, Cache-Schlüssel aus §5.4, Ergebnisse). „Freigeben“ ist nur aktiv – und wird serverseitig nur ausgeführt –, wenn der letzte Bericht vollständig grün ist und Divi-Version, Plugin-Version und Cache-Schlüssel zum aktuellen Stand passen. Freigabe und Zurückziehen werden im Änderungsprotokoll vermerkt. + +Hinweis im Bericht (blockiert nichts): „Optional: eine Divi-Seite im Visual Builder öffnen und speichern.“ + +## 7. WP-CLI für die Entwicklung + +`wp lit-mcp divi-check` (nur registriert, wenn `WP_CLI` definiert ist) führt die Prüfschritte aus §6.2 aus und gibt den Bericht aus; Rückgabecode ≠ 0 bei Rot. Freigaben schreibt der Befehl nicht. + +- `--diff`: zeigt für jeden roten Mirror den Unterschied zwischen Soll-Quelltext (`tests/Fixtures/divi/reference/`) und installiertem Quelltext, mit Datei und Zeilen in der installierten Version. +- `--update-reference`: schreibt Soll-Fingerabdrücke und Referenz-Quelltexte neu – nur, wenn alle übrigen Prüfschritte grün sind. Gedacht nach dem Anpassen eines Nachbaus. + +Ablauf bei neuer Divi-Version: lokal einspielen → `wp lit-mcp divi-check` → bei Grün Version in die Code-Liste eintragen; bei Rot `--diff`, Nachbau anpassen, `--update-reference`, Smoke-Skripte, Version eintragen → Plugin-Release. + +## 8. Daten und Deinstallation + +Neu: Optionen `lit_mcp_divi_releases`, `lit_mcp_divi_check_report`; Transients `lit_mcp_divi_state`, Prüfsperre. `Uninstaller` und `uninstall.php`-Doku werden ergänzt. + +## 9. Fehlerbehandlung + +- Reflection/Tokenisieren scheitert (Funktion fehlt, Datei nicht lesbar): Punkt rot → `incompatible`, nie Ausnahme nach außen. +- Zustand nicht ermittelbar (unerwartete Ausnahme): wie `incompatible` behandeln (Schreiben gesperrt), Protokoll-Fehlereintrag. +- Admin-Prüfung bricht ab (Timeout, Fatal im Rendern): Wegwerfseite im `finally` gelöscht; bei Fatal zusätzlich beim nächsten Aufruf des Reiters nach verwaisten „[LIT MCP Prüfung]“-Entwürfen des Plugins suchen (Marker-Meta) und löschen. + +## 10. Tests + +**Unit:** +- Zustandsberechnung: alle Kombinationen aus Code-Liste, DB-Eintrag (passende/andere Plugin-Version), Live-Prüfung grün/rot. +- Sperre in `AbilityRegistrar`: lesend/schreibend × drei Zustände; Fehlercodes und Texte; `warnings` bei lesenden Tools. +- Sperre in Adaptern inkl. Prüfmodus nur für die Wegwerfseiten-ID. +- Fingerabdruck: Kommentar/Leerraum ändert nichts, Codeänderung schon. +- Architekturtest (§3) und Test „jedes Divi-Tool ist als solches erkannt“. +- Freigabe nur bei frischem grünem Bericht (Version, Plugin-Version, Cache-Schlüssel); Rechte und Nonce. +- `Uninstaller` entfernt die neuen Optionen/Transients. + +**Integration gegen echtes Divi (Smoke-Skript):** +- Admin-Prüfung komplett grün; Wegwerfseite danach nicht mehr vorhanden (auch keine Revisionen). +- Künstlich veränderter Soll-Fingerabdruck → `incompatible`, schreibendes Tool liefert `divi_incompatible`, lesendes Tool liefert Ergebnis + Hinweis. +- Version aus Code-Liste entfernt → `untested`; nach Admin-Prüfung + Freigabe → `released`. +- `wp lit-mcp divi-check` grün; `--diff` bei verändertem Soll zeigt den Unterschied. + +**Regression:** alle bestehenden Unit-Tests und Smoke-Skripte unverändert grün. + +## 11. Umsetzungsreihenfolge + +1. Manifest-Format, Fingerabdruck, Architekturtest mit Ausnahmeliste für Altbestand. +2. Adapter nacheinander umbauen, bis die Ausnahmeliste leer ist; `DiviCompatibility` auf Manifeste umstellen; Docblock-Zeilennummern entfernen. +3. Zustand, Freigabe (Code-Liste, Datenbank), zentrale und Adapter-Sperre, `lit-site-status`. +4. Admin-Reiter „Divi“, Prüfschritte, Wegwerfseite, Bericht, Freigabe. +5. WP-CLI-Befehl mit `--diff` und `--update-reference`. +6. Security-Audit; danach Etappe 4. From ca40e39b18f348809cc2bb3e38a2f808a18f4dae Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 00:36:19 +0200 Subject: [PATCH 02/22] docs: Divi safeguard implementation plan Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plans/2026-09-30-divi-absicherung.md | 6855 +++++++++++++++++ 1 file changed, 6855 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-divi-absicherung.md diff --git a/docs/superpowers/plans/2026-09-30-divi-absicherung.md b/docs/superpowers/plans/2026-09-30-divi-absicherung.md new file mode 100644 index 0000000..2fda3e8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-divi-absicherung.md @@ -0,0 +1,6855 @@ +# Divi-Absicherung – Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Nur fünf Adapterbereiche sprechen Divi an, jeder mit Manifest und Fingerabdrücken der nachgebauten Divi-Funktionen; eine ungetestete oder veränderte Divi-Version sperrt alle schreibenden Divi-Wege mit klarer Antwort (`divi_untested`/`divi_incompatible`), lesende Divi-Tools liefern einen Hinweis; Freigabe über eine Versionsliste im Code oder – nach grüner Admin-Prüfung mit Wegwerfseite – über einen Datenbankeintrag; dieselbe Prüfung als `wp lit-mcp divi-check` mit `--diff` und `--update-reference`. + +**Architecture:** Neuer Namensraum `LIT\MCP\Integrations\Divi\Release`: `SourceFingerprint` (Tokenizer, liest Divi-Dateien ohne sie zu laden), `Manifest`/`Call`/`Mirror`, `DiviAdapter`-Schnittstelle und Register `DiviAdapters`, `DiviTouches` (Scanner für den Architekturtest), `ManifestCheck` (Prüfungen aus den Manifesten), `ReleaseResolver`/`ReleaseStatus`/`ReleaseGate` (Zustand, Zwischenspeicher, Sperre), `ReleaseStore` (DB-Freigaben), `CompatibilityCheck` mit `FormatCheck`/`WriteProbe`/`CheckReport` (Admin-Prüfung), `DiviToolGate` (zentrale Sperre in `AbilityRegistrar`), `DiviCheckRoute` (REST-Route der Prüfung), Admin-Reiter `DiviTab`, WP-CLI `DiviCheckCommand`. Sieben Adapterklassen in fünf Bereichen: `DiviPages` (neu, ersetzt `DiviFlags`), `DiviLibraryStore`, `DiviRoles`, `DiviThemeBuilder`, `DesignSystemReader`, `DiviModules`, `DynamicContentMap`. + +**Tech Stack:** PHP ≥ 8.1 (`token_get_all`, `T_NAME_*`-Tokens, Enums), WordPress 7.x (Abilities API, REST API mit Cookie-Nonce, Options, Transients, `wp_delete_post`), Divi 5.2.1, WP-CLI (`WP_CLI::add_command`), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq/curl-Smokes. + +**Spec:** `docs/superpowers/specs/2026-09-29-divi-absicherung-design.md` (freigegeben 2026-09-30). Hintergrund: `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` (Divi-Speicherformate), `tests/Fixtures/divi/stage3/README.md` (Befunde, v. a. Nr. 6: Taxonomien nicht in jedem Kontext registriert). + +**Voraussetzung:** Branch `divi-absicherung` ab `main` (903246d, 0.5.0) mit Spec-Commit 94b8c7b; `composer test` grün (2202 Tests). DDEV unter `/home/janniclas/Projekte/WordPress` mit aktivem Divi 5.2.1. + +## Entscheidungen dieses Plans + +Die Spec überlässt die Klassenaufteilung dem Plan (§3). Diese Punkte präzisieren sie; wo der Plan von der Spec-Formulierung abweicht, steht es hier und wird dem Benutzer bei der Übergabe genannt. + +1. **Sieben Adapterklassen, fünf Bereiche.** `Manifest::$area` ordnet jede Klasse einem der fünf Bereiche der Spec zu: Seiten = `DiviPages` (neu; übernimmt `DiviFlags`, CSS-Cache, Builder-Kennzeichen lesen, Builder-URL, Divis Link-Typen); Bibliothek = `DiviLibraryStore` (übernimmt die Meta-Schreibweise aus `LibraryItemSpec` und das Lesen von `_et_pb_built_for_post_type` aus `NewLibraryEntry`) + `DiviRoles`; Theme Builder = `DiviThemeBuilder`; Designsystem = `DesignSystemReader` (übernimmt das Divi-Logo für `MediaUsageFinder`); Katalog = `DiviModules` (übernimmt Divis interne Post-Typen und `et_builder_enabled_for_post_type` für `ContentSchema`, den Block-Typ-Test aus `DiviCompatibility`) + `DynamicContentMap`. `DiviSaver`, `LibraryItemSpec`, `NewLibraryEntry`, `WpLinkTargets`, `PostPresenter`, `ContentSchema`, `MediaUsageFinder`, `DiviPage` und `DiviCompatibility` berühren Divi danach nicht mehr selbst. +2. **Fingerabdrücke per Tokenizer statt Reflection.** Divi lädt `theme-builder/api.php` nur in admin-ajax (Etappe-3-Befund F20); Reflection fände `et_theme_builder_api_save()` in MCP-Anfragen nicht. `SourceFingerprint` liest die Divi-Datei mit `token_get_all()` und schneidet die Funktion bzw. Methode heraus. Gehasht werden die Tokens ohne Kommentare und Leerraum. +3. **Nachgebaute Divi-Funktionen (Mirrors)**, fünf Stück: `et_theme_builder_api_save` (Vorlagenliste, „ungenutzt“-Markierung), `et_theme_builder_store_template` (Bedingungen, Aktiv-Schalter), `et_theme_builder_load_template_setting_options` (Zerlegen der Bedingungs-IDs in `TemplateConditions`), `et_pb_submit_layout` (Terme und Meta neuer Bibliothekseinträge), `SyncToServerController::update` (Builder-Kennzeichen). Große Funktionen wie `SyncToServerController::update` melden auch Änderungen, die uns nicht betreffen – gewollt: lieber einmal zu oft prüfen als still falsch schreiben; `--diff` zeigt die Stelle. +4. **Die Sperre zur Laufzeit prüft nur Code**, also Funktionen und Methoden samt Argumentzahl, Konstanten und Fingerabdrücke. Registrierungen (Post-Typen, Taxonomien, Block-Typen) hängen vom Anfragekontext ab (Befund 6) und würden sonst fälschlich sperren. Sie stehen in `lit-site-status` und im Prüfbericht (Schritt „Manifeste“) und müssen für eine Freigabe grün sein. Präzisiert Spec §5.2 „Live-Prüfung“. +5. **„Prüfung starten“ läuft über eine REST-Route** (`POST /wp-json/lit-mcp/v1/divi-check`, Cookie-Anmeldung mit `wp_rest`-Nonce als Formularfeld, `manage_options`, Antwort 303 zurück zum Reiter). Grund: Im Admin-Kontext (`is_admin()`) ist nicht belegt, dass Divi Blöcke über `the_content` rendert; in REST-Anfragen ist es belegt (`lit-page-render-summary`), und die Codeprüfung läuft so im selben Kontext wie MCP-Aufrufe. Freigeben und Zurückziehen bleiben `admin-post`-Aktionen. Präzisiert Spec §6.1. +6. **Prüfsperre als Option** `lit_mcp_divi_check_lock` (`add_option` ist atomar, ein Transient nicht); älter als 10 Minuten gilt sie als verwaist. Präzisiert Spec §8 („Prüfsperre“). +7. **Prüfschritte im Bericht:** fünf statt vier – „Manifeste“ (Code und Registrierungen) und „Fingerabdrücke“ getrennt, damit `--update-reference` erkennt, dass nur Fingerabdrücke rot sind. Der Schreibtest entfällt nur, wenn „Manifeste“ rot ist. +8. **Wegwerfseite:** Post-Typ `page`, Status `draft`, Titel „[LIT MCP Prüfung] “, Marker-Meta `_lit_mcp_check_page` = `1` (für das Aufräumen verwaister Seiten). Endgültig gelöscht mit `wp_delete_post($id, true)`. +9. **Version 0.6.0.** DB-Freigaben hängen an der Plugin-Version; die Etappe ändert, wie das Plugin Divi nutzt. +10. **Unit-Tests:** `LIT\MCP\Tests\Unit\TestCase` setzt in `setUp()` einen Resolver, der „freigegeben“ meldet (`ReleaseGate::useResolver()`), und setzt ihn in `tearDown()` zurück. Bestehende Tests bleiben so unverändert; Tests der Sperre setzen ihren eigenen Status. + +## Global Constraints + +- **Dateien nur im Plugin-Verzeichnis** `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und räumen Testseiten, Optionen (`lit_mcp_divi_releases`, `lit_mcp_divi_check_report`, `lit_mcp_divi_check_lock`) und den Transient `lit_mcp_divi_state` wieder auf. +- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.x); Divi 5.2.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, höchstens 120 Zeichen pro Zeile. Nach jedem Task: `composer test` und `composer lint` sauber. +- **Divi-Grenze (Spec §3):** Nach Task 7 berührt außerhalb der Klassen in `DiviAdapters::CLASSES` kein Code Divi (`et_*(`-Aufrufe, `ET\…`/`ET_Core…`/`ET_Builder…`-Klassen, `ET_*`-Konstanten, Zeichenketten `'et_…'`/`'_et_…'`); jede Fundstelle in einem Adapter steht in dessen Manifest. `DiviBoundaryTest` erzwingt beides. +- **Sperre (Spec §5):** Zustand `released` nur, wenn die Codeprüfung grün ist **und** die Divi-Version in `DiviReleases::TESTED` steht oder einen DB-Eintrag für genau diese Plugin-Version hat. Schreibende Divi-Tools liefern sonst `divi_untested` bzw. `divi_incompatible` und schreiben nichts; lesende Divi-Tools laufen und bekommen den Hinweis in `warnings`. Jede schreibende Adapter-Methode prüft zusätzlich selbst (`ReleaseGate::assertWritable()`); Ausnahme nur für die ID der Wegwerfseite während der Prüfung (`ReleaseGate::whileProbing()`), nie von außen setzbar. +- **Freigabe per Datenbank nur nach frischem grünem Bericht:** gleiche Divi-Version, gleiche Plugin-Version, gleicher Zustandsschlüssel (`ReleaseResolver::stateKey()`), alle Schritte grün. Nur `manage_options`, mit Nonce. +- **Wegwerfseite immer löschen:** `wp_delete_post($id, true)` im `finally`; schlägt es fehl, ist der Bericht rot und nennt die ID; verwaiste Seiten (Marker-Meta) räumt der Reiter beim Öffnen auf. +- **Umbau ohne Verhaltensänderung:** Alle bestehenden Unit-Tests und Smoke-Skripte bleiben grün; geänderte Tests nur dort, wo eine Klasse umzieht oder ein Test Divi-Namen direkt prüft. +- **Sprache und Wortwahl:** Oberfläche, Fehlermeldungen, Hinweise, Guides, Readme auf Deutsch mit echten Umlauten. Neutral formulieren: „KI-Assistent“, „MCP-Client“, „Verbindung“. Tool-Namen in Texten: direkte `lit-`, indirekte `lit/`. +- Tests: Unit-Tests mit WordPress-Funktionen über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. Reine PHP-Tests erben von `PHPUnit\Framework\TestCase`. Einzelne Tests: `vendor/bin/phpunit --filter `. Integrationstests: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`. WP-CLI in DDEV: `cd /home/janniclas/Projekte/WordPress && ddev wp `. +- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "" -m "Co-Authored-By: Claude Opus 5.5 (1M context) "` (im Folgenden „Commit wie in den Global Constraints“). + +## Review Focus + +1. **Sperre umgangen über einen Nebenweg:** `lit-content-create` mit `divi`, `lit-library-insert`, `lit/library-make-global`, `lit/tb-create-template`, `lit/tb-assign`, `lit/tb-activate` bei ungetesteter Version. Erwartet: `divi_untested`, kein Entwurf, kein Bibliothekseintrag, keine Vorlage bleibt zurück → Tests `DiviToolGateTest::testWriteToolOfEveryDiviGroupIsRefused` (Task 10), `ContentCreateDiviTest::testLockedDiviRefusesBeforeTheDraftExists` (Task 11), `ReleaseGuardsTest` (Task 11: Saver, Bibliothek, Theme Builder werfen `DiviLocked` vor dem ersten Schreibaufruf). +2. **Hinweis bricht lesende Tools:** Ein lesendes Divi-Tool liefert eine Liste, einen `WP_Error` oder schon ein `warnings`-Feld; das Ausgabeschema erlaubt kein `warnings`. Erwartet: Fehler und Listen unverändert, vorhandene Warnungen bleiben erhalten, Schema erlaubt `warnings` → Tests `DiviToolGateTest::testReadResultsKeepTheirShapeAndGetTheNotice`, `::testOutputSchemaAllowsWarnings` (Task 10). +3. **Veralteter oder fremder Bericht gibt frei:** Bericht grün, aber Divi inzwischen aktualisiert, Plugin aktualisiert oder eine Divi-Datei per Hotfix geändert; Freigabe-Formular ohne Nonce oder von einem Redakteur. Erwartet: keine Freigabe, Hinweis „Prüfung erneut ausführen“ bzw. 403 → Tests `CheckReportTest::testFreshOnlyForTheSameVersionsAndStateKey` (Task 12), `DiviTabTest::testReleaseNeedsAFreshGreenReport`, `::testActionsNeedNonceAndManageOptions` (Task 13). +4. **Wegwerfseite bleibt stehen:** Composer wirft, Rendern wirft, `wp_delete_post` liefert `false`, der Prozess stirbt. Erwartet: Seite gelöscht bzw. ID im roten Bericht; beim nächsten Öffnen des Reiters aufgeräumt → Tests `WriteProbeTest::testDeletesThePageEvenWhenWritingThrows`, `::testReportsAPageThatCouldNotBeDeleted`, `::testRemovesOrphanedCheckPages` (Task 12). +5. **Fingerabdruck falsch-positiv oder falsch-negativ:** Kommentar oder Einrückung in Divi geändert (darf nicht sperren), eine Zeile Code geändert (muss sperren), Funktion in einem `if ( ! function_exists() )`-Block, gleichnamige Methode in einer anderen Klasse derselben Datei, `Foo::class` im Rumpf, Datei fehlt. Erwartet: Hash stabil bzw. geändert, richtige Funktion, fehlende Datei = rot statt Ausnahme → Tests `SourceFingerprintTest` (Task 1), `ManifestCheckTest::testUnreadableMirrorIsAFailedCheck` (Task 8). + +--- + +## File Structure + +``` +wp-lit-mcp.php, src/Plugin.php (geändert) Version 0.6.0; Verdrahtung Gate, Route, Reiter, CLI, Cache-Reset +readme.txt (geändert) Abschnitt Divi-Freigabe, Changelog 0.6.0 +docs/guides/overview.md (geändert) Hinweis auf gesperrtes Schreiben bei ungetesteter Divi-Version +docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md (neu) manuelle Abnahme +src/ + Uninstaller.php, uninstall.php (geändert) neue Optionen und Transient + Abilities/ + AbilityRegistrar.php (geändert) DiviToolGate: Sperre, Hinweis, Ausgabeschema, DiviLocked + Errors.php (unverändert; Fehler entstehen in ReleaseStatus über Errors::make) + Admin/ + DiviTab.php (neu) Reiter „Divi“ + LogListTable.php (geändert) Ergebnis „refused“ = „abgelehnt“ + SettingsPage.php (geändert) Hinweise divi_* + Cli/DiviCheckCommand.php (neu) wp lit-mcp divi-check + Integrations/ + Core/EnvironmentProbe.php (geändert) DiviEnvironment, Freigabe-Zustand in components + Core/SiteStatusAbility.php (geändert) Beschreibung + Media/MediaUsageFinder.php (geändert) DesignSystemReader::diviLogo() + WordPress/Content/PostPresenter.php (geändert) DiviPages::usesBuilder() + WordPress/Content/ContentComposer.php (geändert) refusal() + WordPress/Content/ContentCreateAbility.php (geändert) Sperre vor dem Anlegen + WordPress/Schema/ContentSchema.php (geändert) DiviModules::INTERNAL_POST_TYPES, builderEnabledFor() + Divi/ + DiviCompatibility.php (geändert) Prüfungen aus den Manifesten + Release/ (neu) + SourceFingerprint.php Manifest.php Call.php Mirror.php DiviAdapter.php DiviAdapters.php + DiviTouches.php ManifestCheck.php DiviEnvironment.php DiviReference.php fingerprints.json + DiviReleases.php ReleaseState.php ReleaseStatus.php ReleaseResolver.php ReleaseGate.php DiviLocked.php + ReleaseStore.php DiviToolGate.php CheckStep.php CheckReport.php CheckReportStore.php FormatCheck.php + WriteProbe.php CompatibilityCheck.php DiviCheckRoute.php LineDiff.php reference-page.html + Pages/DiviPages.php (neu, aus DiviFlags.php) Adapter „Seiten“ + Pages/DiviFlags.php (entfällt) + Pages/DiviSaver.php, DiviPage.php, WpLinkTargets.php, DiviContentComposer.php (geändert) + Library/DiviLibraryStore.php, DiviRoles.php, LibraryItemSpec.php, NewLibraryEntry.php (geändert) + ThemeBuilder/DiviThemeBuilder.php, TemplateConditions.php, Template.php (geändert) + Design/DesignSystemReader.php (geändert) + Schema/DiviModules.php, ShortformCheck.php (neu), Pages/DynamicContentMap.php (geändert) +tests/ + Unit/TestCase.php (geändert) ReleaseGate: freigegeben als Vorgabe + Unit/Integrations/Divi/Release/*Test.php (neu) + Unit/Integrations/Divi/Pages/DiviPagesTest.php (neu, aus DiviFlagsTest.php) + Unit/Admin/DiviTabTest.php, Unit/Cli/DiviCheckCommandTest.php (neu) + Fixtures/divi/reference/*.txt (neu, erzeugt) Soll-Quelltexte der Mirrors + Integration/divi-shortform-check.php (geändert) nutzt ShortformCheck + Integration/divi-release-smoke.sh (neu) +``` + +--- + +### Task 1: SourceFingerprint + +**Files:** +- Create: `src/Integrations/Divi/Release/SourceFingerprint.php` +- Test: `tests/Unit/Integrations/Divi/Release/SourceFingerprintTest.php` + +**Interfaces:** +- Consumes: nichts. +- Produces: `SourceFingerprint::of(string $file, string $function, ?string $class = null): SourceFingerprint` (wirft `RuntimeException`), `SourceFingerprint::fromSource(string $source, string $function, ?string $class = null): ?SourceFingerprint`, Eigenschaften `string $hash` (sha256, 64 Hex-Zeichen), `string $source` (wörtlicher Text von `function` bis zur schließenden Klammer), `int $startLine`, `int $endLine`. + +- [ ] **Step 1: Write the failing test** + +```php + 1 ); + } +} + +class Other { + public static function update( $request ) { + return array( 'b' => 2 ); + } +} +PHP; + + public function testFindsAFunctionInsideAFunctionExistsGuard(): void + { + $print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save'); + + self::assertNotNull($print); + self::assertSame(7, $print->startLine); + self::assertSame(13, $print->endLine); + self::assertStringStartsWith('function et_demo_save(', $print->source); + self::assertStringEndsWith('}', $print->source); + self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $print->hash); + } + + public function testClassConstantsAndInterpolationDoNotConfuseTheBraceCount(): void + { + $print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save'); + + self::assertNotNull($print); + self::assertStringContainsString('return Foo::class;', $print->source); + self::assertStringNotContainsString('class Controller', $print->source); + } + + public function testMethodIsFoundInItsOwnClassOnly(): void + { + $controller = SourceFingerprint::fromSource(self::SOURCE, 'update', 'ET\Builder\VisualBuilder\Controller'); + $other = SourceFingerprint::fromSource(self::SOURCE, 'update', 'Other'); + + self::assertNotNull($controller); + self::assertNotNull($other); + self::assertStringContainsString("'a' => 1", $controller->source); + self::assertStringContainsString("'b' => 2", $other->source); + self::assertNotSame($controller->hash, $other->hash); + self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'update'), 'a method is no top-level function'); + self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save', 'Controller')); + self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'missing')); + } + + public function testCommentsAndWhitespaceDoNotChangeTheHash(): void + { + $reformatted = str_replace( + ["\t\t\$value = ", "if ( \$id ) {"], + ["\t\t// Why: demo.\n\t\t\$value = ", "if (\$id)\n\t\t{"], + self::SOURCE + ); + + self::assertSame( + SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash, + SourceFingerprint::fromSource($reformatted, 'et_demo_save')?->hash + ); + } + + public function testACodeChangeChangesTheHash(): void + { + $changed = str_replace("'_et_demo'", "'_et_demo_v2'", self::SOURCE); + + self::assertNotSame( + SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash, + SourceFingerprint::fromSource($changed, 'et_demo_save')?->hash + ); + } + + public function testOfReadsTheFileAndThrowsForMissingFunctions(): void + { + $file = (string) tempnam(sys_get_temp_dir(), 'lit'); + file_put_contents($file, self::SOURCE); + + try { + self::assertSame( + SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash, + SourceFingerprint::of($file, 'et_demo_save')->hash + ); + try { + SourceFingerprint::of($file, 'update', 'Missing'); + self::fail('a missing method must throw'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Missing::update() not found', $e->getMessage()); + } + } finally { + unlink($file); + } + } + + public function testOfThrowsForAnUnreadableFile(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is not readable'); + + SourceFingerprint::of('/nonexistent/lit-divi.php', 'et_demo_save'); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `vendor/bin/phpunit --filter SourceFingerprintTest` +Expected: FAIL with „Class "LIT\MCP\Integrations\Divi\Release\SourceFingerprint" not found“. + +- [ ] **Step 3: Write minimal implementation** + +```php + $bodies Open class bodies: [brace depth, name]. */ + $bodies = []; + $pending = null; + + foreach ($tokens as $i => $token) { + $id = is_array($token) ? $token[0] : null; + $text = is_array($token) ? $token[1] : $token; + + if ($id !== null && in_array($id, self::TYPES, true) && self::previousId($tokens, $i) !== T_DOUBLE_COLON) { + $pending = self::nextName($tokens, $i) ?? ''; + continue; + } + if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) { + ++$depth; + if ($pending !== null && $text === '{') { + $bodies[] = [$depth, $pending]; + $pending = null; + } + continue; + } + if ($text === '}') { + if ($bodies !== [] && $bodies[count($bodies) - 1][0] === $depth) { + array_pop($bodies); + } + --$depth; + continue; + } + if ($id !== T_FUNCTION || self::previousId($tokens, $i) === T_USE) { + continue; + } + + $name = self::nextName($tokens, $i); + if ($name === null || strcasecmp($name, $function) !== 0) { + continue; + } + $body = $bodies === [] ? null : $bodies[count($bodies) - 1]; + $matches = $wanted === null + ? $body === null + : $body !== null && $body[0] === $depth && strcasecmp($body[1], $wanted) === 0; + if ($matches) { + return self::capture($tokens, $i); + } + } + + return null; + } + + /** @param array $tokens */ + private static function capture(array $tokens, int $start): ?self + { + $startLine = (int) $tokens[$start][2]; + $parts = []; + $verbatim = ''; + $depth = 0; + $opened = false; + $count = count($tokens); + + for ($i = $start; $i < $count; ++$i) { + $token = $tokens[$i]; + $id = is_array($token) ? $token[0] : null; + $text = is_array($token) ? $token[1] : $token; + $verbatim .= $text; + if ($id === null || ! in_array($id, self::IGNORED, true)) { + $parts[] = $text; + } + + if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) { + ++$depth; + $opened = true; + } elseif ($text === '}') { + --$depth; + if ($opened && $depth === 0) { + return new self( + hash('sha256', implode(' ', $parts)), + $verbatim, + $startLine, + $startLine + substr_count($verbatim, "\n") + ); + } + } elseif ($text === ';' && ! $opened) { + return null; + } + } + + return null; + } + + /** The next name after $i (skipping whitespace, comments and "&"), null when something else follows. */ + private static function nextName(array $tokens, int $i): ?string + { + $count = count($tokens); + for ($j = $i + 1; $j < $count; ++$j) { + $token = $tokens[$j]; + if (is_array($token) && in_array($token[0], self::IGNORED, true)) { + continue; + } + if ($token === '&' || (is_array($token) && $token[1] === '&')) { + continue; + } + + return is_array($token) && $token[0] === T_STRING ? $token[1] : null; + } + + return null; + } + + /** Token ID of the previous significant token, null for a single-character token or none. */ + private static function previousId(array $tokens, int $i): ?int + { + for ($j = $i - 1; $j >= 0; --$j) { + $token = $tokens[$j]; + if (is_array($token) && in_array($token[0], self::IGNORED, true)) { + continue; + } + + return is_array($token) ? $token[0] : null; + } + + return null; + } + + public static function shortName(string $class): string + { + $position = strrpos($class, '\\'); + + return $position === false ? $class : substr($class, $position + 1); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `vendor/bin/phpunit --filter SourceFingerprintTest` +Expected: PASS (7 Tests). + +- [ ] **Step 5: Lint und Commit** + +Run: `composer lint` (bei Befunden in der neuen Datei `vendor/bin/phpcbf src/Integrations/Divi/Release/SourceFingerprint.php` und erneut `composer lint`). + +```bash +git add src/Integrations/Divi/Release/SourceFingerprint.php tests/Unit/Integrations/Divi/Release/SourceFingerprintTest.php +``` +Commit wie in den Global Constraints mit Nachricht `feat: fingerprint Divi functions from source tokens`. + +--- + +### Task 2: Manifest, Adapter-Register, Scanner und Architekturtest + +**Files:** +- Create: `src/Integrations/Divi/Release/Call.php`, `Mirror.php`, `Manifest.php`, `DiviAdapter.php`, `DiviAdapters.php`, `DiviTouches.php` +- Test: `tests/Unit/Integrations/Divi/Release/ManifestTest.php`, `tests/Unit/Integrations/Divi/Release/DiviTouchesTest.php`, `tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php` + +**Interfaces:** +- Consumes: `SourceFingerprint::shortName()` (Task 1). +- Produces: + - `Call::__construct(string $symbol, ?int $args = null)`; `className(): ?string` (voll qualifiziert, ohne führenden `\`), `member(): string`, `label(): string` (z. B. `et_pb_is_allowed()`, `GlobalData::get_global_colors()`). + - `Mirror::__construct(string $id, string $file, string $function, ?string $class, string $what)`; `label(): string`. + - `Manifest::__construct(string $area, array $calls = [], array $constants = [], array $postTypes = [], array $taxonomies = [], array $blockTypes = [], array $keys = [], array $mirrors = [])`, Konstanten `PAGES`, `LIBRARY`, `THEME_BUILDER`, `DESIGN`, `CATALOG`; `names(): list` (Funktionen, Klassen, Konstanten, Post-Typen, Taxonomien, Schlüssel). + - `interface DiviAdapter { public static function manifest(): Manifest; }` + - `DiviAdapters::CLASSES` (list), `DiviAdapters::manifests(): array`, `DiviAdapters::mirrors(): list`. + - `DiviTouches::inSource(string $source): list`, `DiviTouches::inFile(string $path)`, `DiviTouches::detects(string $name): bool` (`kind` ∈ `function`, `class`, `constant`, `string`). + +- [ ] **Step 1: Write the failing tests** + +`tests/Unit/Integrations/Divi/Release/ManifestTest.php`: + +```php +className()); + self::assertSame('et_pb_is_allowed', $function->member()); + self::assertSame('et_pb_is_allowed()', $function->label()); + self::assertSame('ET\Builder\Packages\GlobalData\GlobalData', $method->className()); + self::assertSame('get_global_colors', $method->member()); + self::assertSame('GlobalData::get_global_colors()', $method->label()); + self::assertSame('ET_Core_PageResource', (new Call('\ET_Core_PageResource::remove_static_resources', 3))->className()); + } + + public function testMirrorLabel(): void + { + $function = new Mirror('tb-api-save', 'includes/api.php', 'et_theme_builder_api_save', null, 'Vorlagenliste'); + $method = new Mirror('vb-flags', 'x.php', 'update', 'SyncToServerController', 'Kennzeichen'); + + self::assertSame('et_theme_builder_api_save()', $function->label()); + self::assertSame('SyncToServerController::update()', $method->label()); + } + + public function testNamesListEveryDeclaredDiviName(): void + { + $manifest = new Manifest( + Manifest::LIBRARY, + calls: [new Call('et_pb_is_allowed', 1), new Call('ET\Builder\GlobalLayout::is_global_layout_template', 1)], + constants: ['ET_BUILDER_PRODUCT_VERSION'], + postTypes: ['et_pb_layout'], + taxonomies: ['scope'], + blockTypes: ['divi/global-layout'], + keys: ['_et_pb_module_type'] + ); + + self::assertSame( + ['et_pb_is_allowed', 'ET\Builder\GlobalLayout', 'ET_BUILDER_PRODUCT_VERSION', 'et_pb_layout', 'scope', + '_et_pb_module_type'], + $manifest->names() + ); + self::assertSame('Bibliothek', $manifest->area); + } +} +``` + +`tests/Unit/Integrations/Divi/Release/DiviTouchesTest.php`: + +```php +et_method(); + Foo::et_static(); + get_option('lit_mcp_limits'); + } +} +PHP; + + $touches = array_map( + static fn (array $t): string => $t['kind'] . ':' . $t['name'], + DiviTouches::inSource($source) + ); + + self::assertSame( + [ + 'class:ET\Builder\Packages\GlobalData\GlobalData', + 'class:ET\Builder\Packages\Module\DynamicContentOptions', + 'function:et_pb_is_allowed', + 'function:et_theme_builder_clear_wp_cache', + 'class:ET_Core_PageResource', + 'constant:ET_BUILDER_PRODUCT_VERSION', + 'constant:ET_BUILDER_PRODUCT_VERSION', + 'string:_et_pb_use_builder', + 'string:et_', + ], + $touches + ); + } + + public function testDetectsOnlyNamesTheScannerReports(): void + { + self::assertTrue(DiviTouches::detects('et_pb_layout')); + self::assertTrue(DiviTouches::detects('_et_template')); + self::assertTrue(DiviTouches::detects('ET\Builder\Packages\GlobalData\GlobalData')); + self::assertTrue(DiviTouches::detects('ET_Core_PageResource')); + self::assertTrue(DiviTouches::detects('ET_BUILDER_PRODUCT_VERSION')); + self::assertFalse(DiviTouches::detects('scope')); + self::assertFalse(DiviTouches::detects('divi/global-layout')); + } +} +``` + +`tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php`: + +```php + $path) { + $touches = DiviTouches::inFile($path); + if ($touches === [] || in_array($relative, self::LEGACY, true)) { + continue; + } + if (! in_array(self::classOf($relative), DiviAdapters::CLASSES, true)) { + $offenders[] = $relative . ': ' . implode(', ', array_unique(array_column($touches, 'name'))); + } + } + + self::assertSame([], $offenders, "Divi darf nur ein Adapter aus DiviAdapters::CLASSES berühren:\n" + . implode("\n", $offenders)); + } + + public function testLegacyFilesStillTouchDivi(): void + { + $files = self::sourceFiles(); + foreach (self::LEGACY as $relative) { + self::assertArrayHasKey($relative, $files, $relative . ' gibt es nicht mehr – aus LEGACY streichen'); + self::assertNotSame([], DiviTouches::inFile($files[$relative]), $relative . ' ist sauber – aus LEGACY streichen'); + self::assertNotContains(self::classOf($relative), DiviAdapters::CLASSES, $relative . ' ist ein Adapter'); + } + } + + public function testAdaptersDeclareExactlyTheDiviNamesTheyUse(): void + { + foreach (DiviAdapters::CLASSES as $class) { + self::assertTrue(is_subclass_of($class, DiviAdapter::class), $class . ' implementiert DiviAdapter nicht'); + $declared = $class::manifest()->names(); + $used = array_values(array_unique(array_column( + DiviTouches::inFile((string) (new ReflectionClass($class))->getFileName()), + 'name' + ))); + + self::assertSame([], array_values(array_diff($used, $declared)), $class . ': nicht im Manifest'); + self::assertSame( + [], + array_values(array_diff(array_filter($declared, [DiviTouches::class, 'detects']), $used)), + $class . ': im Manifest, aber nicht benutzt' + ); + } + } + + public function testMirrorIdsAreUniqueAndFilesRelative(): void + { + $ids = []; + foreach (DiviAdapters::mirrors() as $mirror) { + self::assertNotContains($mirror->id, $ids, 'doppelte Mirror-ID ' . $mirror->id); + self::assertStringStartsNotWith('/', $mirror->file); + $ids[] = $mirror->id; + } + } + + /** @return array Path below src/ => absolute path. */ + private static function sourceFiles(): array + { + $root = dirname(__DIR__, 5) . '/src/'; + $files = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + if ($file->getExtension() === 'php') { + $files[substr((string) $file->getPathname(), strlen($root))] = (string) $file->getPathname(); + } + } + ksort($files); + + return $files; + } + + private static function classOf(string $relative): string + { + return 'LIT\\MCP\\' . str_replace(['/', '.php'], ['\\', ''], $relative); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `vendor/bin/phpunit --filter 'ManifestTest|DiviTouchesTest|DiviBoundaryTest'` +Expected: FAIL („Class … Call not found“, „… DiviTouches not found“, „… DiviAdapters not found“). + +- [ ] **Step 3: Write the implementation** + +`src/Integrations/Divi/Release/Call.php`: + +```php +symbol, '::') ? ltrim(explode('::', $this->symbol, 2)[0], '\\') : null; + } + + public function member(): string + { + return str_contains($this->symbol, '::') ? explode('::', $this->symbol, 2)[1] : $this->symbol; + } + + public function label(): string + { + $class = $this->className(); + + return ($class === null ? '' : SourceFingerprint::shortName($class) . '::') . $this->member() . '()'; + } +} +``` + +`src/Integrations/Divi/Release/Mirror.php`: + +```php +.txt. + * @param string $file Divi file, relative to the Divi theme directory. + * @param string $function Function or method name. + * @param string|null $class Class of a method – the short name is enough (the file fixes where it + * lives, and a short name is no Divi name for DiviBoundaryTest); null for a function. + * @param string $what What the plugin repeats (German, for reports). + */ + public function __construct( + public readonly string $id, + public readonly string $file, + public readonly string $function, + public readonly ?string $class, + public readonly string $what + ) { + } + + public function label(): string + { + return ($this->class === null ? '' : SourceFingerprint::shortName($this->class) . '::') . $this->function . '()'; + } +} +``` + +`src/Integrations/Divi/Release/Manifest.php`: + +```php + $calls Divi functions and static methods the adapter calls. + * @param list $constants Divi constants it reads. + * @param list $postTypes Divi post types it needs registered. + * @param list $taxonomies Divi taxonomies it needs registered. + * @param list $blockTypes Divi block types it needs (registered or in Divi's module metadata). + * @param list $keys Other Divi names it uses as strings: meta keys, options, internal post + * types that need no registration check, query arguments. + * @param list $mirrors Divi functions whose storage logic it repeats. + */ + public function __construct( + public readonly string $area, + public readonly array $calls = [], + public readonly array $constants = [], + public readonly array $postTypes = [], + public readonly array $taxonomies = [], + public readonly array $blockTypes = [], + public readonly array $keys = [], + public readonly array $mirrors = [] + ) { + } + + /** + * @return list Every Divi name the adapter's code may contain (DiviBoundaryTest): called functions and + * classes, constants, post types, taxonomies, keys and the names of mirrored functions. + */ + public function names(): array + { + $names = []; + foreach ($this->calls as $call) { + $names[] = $call->className() ?? $call->member(); + } + $mirrored = array_map(static fn (Mirror $mirror): string => $mirror->function, $this->mirrors); + + return array_values(array_unique(array_merge( + $names, + $this->constants, + $this->postTypes, + $this->taxonomies, + $this->keys, + $mirrored + ))); + } +} +``` + +`src/Integrations/Divi/Release/DiviAdapter.php`: + +```php +> */ + public const CLASSES = []; + + /** @return array, Manifest> */ + public static function manifests(): array + { + $manifests = []; + foreach (self::CLASSES as $class) { + $manifests[$class] = $class::manifest(); + } + + return $manifests; + } + + /** @return list */ + public static function mirrors(): array + { + $mirrors = []; + foreach (self::manifests() as $manifest) { + array_push($mirrors, ...$manifest->mirrors); + } + + return $mirrors; + } +} +``` + +`src/Integrations/Divi/Release/DiviTouches.php`: + +```php +et_a(), Foo::et_b()) are not Divi's. + */ +final class DiviTouches +{ + private const IGNORED = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]; + + private const NOT_A_CALL = [ + T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_CONST, + ]; + + /** @return list */ + public static function inFile(string $path): array + { + $source = file_get_contents($path); + + return is_string($source) ? self::inSource($source) : []; + } + + /** @return list */ + public static function inSource(string $source): array + { + $tokens = array_values(array_filter( + token_get_all($source), + static fn (mixed $token): bool => ! is_array($token) || ! in_array($token[0], self::IGNORED, true) + )); + + $touches = []; + foreach ($tokens as $i => $token) { + if (! is_array($token)) { + continue; + } + $touch = match ($token[0]) { + T_STRING, T_NAME_FULLY_QUALIFIED => self::name($tokens, $i, ltrim($token[1], '\\')), + T_NAME_QUALIFIED => str_starts_with($token[1], 'ET\\') ? ['class', $token[1]] : null, + T_CONSTANT_ENCAPSED_STRING => self::literal($token[1]), + default => null, + }; + if ($touch !== null) { + $touches[] = ['kind' => $touch[0], 'name' => $touch[1], 'line' => (int) $token[2]]; + } + } + + return $touches; + } + + /** Whether inSource() reports $name when the code uses it (DiviBoundaryTest's unused-entry check). */ + public static function detects(string $name): bool + { + return self::classify($name) !== null || preg_match('/^_?et_[a-z0-9_]*$/', $name) === 1; + } + + /** + * @param array $tokens + * @return array{0: string, 1: string}|null + */ + private static function name(array $tokens, int $i, string $name): ?array + { + $classified = self::classify($name); + if ($classified !== null) { + return $classified; + } + if (preg_match('/^et_[a-z0-9_]*$/', $name) !== 1 || ($tokens[$i + 1] ?? null) !== '(') { + return null; + } + $previous = $tokens[$i - 1] ?? null; + + return is_array($previous) && in_array($previous[0], self::NOT_A_CALL, true) ? null : ['function', $name]; + } + + /** @return array{0: string, 1: string}|null */ + private static function literal(string $text): ?array + { + $value = substr($text, 1, -1); + if ($text[0] === "'") { + $value = str_replace(['\\\\', "\\'"], ['\\', "'"], $value); + } + $value = ltrim($value, '\\'); + + $classified = self::classify($value); + if ($classified !== null) { + return $classified; + } + + return preg_match('/^_?et_[a-z0-9_]*$/', $value) === 1 ? ['string', $value] : null; + } + + /** @return array{0: string, 1: string}|null A Divi class or constant name. */ + private static function classify(string $name): ?array + { + if (preg_match('/^ET\\\\[A-Za-z0-9_\\\\]+$/', $name) === 1 + || preg_match('/^ET_(?:Core|Builder)[A-Za-z0-9_]*$/', $name) === 1) { + return ['class', $name]; + } + + return preg_match('/^ET_[A-Z0-9_]+$/', $name) === 1 ? ['constant', $name] : null; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `vendor/bin/phpunit --filter 'ManifestTest|DiviTouchesTest|DiviBoundaryTest'` +Expected: PASS. Meldet `testOnlyAdaptersTouchDivi` weitere Dateien, stimmt die Liste `LEGACY` nicht mit dem Code überein: die gemeldeten Dateien prüfen – echte Divi-Berührungen in `LEGACY` aufnehmen (und im Bericht an den Controller nennen), Fehlalarme des Scanners als Test in `DiviTouchesTest` festhalten und `DiviTouches` korrigieren. Meldet `testLegacyFilesStillTouchDivi` eine Datei als sauber, sie aus `LEGACY` streichen. + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` +Expected: alle Tests grün (2202 + neue), Lint sauber. + +```bash +git add src/Integrations/Divi/Release tests/Unit/Integrations/Divi/Release +``` +Commit wie in den Global Constraints mit Nachricht `feat: Divi manifests, adapter register and boundary test`. + +--- +### Task 3: Adapter „Katalog“ (DiviModules, DynamicContentMap, ContentSchema) + +**Files:** +- Modify: `src/Integrations/Divi/Schema/DiviModules.php`, `src/Integrations/Divi/Pages/DynamicContentMap.php`, `src/Integrations/WordPress/Schema/ContentSchema.php`, `src/Integrations/Divi/DiviCompatibility.php` (nur `knowsGlobalLayout()`), `src/Integrations/Divi/Release/DiviAdapters.php` +- Test: `tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php` (neu), `tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php`, `tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php` (LEGACY) + +**Interfaces:** +- Consumes: `Manifest`, `Call`, `DiviAdapter`, `DiviAdapters` (Task 2); `GlobalInstance::BLOCK` (`'divi/global-layout'`), `DiviLibraryStore::POST_TYPE`, `DiviThemeBuilder::THEME_BUILDER`, `::TEMPLATE`, `::LAYOUT_TYPES` (bestehend). +- Produces: `DiviModules implements DiviAdapter`; `DiviModules::INTERNAL_POST_TYPES` (list, Reihenfolge wie bisher in `ContentSchema::EXCLUDED`), `DiviModules::builderEnabledFor(string $postType): bool`, `DiviModules::knowsBlock(string $name): bool`; `DynamicContentMap implements DiviAdapter`. + +- [ ] **Step 1: Write the failing tests** + +`tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php`: + +```php +area); + self::assertSame(Manifest::CATALOG, DynamicContentMap::manifest()->area); + } + + public function testManifestListsModuleRegistrationAndTheBuilderSwitch(): void + { + $manifest = DiviModules::manifest(); + + self::assertSame( + [ + 'ModuleRegistration::get_all_core_modules_metadata()', + 'ModuleRegistration::get_default_attrs()', + 'et_builder_enabled_for_post_type()', + ], + array_map(static fn (Call $call): string => $call->label(), $manifest->calls) + ); + self::assertSame(['divi/global-layout'], $manifest->blockTypes); + self::assertSame(['ET_BUILDER_PRODUCT_VERSION'], $manifest->constants); + } + + public function testInternalPostTypesAreDivisOwn(): void + { + self::assertSame( + [ + 'et_pb_layout', 'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', + 'et_footer_layout', 'et_pb_canvas', '_et_pb_speculation', + ], + DiviModules::INTERNAL_POST_TYPES + ); + } + + public function testBuilderEnabledForAsksDivi(): void + { + Functions\when('et_builder_enabled_for_post_type')->alias(static fn (string $type): bool => $type === 'page'); + + self::assertTrue(DiviModules::builderEnabledFor('page')); + self::assertFalse(DiviModules::builderEnabledFor('post')); + } +} +``` + +In `tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php` ergänzen (Import `LIT\MCP\Integrations\WordPress\Schema\ContentSchema` ist vorhanden): + +```php + /** Spec §3: Divi's post types come from the Katalog adapter – the list itself stays the same. */ + public function testExcludedPostTypesStayTheSame(): void + { + self::assertSame( + [ + 'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part', + 'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'et_pb_layout', + 'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout', + 'et_pb_canvas', '_et_pb_speculation', + ], + ContentSchema::EXCLUDED + ); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `vendor/bin/phpunit --filter 'DiviModulesTest|ContentSchemaTest'` +Expected: `DiviModulesTest` FAIL („Call to undefined method … DiviModules::manifest()“); `testExcludedPostTypesStayTheSame` PASS (Schutz für den Umbau). + +- [ ] **Step 3: Implementation** + +`src/Integrations/Divi/Schema/DiviModules.php` – Klassenkopf, Konstante, Manifest und zwei neue Methoden; `isAvailable()`, `catalog()`, `builderVersion()` bleiben unverändert: + +```php +is_registered($name)) { + return true; + } + if (! self::isAvailable()) { + return false; + } + + foreach ((array) ModuleRegistration::get_all_core_modules_metadata() as $module) { + if (is_array($module) && ($module['name'] ?? null) === $name) { + return true; + } + } + + return false; + } +} +``` + +`src/Integrations/Divi/Pages/DynamicContentMap.php`: `class DynamicContentMap implements DiviAdapter`, Importe `LIT\MCP\Integrations\Divi\Release\Call`, `…\DiviAdapter`, `…\Manifest`, im Klassen-Docblock den ersten Satz mit „Adapter "Katalog" (spec §3):“ beginnen, am Docblock von `REGISTRY` „also checked by DiviCompatibility“ durch „listed in manifest()“ ersetzen, und: + +```php + public static function manifest(): Manifest + { + return new Manifest(Manifest::CATALOG, calls: [new Call(self::REGISTRY . '::get_options', 2)]); + } +``` + +`src/Integrations/WordPress/Schema/ContentSchema.php`: Import `use LIT\MCP\Integrations\Divi\Schema\DiviModules;`, dann: + +```php + /** Post types handled elsewhere (media tools, Divi tools, WordPress internals). */ + public const EXCLUDED = [ + 'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part', + 'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', + ...DiviModules::INTERNAL_POST_TYPES, + ]; +``` + +und im Konstruktor: + +```php + public function __construct(private array $sources, ?callable $diviEnabled = null) + { + $this->diviEnabled = $diviEnabled ?? DiviModules::builderEnabledFor(...); + } +``` + +`src/Integrations/Divi/DiviCompatibility.php`: Die Prüfung `'Block-Typ divi/global-layout (Instanzen globaler Module)'` ruft `DiviModules::knowsBlock(GlobalInstance::BLOCK)` auf; die private Methode `knowsGlobalLayout()` entfällt (Import `DiviModules` ist vorhanden). + +`src/Integrations/Divi/Release/DiviAdapters.php`: `CLASSES` wird + +```php + public const CLASSES = [ + \LIT\MCP\Integrations\Divi\Schema\DiviModules::class, + \LIT\MCP\Integrations\Divi\Pages\DynamicContentMap::class, + ]; +``` + +(mit `use`-Importen statt voll qualifizierter Namen, wie im übrigen Code). In `DiviBoundaryTest::LEGACY` die Zeilen `Integrations/Divi/Schema/DiviModules.php`, `Integrations/Divi/Pages/DynamicContentMap.php` und `Integrations/WordPress/Schema/ContentSchema.php` streichen. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `vendor/bin/phpunit --filter 'DiviModulesTest|ContentSchemaTest|DiviBoundaryTest|DiviCompatibilityTest'` +Expected: PASS. Meldet `testAdaptersDeclareExactlyTheDiviNamesTheyUse` einen Namen, fehlt er im Manifest (ergänzen) oder ist dort überflüssig (streichen). + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` – Expected: grün. +Commit wie in den Global Constraints mit Nachricht `refactor: Divi catalog adapter with manifest`. + +--- + +### Task 4: Adapter „Seiten“ (DiviPages statt DiviFlags) + +**Files:** +- Create (per `git mv`): `src/Integrations/Divi/Pages/DiviPages.php` aus `DiviFlags.php`; `tests/Unit/Integrations/Divi/Pages/DiviPagesTest.php` aus `DiviFlagsTest.php` +- Modify: `src/Integrations/Divi/Pages/DiviSaver.php`, `src/Integrations/Divi/Pages/DiviPage.php`, `src/Integrations/Divi/Pages/WpLinkTargets.php`, `src/Integrations/WordPress/Content/PostPresenter.php`, `src/Integrations/Divi/Release/DiviAdapters.php`, `tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php` + +**Interfaces:** +- Consumes: `Manifest`, `Call`, `Mirror`, `DiviAdapter` (Task 2); `DiviLibraryStore::POST_TYPE`. +- Produces: `final class DiviPages implements DiviAdapter` mit `FLAGS` (wie bisher `DiviFlags::FLAGS`), `setFlags(int $postId): void` (wirft `RuntimeException`, wie `DiviFlags::set()`), `usesBuilder(int $postId): bool`, `builderUrl(WP_Post|int $post): string`, `clearPageCss(int $postId): void`, `isLinkable(string $postType): bool` – alle statisch. Mirror-ID `vb-sync-flags`. + +- [ ] **Step 1: Klasse und Test umbenennen, Tests schreiben** + +```bash +git mv src/Integrations/Divi/Pages/DiviFlags.php src/Integrations/Divi/Pages/DiviPages.php +git mv tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php tests/Unit/Integrations/Divi/Pages/DiviPagesTest.php +``` + +In `DiviPagesTest.php`: Klasse `DiviPagesTest`, Import `LIT\MCP\Integrations\Divi\Pages\DiviPages`, jedes `DiviFlags::set(7)` → `DiviPages::setFlags(7)`, im Kommentar des ersten Tests „(SyncToServerController.php:382-387)“ → „(SyncToServerController::update, Mirror vb-sync-flags)“. Dazu diese Tests (Importe `LIT\MCP\Integrations\Divi\Release\DiviAdapters`, `LIT\MCP\Integrations\Divi\Release\Manifest`, `WP_Post`): + +```php + public function testIsTheRegisteredPagesAdapterWithTheFlagMirror(): void + { + $manifest = DiviPages::manifest(); + + self::assertContains(DiviPages::class, DiviAdapters::CLASSES); + self::assertSame(Manifest::PAGES, $manifest->area); + self::assertSame(['vb-sync-flags'], array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors)); + self::assertSame('SyncToServerController::update()', $manifest->mirrors[0]->label()); + } + + public function testUsesBuilderReadsDivisFlag(): void + { + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $id === 7 && $key === '_et_pb_use_builder' ? 'on' : '' + ); + + self::assertTrue(DiviPages::usesBuilder(7)); + self::assertFalse(DiviPages::usesBuilder(8)); + } + + public function testBuilderUrlOpensTheVisualBuilder(): void + { + Functions\when('get_permalink')->justReturn('https://k.de/start/'); + Functions\when('add_query_arg')->alias( + static fn (string $key, string $value, string $url): string => $url . '?' . $key . '=' . $value + ); + + self::assertSame('https://k.de/start/?et_fb=1', DiviPages::builderUrl(new WP_Post(['ID' => 7]))); + } + + public function testClearPageCssRemovesThePagesStaticFiles(): void + { + \ET_Core_PageResource::$removed = []; + + DiviPages::clearPageCss(7); + + self::assertSame([[7, 'all', true]], \ET_Core_PageResource::$removed); + } + + public function testIsLinkableFollowsDivisPublicPostTypes(): void + { + Functions\when('et_builder_get_public_post_types')->justReturn(['page' => 'Seiten', 'post' => 'Beiträge']); + + self::assertTrue(DiviPages::isLinkable('page')); + self::assertFalse(DiviPages::isLinkable('product')); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `vendor/bin/phpunit --filter DiviPagesTest` +Expected: FAIL („Class … DiviPages not found“ bzw. „undefined method setFlags“). + +- [ ] **Step 3: Implementation** + +`src/Integrations/Divi/Pages/DiviPages.php` (vollständig): + +```php + 'on', + '_et_pb_use_builder' => 'on', + '_et_pb_show_page_creation' => 'off', + ]; + + public static function manifest(): Manifest + { + return new Manifest( + Manifest::PAGES, + calls: [ + new Call('ET_Core_PageResource::remove_static_resources', 3), + new Call('et_builder_get_public_post_types', 0), + ], + keys: ['_et_pb_use_divi_5', '_et_pb_use_builder', '_et_pb_show_page_creation', 'et_fb'], + mirrors: [ + new Mirror( + 'vb-sync-flags', + 'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php', + 'update', + 'SyncToServerController', + 'Builder-Kennzeichen einer Seite (_et_pb_use_divi_5, _et_pb_use_builder, _et_pb_show_page_creation)' + ), + ] + ); + } + + /** @throws RuntimeException When a flag does not read back as set. */ + public static function setFlags(int $postId): void + { + foreach (self::FLAGS as $key => $value) { + update_post_meta($postId, $key, $value); + } + + foreach (self::FLAGS as $key => $value) { + if (get_post_meta($postId, $key, true) !== $value) { + throw new RuntimeException(sprintf('Divi flag %s of post %d is not "%s".', $key, $postId, $value)); + } + } + } + + /** Whether Divi's builder flag marks $postId as a builder layout. */ + public static function usesBuilder(int $postId): bool + { + return get_post_meta($postId, '_et_pb_use_builder', true) === 'on'; + } + + /** The entry opened in the Divi Visual Builder: its permalink with et_fb=1. */ + public static function builderUrl(WP_Post|int $post): string + { + return (string) add_query_arg('et_fb', '1', (string) get_permalink($post)); + } + + /** Removes the static CSS files Divi generated for $postId (spec §5.6 step 5). */ + public static function clearPageCss(int $postId): void + { + if (class_exists('ET_Core_PageResource')) { + \ET_Core_PageResource::remove_static_resources($postId, 'all', true); + } + } + + /** Whether Divi offers a post_link_url_ option for $postType. Fail closed: without Divi's list, none. */ + public static function isLinkable(string $postType): bool + { + if (! function_exists('et_builder_get_public_post_types')) { + return false; + } + + $types = et_builder_get_public_post_types(); + + return is_array($types) && array_key_exists($postType, $types); + } +} +``` + +`src/Integrations/Divi/Pages/DiviSaver.php`: +- Klassen-Docblock: „Divi 5 flags (DiviFlags), Divi's static CSS cache“ → „Divi 5 flags and the page's static CSS (DiviPages)“; „Security::initialize() (Divi 5.2.1 Security.php:74-81)“ → „Security::initialize() (Divi's Security.php)“. +- In `save()`: `static fn () => DiviFlags::set($postId)` → `static fn () => DiviPages::setFlags($postId)`; der CSS-Block wird + +```php + static function () use ($postId, $scope): void { + DiviPages::clearPageCss($postId); + // A global library entry is part of every page that shows it (spec §5.6 step 5). + if ($scope->clearsSiteCss()) { + DiviThemeBuilder::clearSiteCaches(); + } + }, +``` +- In `saveNew()`: `DiviFlags::set($postId);` → `DiviPages::setFlags($postId);`. + +`src/Integrations/Divi/Pages/DiviPage.php`, Methode `builderUrl()`: + +```php + /** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */ + public static function builderUrl(WP_Post|int $post): string + { + return DiviPages::builderUrl($post); + } +``` + +`src/Integrations/Divi/Pages/WpLinkTargets.php`: Import `LIT\MCP\Integrations\Divi\Library\DiviLibraryStore`; `NEVER = ['attachment', DiviLibraryStore::POST_TYPE]`; im Konstruktor `$this->linkable = $linkable ?? DiviPages::isLinkable(...);`; die private Methode `diviLinkable()` entfällt; Docblock des Konstruktors: „null = DiviPages::isLinkable() (Divi's et_builder_get_public_post_types(), the list its link option uses)“. + +`src/Integrations/WordPress/Content/PostPresenter.php`: Import `LIT\MCP\Integrations\Divi\Pages\DiviPages`; + +```php + public static function usesDivi(WP_Post $post): bool + { + return DiviPages::usesBuilder((int) $post->ID) + || str_contains((string) $post->post_content, '')]; + yield 'saveNew' => [static fn () => (new DiviSaver())->saveNew(7, '')]; + yield 'library create' => [static fn () => (new DiviLibraryStore())->create( + $spec, + new NewDraft(null, FakeChangeLog::forbidden(), 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag') + )]; + yield 'tb insertLayout' => [static fn () => (new DiviThemeBuilder())->insertLayout('header', 'Kopf')]; + yield 'tb storeTemplate' => [static fn () => (new DiviThemeBuilder())->storeTemplate(3, [ + 'title' => 'V', 'layouts' => [], 'use_on' => [], 'exclude_from' => [], + ])]; + yield 'tb attach' => [static fn () => (new DiviThemeBuilder())->attach(3, 9)]; + yield 'tb detach' => [static fn () => (new DiviThemeBuilder())->detach(3, 9)]; + yield 'tb setConditions' => [static fn () => (new DiviThemeBuilder())->setConditions(9, ['singular'], [])]; + yield 'tb setEnabled' => [static fn () => (new DiviThemeBuilder())->setEnabled(9, true)]; + yield 'tb unmarkUnused' => [static fn () => (new DiviThemeBuilder())->unmarkUnused(9)]; + } + + /** @dataProvider writes */ + public function testRefusesBeforeTheFirstWrite(callable $write): void + { + $this->expectException(DiviLocked::class); + + $write(); + } + + public function testTheCheckPageMayBeWrittenWhileProbing(): void + { + Functions\when('wp_update_post')->justReturn(41); + Functions\when('update_post_meta')->justReturn(true); + Functions\when('get_post_meta')->alias(static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on'); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 41, 'post_content' => 'x'])); + Functions\when('has_filter')->justReturn(false); + + $warnings = ReleaseGate::whileProbing(41, static fn () => (new DiviSaver())->saveNew(41, 'x')); + + self::assertSame([], $warnings); + } +} +``` + +In `tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php`: die Hilfsmethode `composer()` bekommt einen dritten Parameter `?WP_Error $refusal = null` und `$composer->allows('refusal')->andReturn($refusal);`. Neuer Test (Import `LIT\MCP\Integrations\Divi\Release\ReleaseState`, `…\ReleaseStatus`): + +```php + public function testLockedDiviRefusesBeforeTheDraftExists(): void + { + $refusal = (new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->refusal(); + Functions\expect('wp_insert_post')->never(); + + $result = $this->ability($this->composer(true, true, $refusal))->execute(self::INPUT); + + self::assertToolError($result, 'divi_untested'); + } +``` + +Alle übrigen Mocks von `ContentComposer` im Test-Verzeichnis (`grep -rln "mock(ContentComposer::class)" tests`) um `->allows('refusal')->andReturn(null)` ergänzen, wo der Mock `writable` erlaubt. + +In `tests/Unit/Integrations/Core/EnvironmentProbeTest.php` ergänzen: + +```php + public function testDiviComponentCarriesTheReleaseStatus(): void + { + $theme = Mockery::mock(); + $theme->allows('get_template')->andReturn('Divi'); + $theme->allows('parent')->andReturn(false); + $theme->allows('get')->with('Version')->andReturn('5.2.1'); + Functions\when('wp_get_theme')->justReturn($theme); + + $components = array_column( + (new EnvironmentProbe(new DiviCompatibility([])))->components(), + null, + 'id' + ); + + self::assertSame('released', $components['divi']['release']['state'] ?? null); + self::assertNull($components['pods']['release'] ?? null); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `vendor/bin/phpunit --filter 'ReleaseGuardsTest|ContentCreateDiviTest|EnvironmentProbeTest'` +Expected: FAIL (keine Ausnahme `DiviLocked`; `refusal` unbekannt; kein `release`). + +- [ ] **Step 3: Implementation** + +`src/Integrations/Divi/Pages/DiviSaver.php` – Import `LIT\MCP\Integrations\Divi\Release\ReleaseGate`; erste Zeile in `save()` nach `$postId = (int) $post->ID;`: + +```php + ReleaseGate::assertWritable($postId); +``` + +erste Zeile in `saveNew()`: + +```php + ReleaseGate::assertWritable($postId); +``` + +Beide Docblocks um `@throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.` ergänzen (Import `…\Release\DiviLocked` nur für den Docblock ist in diesem Code üblich; sonst voll qualifiziert im Docblock). + +`src/Integrations/Divi/Library/DiviLibraryStore.php`: erste Zeile in `create()`: `ReleaseGate::assertWritable();` (Import). + +`src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php`: erste Zeile in `insertLayout()`, `storeTemplate()`, `attach()`, `detach()`, `setConditions()`, `setEnabled()`, `unmarkUnused()`: `ReleaseGate::assertWritable();` (Import). In `insertLayout()` vor der `LAYOUT_TYPES`-Prüfung. + +`src/Integrations/WordPress/Content/ContentComposer.php` – neue Methode der Schnittstelle nach `writable()`: + +```php + /** + * Why writing such content is refused right now although it is enabled (e.g. the installed Divi version is not + * released for LIT MCP, spec §5.5); null = allowed. + */ + public function refusal(): ?WP_Error; +``` +(Import `WP_Error` ergänzen, falls nicht vorhanden.) + +`src/Integrations/Divi/Pages/DiviContentComposer.php` (Import `LIT\MCP\Integrations\Divi\Release\ReleaseGate`): + +```php + public function refusal(): ?WP_Error + { + $status = ReleaseGate::status(); + + return $status->writable() ? null : $status->refusal(); + } +``` + +`src/Integrations/WordPress/Content/ContentCreateAbility.php`, direkt nach dem `writable()`-Block: + +```php + $locked = $withDivi ? $this->divi->refusal() : null; + if ($locked !== null) { + return $locked; + } +``` + +`src/Integrations/Core/EnvironmentProbe.php` – Import `LIT\MCP\Integrations\Divi\Release\ReleaseGate`; Rückgabetyp im Docblock um `release: ?array` erweitern; im Divi-Eintrag `'release' => $isDivi ? ReleaseGate::status()->toArray() : null,`, bei Pods und The Events Calendar `'release' => null,`. + +`src/Integrations/Core/SiteStatusAbility.php`, `description()`: „Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.“ → „Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, und release, ob LIT MCP mit dieser Divi-Version schreiben darf (released, untested, incompatible).“ + +`src/Plugin.php`, in `boot()` nach der ChangeLog-Einrichtung (Import `LIT\MCP\Integrations\Divi\Release\ReleaseResolver`): + +```php + // Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too). + add_action('upgrader_process_complete', static function (): void { + delete_transient(ReleaseResolver::CACHE); + }); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `vendor/bin/phpunit --filter 'ReleaseGuardsTest|ContentCreate|EnvironmentProbeTest|SiteStatusAbilityTest|DiviSaverTest|DiviLibraryStoreTest|DiviThemeBuilderTest'` +Expected: PASS. Scheitert `testRefusesBeforeTheFirstWrite` für eine Methode mit „… ran although Divi is locked“, steht die Sperre dort nicht in der ersten Zeile. + +- [ ] **Step 5: Gesamtlauf, Lint, Commit** + +Run: `composer test && composer lint` – Expected: grün. +Commit wie in den Global Constraints mit Nachricht `feat: adapter-level Divi release lock, content-create and site status`. + +--- +### Task 12: Prüfschritte der Admin-Prüfung (Katalog, Format, Schreibtest, Bericht) + +**Files:** +- Create: `src/Integrations/Divi/Schema/ShortformCheck.php`, `src/Integrations/Divi/Release/FormatCheck.php`, `reference-page.html` (Kopie von `tests/Fixtures/divi/converted-landing.html`), `WriteProbe.php`, `CheckStep.php`, `CheckReport.php`, `CheckReportStore.php`, `CompatibilityCheck.php` +- Modify: `tests/Integration/divi-shortform-check.php` +- Test: `tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php`, `tests/Unit/Integrations/Divi/Release/FormatCheckTest.php`, `WriteProbeTest.php`, `CheckReportTest.php`, `CompatibilityCheckTest.php` + +**Interfaces:** +- Consumes: `ManifestCheck` (Task 8), `ReleaseGate::whileProbing()`, `ReleaseResolver::stateKey()` (Task 9), `DiviPages::FLAGS` (Task 4), `ContentComposer::write()`, `PageRenderer::render()`, `Parser`, `Serializer`, `Block`, `ShortformMap`, `ModuleCatalog`, `DiviModules`, `DiviEnvironment` (Task 8), `Errors::clientMessage()`, `Plugin::VERSION`. +- Produces: + - `ShortformCheck::problems(ModuleCatalog $catalog): list`. + - `FormatCheck::REFERENCE`, `__construct(?string $file = null, ?Closure $wpBlocks = null)`, `problems(): list`. + - `WriteProbe::MARKER = '_lit_mcp_check_page'`, `TITLE = '[LIT MCP Prüfung]'`, `SECTIONS`, `TEXTS`; `__construct(ContentComposer $composer, ?PageRenderer $renderer = null, ?string $diviDir = null)`, `run(): list`, statisch `removeOrphans(): list`. Nicht `final` (Mock im `CompatibilityCheckTest`). + - `CheckStep` mit Schlüsseln `MANIFEST = 'manifest'`, `MIRRORS = 'mirrors'`, `CATALOG = 'catalog'`, `FORMAT = 'format'`, `WRITE = 'write'`; `__construct(string $key, array $problems, bool $skipped = false)`, `green(): bool`, `label(): string`, `toArray()`, statisch `fromArray(mixed $data): ?CheckStep`. + - `CheckReport::__construct(string $id, string $createdAt, int $userId, string $diviVersion, string $pluginVersion, string $stateKey, array $steps)`, `green(): bool`, `freshFor(string $diviVersion, string $pluginVersion, string $stateKey): bool`, `step(string $key): ?CheckStep`, `toArray()`, statisch `fromArray(mixed $data): ?CheckReport`. + - `CheckReportStore::OPTION = 'lit_mcp_divi_check_report'`, `latest(): ?CheckReport`, `save(CheckReport $report): void`. Nicht `final`. + - `CompatibilityCheck::__construct(ManifestCheck $manifests, Closure $catalog, FormatCheck $format, WriteProbe $probe, Closure $version, Closure $stateKey)`, statisch `create(ContentComposer $composer): self`, `run(int $userId): CheckReport`. + +- [ ] **Step 1: Write the failing tests** + +`tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php`: + +```php + [ + 'blockName' => $block->name(), + 'innerBlocks' => $convert($block->children()), + ], $blocks); + }; + + return static fn (string $content): array => $convert((new Parser())->parse($content)->children()); + } + + public function testTheShippedReferenceRoundTripsAndMatchesWordPress(): void + { + self::assertSame([], (new FormatCheck(null, self::wordPress()))->problems()); + self::assertFileEquals(dirname(__DIR__, 5) . '/tests/Fixtures/divi/converted-landing.html', FormatCheck::REFERENCE); + } + + public function testADifferentStructureInWordPressIsReported(): void + { + $check = new FormatCheck(null, static fn (): array => [['blockName' => 'divi/section', 'innerBlocks' => []]]); + + self::assertSame(['WordPress liest eine andere Blockstruktur als LIT MCP.'], $check->problems()); + } + + public function testAMissingReferenceIsReported(): void + { + self::assertSame( + ['Der Referenzinhalt der Prüfung fehlt im Plugin.'], + (new FormatCheck('/nonexistent/reference.html', self::wordPress()))->problems() + ); + } +} +``` + +`tests/Unit/Integrations/Divi/Release/WriteProbeTest.php`: + +```php + */ + private array $deleted = []; + + protected function setUp(): void + { + parent::setUp(); + // The probe must work while Divi is untested – that is when it runs. + ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0')); + $this->deleted = []; + Functions\when('wp_insert_post')->justReturn(41); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 41, 'post_content' => ''])); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on' + ); + Functions\when('wp_delete_post')->alias(function (int $id, bool $force): WP_Post { + self::assertTrue($force, 'never to the trash'); + $this->deleted[] = $id; + + return new WP_Post(['ID' => $id]); + }); + } + + private function composer(?callable $write = null): ContentComposer + { + $composer = Mockery::mock(ContentComposer::class); + $composer->allows('write')->andReturnUsing($write ?? static function (int $id, string $type, array $sections): array { + // The composer writes through the saver, which asks the gate: the probe must have opened it for 41. + ReleaseGate::assertWritable($id); + + return ['warnings' => [], 'outline' => '']; + }); + + return $composer; + } + + private function renderer(string $html): PageRenderer + { + $renderer = Mockery::mock(PageRenderer::class); + $renderer->allows('render')->andReturn($html); + + return $renderer; + } + + public function testGreenWhenEveryModuleRendersAndThePageIsGone(): void + { + $probe = new WriteProbe($this->composer(), $this->renderer(implode(' ', WriteProbe::TEXTS)), '/nonexistent-divi'); + + self::assertSame([], $probe->run()); + self::assertSame([41], $this->deleted); + } + + public function testMissingTextsAndFlagsAreReported(): void + { + Functions\when('get_post_meta')->justReturn(''); + + $problems = (new WriteProbe($this->composer(), $this->renderer('litcheck-heading'), '/nonexistent-divi'))->run(); + + self::assertContains('Builder-Kennzeichen _et_pb_use_builder fehlt.', $problems); + self::assertContains('„litcheck-button“ fehlt in der gerenderten Seite.', $problems); + self::assertNotContains('„litcheck-heading“ fehlt in der gerenderten Seite.', $problems); + self::assertSame([41], $this->deleted); + } + + public function testDeletesThePageEvenWhenWritingThrows(): void + { + $composer = $this->composer(static function (): never { + throw new RuntimeException('Composer kaputt'); + }); + + $problems = (new WriteProbe($composer, $this->renderer(''), '/nonexistent-divi'))->run(); + + self::assertSame(['Schreibtest abgebrochen: Composer kaputt'], $problems); + self::assertSame([41], $this->deleted); + $this->expectException(\LIT\MCP\Integrations\Divi\Release\DiviLocked::class); + ReleaseGate::assertWritable(41); + } + + public function testReportsAPageThatCouldNotBeDeleted(): void + { + Functions\when('wp_delete_post')->justReturn(false); + + $problems = (new WriteProbe($this->composer(), $this->renderer(implode(' ', WriteProbe::TEXTS)), '/nonexistent-divi'))->run(); + + self::assertSame( + ['Die Testseite 41 konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.'], + $problems + ); + } + + public function testCreatesAMarkedDraftPage(): void + { + Functions\expect('wp_insert_post')->once()->with(Mockery::on(static fn (array $post): bool => + $post['post_type'] === 'page' + && $post['post_status'] === 'draft' + && str_starts_with($post['post_title'], '[LIT MCP Prüfung] ') + && $post['meta_input'] === ['_lit_mcp_check_page' => '1']), true)->andReturn(41); + + (new WriteProbe($this->composer(), $this->renderer(implode(' ', WriteProbe::TEXTS)), '/nonexistent-divi'))->run(); + } + + public function testRemovesOrphanedCheckPages(): void + { + Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $query): bool => + $query['meta_key'] === '_lit_mcp_check_page' + && $query['post_status'] === 'any' + && $query['fields'] === 'ids' + && ($query['date_query'][0]['before'] ?? null) === '10 minutes ago'))->andReturn([51, 52]); + + self::assertSame([51, 52], WriteProbe::removeOrphans()); + self::assertSame([51, 52], $this->deleted); + } +} +``` + +`tests/Unit/Integrations/Divi/Release/CheckReportTest.php`: + +```php + new CheckStep($key, []), + [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE] + ); + } + + public function testGreenOnlyWithEveryStepGreenAndNoneSkipped(): void + { + self::assertTrue(self::report(self::green())->green()); + self::assertFalse(self::report([])->green()); + + $red = self::green(); + $red[2] = new CheckStep(CheckStep::CATALOG, ['divi/text: unbekanntes Modul']); + self::assertFalse(self::report($red)->green()); + + $skipped = self::green(); + $skipped[4] = new CheckStep(CheckStep::WRITE, [], true); + self::assertFalse(self::report($skipped)->green()); + } + + public function testFreshOnlyForTheSameVersionsAndStateKey(): void + { + $report = self::report(self::green()); + + self::assertTrue($report->freshFor('5.3.0', '0.6.0', 'key')); + self::assertFalse($report->freshFor('5.3.1', '0.6.0', 'key'), 'Divi updated'); + self::assertFalse($report->freshFor('5.3.0', '0.7.0', 'key'), 'plugin updated'); + self::assertFalse($report->freshFor('5.3.0', '0.6.0', 'other'), 'a Divi file changed'); + } + + public function testRoundTripsThroughAnArray(): void + { + $steps = self::green(); + $steps[1] = new CheckStep(CheckStep::MIRRORS, ['Theme Builder: Nachbau von x() – y']); + $report = self::report($steps); + + self::assertEquals($report, CheckReport::fromArray($report->toArray())); + self::assertNull(CheckReport::fromArray('broken')); + self::assertNull(CheckReport::fromArray(['id' => 'r1'])); + self::assertSame('Fingerabdrücke nachgebauter Divi-Funktionen', $report->step(CheckStep::MIRRORS)?->label()); + } +} +``` + +`tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php`: + +```php +justReturn('uuid-1'); + $manifests = Mockery::mock(ManifestCheck::class); + $manifests->allows('codeChecks')->andReturn($code); + $manifests->allows('registrationChecks')->andReturn([]); + $manifests->allows('mirrorChecks')->andReturn($mirrors); + $format = Mockery::mock(FormatCheck::class); + $format->allows('problems')->andReturn([]); + if ($probe === null) { + $probe = Mockery::mock(WriteProbe::class); + $probe->allows('run')->andReturn([]); + } + + return new CompatibilityCheck( + $manifests, + $catalog ?? static fn (): array => [], + $format, + $probe, + static fn (): string => '5.3.0', + static fn (string $version): string => 'key-' . $version + ); + } + + public function testAGreenRunNamesVersionsAndStateKey(): void + { + $report = $this->check([], [])->run(7); + + self::assertTrue($report->green()); + self::assertSame(['uuid-1', 7, '5.3.0', Plugin::VERSION, 'key-5.3.0'], [ + $report->id, $report->userId, $report->diviVersion, $report->pluginVersion, $report->stateKey, + ]); + self::assertSame( + ['manifest', 'mirrors', 'catalog', 'format', 'write'], + array_map(static fn (CheckStep $step): string => $step->key, $report->steps) + ); + } + + public function testNoWriteTestWhenDiviFunctionsAreMissing(): void + { + $probe = Mockery::mock(WriteProbe::class); + $probe->expects('run')->never(); + + $report = $this->check(['Seiten: et_x()' => static fn (): bool => false], [], $probe)->run(7); + + self::assertSame(['Seiten: et_x()'], $report->step(CheckStep::MANIFEST)?->problems); + self::assertTrue($report->step(CheckStep::WRITE)?->skipped); + } + + public function testChangedFingerprintsStillRunTheWriteTest(): void + { + $probe = Mockery::mock(WriteProbe::class); + $probe->expects('run')->once()->andReturn([]); + + $report = $this->check([], ['TB: Nachbau von y()' => static fn (): bool => false], $probe)->run(7); + + self::assertFalse($report->green()); + self::assertTrue($report->step(CheckStep::WRITE)?->green()); + } + + public function testAThrowingStepIsARedStep(): void + { + $report = $this->check([], [], null, static function (): never { + throw new RuntimeException('Katalog weg'); + })->run(7); + + self::assertSame(['Prüfschritt abgebrochen: Katalog weg'], $report->step(CheckStep::CATALOG)?->problems); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `vendor/bin/phpunit --filter 'ShortformCheckTest|FormatCheckTest|WriteProbeTest|CheckReportTest|CompatibilityCheckTest'` +Expected: FAIL (Klassen fehlen). + +- [ ] **Step 3: Implementation** + +```bash +cp tests/Fixtures/divi/converted-landing.html src/Integrations/Divi/Release/reference-page.html +``` + +`src/Integrations/Divi/Schema/ShortformCheck.php`: + +```php + German problems; [] = the short form matches. */ + public static function problems(ModuleCatalog $catalog): array + { + $problems = []; + foreach (ShortformMap::types() as $type) { + $name = ModuleCatalog::normalize($type); + if (! $catalog->has($name)) { + $problems[] = $name . ': unbekanntes Modul'; + continue; + } + + foreach (ShortformMap::fields($type) as $field => $spec) { + if (! $catalog->isKnownPath($name, (string) $spec['path'])) { + $problems[] = sprintf( + '%s.%s: unbekannter Pfad %s (ähnlich: %s)', + $type, + $field, + $spec['path'], + implode(', ', $catalog->suggestions($name, (string) $spec['path'])) + ); + } + } + + $child = ShortformMap::childType($type); + if ($child !== null && ! in_array(ModuleCatalog::normalize($child), $catalog->childrenOf($name), true)) { + $problems[] = sprintf( + '%s: %s ist kein erlaubtes Kind (%s)', + $type, + $child, + implode(', ', $catalog->childrenOf($name)) + ); + } + } + + return $problems; + } +} +``` + +`tests/Integration/divi-shortform-check.php` – der Schleifenblock ab `$problems = [];` bis vor `if ($problems !== [])` wird `$problems = ShortformCheck::problems($catalog);` (Import `LIT\MCP\Integrations\Divi\Schema\ShortformCheck`; `ShortformMap` bleibt für die Erfolgsmeldung). + +`src/Integrations/Divi/Release/FormatCheck.php`: + +```php +)|null $wpBlocks null = parse_blocks(). */ + public function __construct(private ?string $file = null, private ?Closure $wpBlocks = null) + { + } + + /** @return list */ + public function problems(): array + { + $file = $this->file ?? self::REFERENCE; + $content = is_file($file) ? file_get_contents($file) : false; + if (! is_string($content) || $content === '') { + return ['Der Referenzinhalt der Prüfung fehlt im Plugin.']; + } + + $document = (new Parser())->parse($content); + $problems = []; + if (Serializer::document($document) !== $content) { + $problems[] = 'Parser und Serializer geben den Referenzinhalt nicht byte-gleich zurück.'; + } + $wordPress = ($this->wpBlocks ?? static fn (string $markup): array => parse_blocks($markup))($content); + if (self::structure($document->children()) !== self::wpStructure($wordPress)) { + $problems[] = 'WordPress liest eine andere Blockstruktur als LIT MCP.'; + } + + return $problems; + } + + /** + * @param list $blocks + * @return list}> + */ + private static function structure(array $blocks): array + { + return array_map(static fn (Block $block): array => [$block->name(), self::structure($block->children())], $blocks); + } + + /** + * @param array $blocks parse_blocks() result; freeform parts (blockName null) are left out. + * @return list}> + */ + private static function wpStructure(array $blocks): array + { + $structure = []; + foreach ($blocks as $block) { + if (is_array($block) && is_string($block['blockName'] ?? null)) { + $inner = is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : []; + $structure[] = [$block['blockName'], self::wpStructure($inner)]; + } + } + + return $structure; + } +} +``` + +`src/Integrations/Divi/Release/WriteProbe.php`: + +```php + 'section', + 'label' => 'LIT MCP Prüfung', + 'rows' => [ + [ + 'columns' => '1_2,1_2', + 'modules' => [ + [ + ['type' => 'heading', 'text' => 'litcheck-heading', 'level' => 'h2'], + ['type' => 'text', 'content' => '

litcheck-text

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

litcheck-blurb-text

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

litcheck-toggle-text

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

a

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

t

']]], + ['type' => 'icon-list', 'items' => [['text' => 'litcheck-icon-list']]], + ], + ], + ], + ]]; + + public const TEXTS = [ + 'litcheck-heading', 'litcheck-text', 'litcheck-button', 'litcheck-blurb', 'litcheck-cta', 'litcheck-toggle', + 'litcheck-accordion', 'litcheck-tab', 'litcheck-icon-list', + ]; + + /** Only pages older than this are orphans – a running check's page is younger. */ + private const ORPHAN_AGE = '10 minutes ago'; + + /** @param string|null $diviDir PHP warnings from files below it fail the check; null = DiviEnvironment::themeDir(). */ + public function __construct( + private ContentComposer $composer, + private ?PageRenderer $renderer = null, + private ?string $diviDir = null + ) { + } + + /** @return list German problems; [] = green. */ + public function run(): array + { + $id = wp_insert_post([ + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_title' => self::TITLE . ' ' . gmdate('Y-m-d H:i'), + 'post_content' => '', + 'meta_input' => [self::MARKER => '1'], + ], true); + if ($id instanceof WP_Error || (int) $id <= 0) { + return ['Die Testseite konnte nicht angelegt werden: ' + . ($id instanceof WP_Error ? $id->get_error_message() : 'keine ID')]; + } + $id = (int) $id; + + try { + $problems = $this->exercise($id); + } catch (Throwable $e) { + $problems = ['Schreibtest abgebrochen: ' . $e->getMessage()]; + } finally { + if (! self::delete($id)) { + $problems[] = sprintf( + 'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.', + $id + ); + } + } + + return $problems; + } + + /** @return list IDs of check pages a dead check left behind, deleted now. */ + public static function removeOrphans(): array + { + $ids = get_posts([ + 'post_type' => 'page', + 'post_status' => 'any', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked pages at most. + 'meta_key' => self::MARKER, + 'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']], + 'fields' => 'ids', + 'posts_per_page' => 20, + 'no_found_rows' => true, + 'suppress_filters' => true, + ]); + + $removed = []; + foreach (is_array($ids) ? $ids : [] as $id) { + if (self::delete((int) $id)) { + $removed[] = (int) $id; + } + } + + return $removed; + } + + /** @return list */ + private function exercise(int $id): array + { + $written = ReleaseGate::whileProbing( + $id, + fn (): array|WP_Error => $this->composer->write($id, 'page', self::SECTIONS) + ); + if ($written instanceof WP_Error) { + return ['Die Kurzform der Testseite wurde abgelehnt: ' . Errors::clientMessage($written)]; + } + + $post = get_post($id); + if (! $post instanceof WP_Post) { + return ['Die Testseite ist nach dem Schreiben nicht lesbar.']; + } + + $problems = []; + if (! str_contains((string) $post->post_content, '')]; + yield 'saveNew' => [static fn () => (new DiviSaver())->saveNew(7, '')]; + yield 'library create' => [static fn () => (new DiviLibraryStore())->create( + $spec, + new NewDraft(null, FakeChangeLog::forbidden(), 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag') + )]; + yield 'tb insertLayout' => [static fn () => (new DiviThemeBuilder())->insertLayout('header', 'Kopf')]; + yield 'tb storeTemplate' => [static fn () => (new DiviThemeBuilder())->storeTemplate(3, [ + 'title' => 'V', 'layouts' => [], 'use_on' => [], 'exclude_from' => [], + ])]; + yield 'tb attach' => [static fn () => (new DiviThemeBuilder())->attach(3, 9)]; + yield 'tb detach' => [static fn () => (new DiviThemeBuilder())->detach(3, 9)]; + yield 'tb setConditions' => [static fn () => (new DiviThemeBuilder())->setConditions(9, ['singular'], [])]; + yield 'tb setEnabled' => [static fn () => (new DiviThemeBuilder())->setEnabled(9, true)]; + yield 'tb unmarkUnused' => [static fn () => (new DiviThemeBuilder())->unmarkUnused(9)]; + } + + /** @dataProvider writes */ + public function testRefusesBeforeTheFirstWrite(callable $write): void + { + $this->expectException(DiviLocked::class); + + $write(); + } + + public function testTheCheckPageMayBeWrittenWhileProbing(): void + { + Functions\when('wp_update_post')->justReturn(41); + Functions\when('update_post_meta')->justReturn(true); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on' + ); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 41, 'post_content' => 'x'])); + Functions\when('has_filter')->justReturn(false); + + $warnings = ReleaseGate::whileProbing(41, static fn () => (new DiviSaver())->saveNew(41, 'x')); + + self::assertSame([], $warnings); + } +} diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php index 7ff7694..691e2a6 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php @@ -11,6 +11,8 @@ use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; +use LIT\MCP\Integrations\Divi\Release\ReleaseState; +use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; use LIT\MCP\Integrations\WordPress\Content\NewDraft; @@ -56,11 +58,12 @@ final class ContentCreateDiviTest extends TestCase } /** @return ContentComposer&MockInterface */ - private function composer(bool $available = true, bool $writable = true): ContentComposer + private function composer(bool $available = true, bool $writable = true, ?WP_Error $refusal = null): ContentComposer { $composer = Mockery::mock(ContentComposer::class); $composer->allows('available')->andReturn($available); $composer->allows('writable')->andReturn($writable); + $composer->allows('refusal')->andReturn($refusal); $composer->allows('check')->with('page', self::SECTIONS)->andReturn(null); return $composer; @@ -194,6 +197,16 @@ final class ContentCreateDiviTest extends TestCase * Security M2: with the Divi group disabled or "nur lesen", or lit/divi-insert disabled, the * divi input is refused before anything is created; content-create without divi keeps working. */ + 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'); + } + public function testDiviIsRefusedWhenDiviWritesAreSwitchedOff(): void { Functions\expect('wp_insert_post')->never(); @@ -224,6 +237,7 @@ final class ContentCreateDiviTest extends TestCase $composer = Mockery::mock(ContentComposer::class); $composer->allows('available')->andReturn(true); $composer->allows('writable')->andReturn(true); + $composer->allows('refusal')->andReturn(null); $composer->allows('check')->andReturn(Errors::invalidAttrPath(['divi[0].attrs.x' => 'unbekannt'])); $writer = Mockery::mock(PostWriter::class); $writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.'])); @@ -323,6 +337,11 @@ final class ContentCreateDiviTest extends TestCase return true; } + public function refusal(): ?WP_Error + { + return null; + } + public function check(string $postType, array $sections): ?WP_Error { return $this->real->check($postType, $sections); From 47040aa43d3ff1f721615d151fc3226adbdc6fda Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 02:21:09 +0200 Subject: [PATCH 15/22] feat: admin check steps with throwaway write test and reports Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Release/CheckReport.php | 105 +++++++++ .../Divi/Release/CheckReportStore.php | 23 ++ src/Integrations/Divi/Release/CheckStep.php | 62 +++++ .../Divi/Release/CompatibilityCheck.php | 92 ++++++++ src/Integrations/Divi/Release/FormatCheck.php | 79 +++++++ src/Integrations/Divi/Release/WriteProbe.php | 218 ++++++++++++++++++ .../Divi/Release/reference-page.html | 1 + .../Divi/Schema/ShortformCheck.php | 51 ++++ tests/Integration/divi-shortform-check.php | 28 +-- .../Divi/Release/CheckReportTest.php | 61 +++++ .../Divi/Release/CompatibilityCheckTest.php | 92 ++++++++ .../Divi/Release/FormatCheckTest.php | 50 ++++ .../Divi/Release/WriteProbeTest.php | 159 +++++++++++++ .../Divi/Schema/ShortformCheckTest.php | 31 +++ 14 files changed, 1026 insertions(+), 26 deletions(-) create mode 100644 src/Integrations/Divi/Release/CheckReport.php create mode 100644 src/Integrations/Divi/Release/CheckReportStore.php create mode 100644 src/Integrations/Divi/Release/CheckStep.php create mode 100644 src/Integrations/Divi/Release/CompatibilityCheck.php create mode 100644 src/Integrations/Divi/Release/FormatCheck.php create mode 100644 src/Integrations/Divi/Release/WriteProbe.php create mode 100644 src/Integrations/Divi/Release/reference-page.html create mode 100644 src/Integrations/Divi/Schema/ShortformCheck.php create mode 100644 tests/Unit/Integrations/Divi/Release/CheckReportTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/FormatCheckTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/WriteProbeTest.php create mode 100644 tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php diff --git a/src/Integrations/Divi/Release/CheckReport.php b/src/Integrations/Divi/Release/CheckReport.php new file mode 100644 index 0000000..990c048 --- /dev/null +++ b/src/Integrations/Divi/Release/CheckReport.php @@ -0,0 +1,105 @@ + $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 */ + 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 + ); + } +} diff --git a/src/Integrations/Divi/Release/CheckReportStore.php b/src/Integrations/Divi/Release/CheckReportStore.php new file mode 100644 index 0000000..1141884 --- /dev/null +++ b/src/Integrations/Divi/Release/CheckReportStore.php @@ -0,0 +1,23 @@ +toArray(), false); + } +} diff --git a/src/Integrations/Divi/Release/CheckStep.php b/src/Integrations/Divi/Release/CheckStep.php new file mode 100644 index 0000000..fffac3d --- /dev/null +++ b/src/Integrations/Divi/Release/CheckStep.php @@ -0,0 +1,62 @@ + '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 $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, 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 + ); + } +} diff --git a/src/Integrations/Divi/Release/CompatibilityCheck.php b/src/Integrations/Divi/Release/CompatibilityCheck.php new file mode 100644 index 0000000..3ed50c4 --- /dev/null +++ b/src/Integrations/Divi/Release/CompatibilityCheck.php @@ -0,0 +1,92 @@ + $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 */ + 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()]; + } + } +} diff --git a/src/Integrations/Divi/Release/FormatCheck.php b/src/Integrations/Divi/Release/FormatCheck.php new file mode 100644 index 0000000..0b21f43 --- /dev/null +++ b/src/Integrations/Divi/Release/FormatCheck.php @@ -0,0 +1,79 @@ +)|null $wpBlocks null = parse_blocks(). */ + public function __construct(private ?string $file = null, private ?Closure $wpBlocks = null) + { + } + + /** @return list */ + public function problems(): array + { + $file = $this->file ?? self::REFERENCE; + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin file. + $content = is_file($file) ? file_get_contents($file) : false; + if (! is_string($content) || $content === '') { + return ['Der Referenzinhalt der Prüfung fehlt im Plugin.']; + } + + $document = (new Parser())->parse($content); + $problems = []; + if (Serializer::document($document) !== $content) { + $problems[] = 'Parser und Serializer geben den Referenzinhalt nicht byte-gleich zurück.'; + } + $wordPress = ($this->wpBlocks ?? static fn (string $markup): array => parse_blocks($markup))($content); + if (self::structure($document->children()) !== self::wpStructure($wordPress)) { + $problems[] = 'WordPress liest eine andere Blockstruktur als LIT MCP.'; + } + + return $problems; + } + + /** + * @param list $blocks + * @return list}> + */ + private static function structure(array $blocks): array + { + return array_map( + static fn (Block $block): array => [$block->name(), self::structure($block->children())], + $blocks + ); + } + + /** + * @param array $blocks parse_blocks() result; freeform parts (blockName null) are left out. + * @return list}> + */ + private static function wpStructure(array $blocks): array + { + $structure = []; + foreach ($blocks as $block) { + if (is_array($block) && is_string($block['blockName'] ?? null)) { + $inner = is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : []; + $structure[] = [$block['blockName'], self::wpStructure($inner)]; + } + } + + return $structure; + } +} diff --git a/src/Integrations/Divi/Release/WriteProbe.php b/src/Integrations/Divi/Release/WriteProbe.php new file mode 100644 index 0000000..dfab546 --- /dev/null +++ b/src/Integrations/Divi/Release/WriteProbe.php @@ -0,0 +1,218 @@ + 'section', + 'label' => 'LIT MCP Prüfung', + 'rows' => [ + [ + 'columns' => '1_2,1_2', + 'modules' => [ + [ + ['type' => 'heading', 'text' => 'litcheck-heading', 'level' => 'h2'], + ['type' => 'text', 'content' => '

litcheck-text

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

litcheck-blurb-text

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

litcheck-toggle-text

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

a

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

t

']]], + ['type' => 'icon-list', 'items' => [['text' => 'litcheck-icon-list']]], + ], + ], + ], + ]]; + + public const TEXTS = [ + 'litcheck-heading', 'litcheck-text', 'litcheck-button', 'litcheck-blurb', 'litcheck-cta', 'litcheck-toggle', + 'litcheck-accordion', 'litcheck-tab', 'litcheck-icon-list', + ]; + + /** Only pages older than this are orphans – a running check's page is younger. */ + private const ORPHAN_AGE = '10 minutes ago'; + + /** + * @param string|null $diviDir PHP warnings from files below it fail the check; null = DiviEnvironment::themeDir(). + */ + public function __construct( + private ContentComposer $composer, + private ?PageRenderer $renderer = null, + private ?string $diviDir = null + ) { + } + + /** @return list German problems; [] = green. */ + public function run(): array + { + $id = wp_insert_post([ + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_title' => self::TITLE . ' ' . gmdate('Y-m-d H:i'), + 'post_content' => '', + 'meta_input' => [self::MARKER => '1'], + ], true); + if ($id instanceof WP_Error || (int) $id <= 0) { + return ['Die Testseite konnte nicht angelegt werden: ' + . ($id instanceof WP_Error ? $id->get_error_message() : 'keine ID')]; + } + $id = (int) $id; + + try { + $problems = $this->exercise($id); + } catch (Throwable $e) { + $problems = ['Schreibtest abgebrochen: ' . $e->getMessage()]; + } finally { + if (! self::delete($id)) { + $problems[] = sprintf( + 'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.', + $id + ); + } + } + + return $problems; + } + + /** @return list IDs of check pages a dead check left behind, deleted now. */ + public static function removeOrphans(): array + { + $ids = get_posts([ + 'post_type' => 'page', + 'post_status' => 'any', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked pages at most. + 'meta_key' => self::MARKER, + 'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']], + 'fields' => 'ids', + 'posts_per_page' => 20, + 'no_found_rows' => true, + 'suppress_filters' => true, + ]); + + $removed = []; + foreach (is_array($ids) ? $ids : [] as $id) { + if (self::delete((int) $id)) { + $removed[] = (int) $id; + } + } + + return $removed; + } + + /** @return list */ + private function exercise(int $id): array + { + $written = ReleaseGate::whileProbing( + $id, + fn (): array|WP_Error => $this->composer->write($id, 'page', self::SECTIONS) + ); + if ($written instanceof WP_Error) { + return ['Die Kurzform der Testseite wurde abgelehnt: ' . Errors::clientMessage($written)]; + } + + $post = get_post($id); + if (! $post instanceof WP_Post) { + return ['Die Testseite ist nach dem Schreiben nicht lesbar.']; + } + + $problems = []; + if (! str_contains((string) $post->post_content, ' \ No newline at end of file diff --git a/src/Integrations/Divi/Schema/ShortformCheck.php b/src/Integrations/Divi/Schema/ShortformCheck.php new file mode 100644 index 0000000..f6e8cfa --- /dev/null +++ b/src/Integrations/Divi/Schema/ShortformCheck.php @@ -0,0 +1,51 @@ + 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; + } +} diff --git a/tests/Integration/divi-shortform-check.php b/tests/Integration/divi-shortform-check.php index 2489ed7..17afd15 100644 --- a/tests/Integration/divi-shortform-check.php +++ b/tests/Integration/divi-shortform-check.php @@ -10,7 +10,7 @@ defined('WP_CLI') && WP_CLI || exit; use LIT\MCP\Integrations\Divi\Schema\DiviModules; -use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog; +use LIT\MCP\Integrations\Divi\Schema\ShortformCheck; use LIT\MCP\Integrations\Divi\Schema\ShortformMap; if (! DiviModules::isAvailable()) { @@ -19,31 +19,7 @@ if (! DiviModules::isAvailable()) { $started = microtime(true); $catalog = (new DiviModules())->catalog(); -$problems = []; -foreach (ShortformMap::types() as $type) { - $name = ModuleCatalog::normalize($type); - if (! $catalog->has($name)) { - $problems[] = $name . ': unknown module'; - continue; - } - - foreach (ShortformMap::fields($type) as $field => $spec) { - if (! $catalog->isKnownPath($name, (string) $spec['path'])) { - $problems[] = sprintf( - '%s.%s: unknown path %s (closest: %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 is no allowed child (%s)', $type, $child, implode(', ', $catalog->childrenOf($name))); - } -} +$problems = ShortformCheck::problems($catalog); if ($problems !== []) { WP_CLI::error("Short form does not match the catalog:\n" . implode("\n", $problems)); diff --git a/tests/Unit/Integrations/Divi/Release/CheckReportTest.php b/tests/Unit/Integrations/Divi/Release/CheckReportTest.php new file mode 100644 index 0000000..959034b --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/CheckReportTest.php @@ -0,0 +1,61 @@ + 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()); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php b/tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php new file mode 100644 index 0000000..37d4671 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php @@ -0,0 +1,92 @@ +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); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/FormatCheckTest.php b/tests/Unit/Integrations/Divi/Release/FormatCheckTest.php new file mode 100644 index 0000000..d9826b0 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/FormatCheckTest.php @@ -0,0 +1,50 @@ + [ + '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() + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php new file mode 100644 index 0000000..6e0aa16 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php @@ -0,0 +1,159 @@ + */ + private array $deleted = []; + + protected function setUp(): void + { + parent::setUp(); + // The probe must work while Divi is untested – that is when it runs. + ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0')); + $this->deleted = []; + Functions\when('wp_insert_post')->justReturn(41); + Functions\when('get_post')->justReturn( + new WP_Post(['ID' => 41, 'post_content' => '']) + ); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on' + ); + Functions\when('wp_delete_post')->alias(function (int $id, bool $force): WP_Post { + self::assertTrue($force, 'never to the trash'); + $this->deleted[] = $id; + + return new WP_Post(['ID' => $id]); + }); + } + + private function composer(?callable $write = null): ContentComposer + { + $composer = Mockery::mock(ContentComposer::class); + $default = 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' => '']; + }; + $composer->allows('write')->andReturnUsing($write ?? $default); + + 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(''); + + $probe = new WriteProbe($this->composer(), $this->renderer('litcheck-heading'), '/nonexistent-divi'); + $problems = $probe->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); + + $probe = new WriteProbe( + $this->composer(), + $this->renderer(implode(' ', WriteProbe::TEXTS)), + '/nonexistent-divi' + ); + $problems = $probe->run(); + + self::assertSame( + ['Die Testseite 41 konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.'], + $problems + ); + } + + public function testCreatesAMarkedDraftPage(): void + { + // wp_insert_post is already stubbed in setUp(); Functions\expect() cannot rewire the same + // function again (Brain Monkey keeps the first redefinition), so the args are captured here. + $captured = null; + Functions\when('wp_insert_post')->alias(static function (array $post, bool $wpError) use (&$captured): int { + $captured = $post; + + return 41; + }); + + $probe = new WriteProbe( + $this->composer(), + $this->renderer(implode(' ', WriteProbe::TEXTS)), + '/nonexistent-divi' + ); + $probe->run(); + + self::assertIsArray($captured); + self::assertSame('page', $captured['post_type']); + self::assertSame('draft', $captured['post_status']); + self::assertTrue(str_starts_with($captured['post_title'], '[LIT MCP Prüfung] ')); + self::assertSame(['_lit_mcp_check_page' => '1'], $captured['meta_input']); + } + + 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); + } +} diff --git a/tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php b/tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php new file mode 100644 index 0000000..85d7421 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Schema/ShortformCheckTest.php @@ -0,0 +1,31 @@ + Date: Wed, 30 Sep 2026 02:39:21 +0200 Subject: [PATCH 16/22] feat: Divi tab with REST check, release and withdrawal Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Admin/DiviTab.php | 296 ++++++++++++++++++ src/Admin/SettingsPage.php | 22 ++ .../Divi/Release/DiviCheckRoute.php | 88 ++++++ src/Plugin.php | 8 + src/Uninstaller.php | 29 +- tests/Stubs/WpRestResponse.php | 32 ++ tests/Unit/Admin/DiviTabTest.php | 213 +++++++++++++ tests/Unit/Admin/SettingsPageTest.php | 27 ++ .../Divi/Release/DiviCheckRouteTest.php | 93 ++++++ tests/Unit/UninstallTest.php | 22 +- tests/Unit/UninstallerTest.php | 30 +- tests/bootstrap.php | 1 + uninstall.php | 6 +- 13 files changed, 849 insertions(+), 18 deletions(-) create mode 100644 src/Admin/DiviTab.php create mode 100644 src/Integrations/Divi/Release/DiviCheckRoute.php create mode 100644 tests/Stubs/WpRestResponse.php create mode 100644 tests/Unit/Admin/DiviTabTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php diff --git a/src/Admin/DiviTab.php b/src/Admin/DiviTab.php new file mode 100644 index 0000000..263707b --- /dev/null +++ b/src/Admin/DiviTab.php @@ -0,0 +1,296 @@ + 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(); + $fresh = $report !== null + && $report->freshFor($version, Plugin::VERSION, $this->stateKey($version)); + if ($version === '' || ! $fresh) { + $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 '

' . esc_html__('Divi-Freigabe', 'wp-lit-mcp') . '

'; + echo '

' . esc_html__( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + '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' + ) . '

'; + if ($removed !== []) { + printf( + '

%s

', + 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 ''; + printf( + '', + esc_html__('Divi-Version', 'wp-lit-mcp'), + esc_html($status->version === '' ? __('nicht erkannt', 'wp-lit-mcp') : $status->version) + ); + printf( + '', + esc_html__('Zustand', 'wp-lit-mcp'), + esc_html(self::STATES[$status->state->value]), + esc_html($this->explanation($status)) + ); + if ($status->problems !== []) { + echo ''; + } + echo ''; + } + + private function explanation(ReleaseStatus $status): string + { + if ($status->state === ReleaseState::Incompatible) { + return __( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + '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)); + $releasedAt = (int) strtotime((string) ($status->release['released_at'] ?? '')); + + 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'), $releasedAt), + is_object($user) && isset($user->display_name) ? (string) $user->display_name : '?' + ); + } + + private function renderForms(ReleaseStatus $status, ?CheckReport $report): void + { + printf('
', esc_url(DiviCheckRoute::url())); + printf('', esc_attr(wp_create_nonce('wp_rest'))); + submit_button(__('Prüfung starten', 'wp-lit-mcp'), 'secondary', 'lit-mcp-divi-check', false); + echo '
'; + + if ($status->state === ReleaseState::Untested && $status->version !== '') { + $fresh = $report !== null + && $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version)); + if ($fresh) { + $this->actionForm( + self::RELEASE_ACTION, + /* translators: %s: Divi version */ + sprintf(__('Divi %s freigeben', 'wp-lit-mcp'), $status->version), + 'primary' + ); + } else { + echo '

' . esc_html__( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + 'Freigeben geht erst nach einer vollständig grünen Prüfung dieser Divi-Version – Prüfung erneut ausführen.', + 'wp-lit-mcp' + ) . '

'; + } + } + + 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('
', esc_url(admin_url('admin-post.php'))); + printf('', esc_attr($action)); + wp_nonce_field($action); + submit_button($label, $type, 'submit', false); + echo '
'; + } + + private function renderReport(CheckReport $report): void + { + printf( + '

%s

%s

    ', + 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('
  • %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 '
      '; + foreach ($step->problems as $problem) { + echo '
    • ' . esc_html($problem) . '
    • '; + } + echo '
    '; + } + echo '
  • '; + } + echo '

' . esc_html__( + 'Optional: eine Divi-Seite im Visual Builder öffnen und speichern.', + 'wp-lit-mcp' + ) . '

'; + } + + 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(); + } +} diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php index b35d112..0d00b67 100644 --- a/src/Admin/SettingsPage.php +++ b/src/Admin/SettingsPage.php @@ -160,6 +160,28 @@ final class SettingsPage 'revoked' => ['success', __('Verbindung widerrufen.', 'wp-lit-mcp')], 'revoke_failed' => ['error', __('Die Verbindung konnte nicht widerrufen werden.', 'wp-lit-mcp')], 'cleared' => ['success', __('Protokoll geleert.', 'wp-lit-mcp')], + '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', + __( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + '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'), + ], default => null, }; diff --git a/src/Integrations/Divi/Release/DiviCheckRoute.php b/src/Integrations/Divi/Release/DiviCheckRoute.php new file mode 100644 index 0000000..c1ae3df --- /dev/null +++ b/src/Integrations/Divi/Release/DiviCheckRoute.php @@ -0,0 +1,88 @@ + '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); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index fdb5e34..16468cb 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -12,6 +12,7 @@ use LIT\MCP\Admin\ConnectionRepository; use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\Admin\ConnectionTab; use LIT\MCP\Admin\ConnectionUsage; +use LIT\MCP\Admin\DiviTab; use LIT\MCP\Admin\LimitsTab; use LIT\MCP\Admin\LogTab; use LIT\MCP\Admin\SettingsPage; @@ -24,6 +25,8 @@ use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; +use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; +use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\DiviToolGate; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess; @@ -134,6 +137,10 @@ final class Plugin $divi->themeBuilder() ); + // 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(); + $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); (new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register(); (new ServerRegistrar( @@ -153,6 +160,7 @@ final class Plugin new ToolsTab($registry, $policy), new LogTab($changeLog, $limits, $registry), new LimitsTab(), + new DiviTab(log: $changeLog), ]; self::$settingsPage = new SettingsPage( $tabs, diff --git a/src/Uninstaller.php b/src/Uninstaller.php index fe70731..1a1fdb2 100644 --- a/src/Uninstaller.php +++ b/src/Uninstaller.php @@ -7,19 +7,21 @@ namespace LIT\MCP; defined('ABSPATH') || exit; /** - * Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options, the endpoint-check - * transient, the log-cleanup cron hook and the change log table – on every site of a - * network – and the Protokoll tab's per-user screen option. + * Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options, the endpoint-check and + * Divi-release transients, the log-cleanup cron hook and the change log table – on every + * site of a network – and the Protokoll tab's per-user screen option. * * Deliberately self-contained (no reference to any other LIT\MCP\* class): uninstall.php * is executed by WordPress core standalone, without loading the plugin's main file first, * so the Composer/Jetpack autoloader is not guaranteed to have run. The option/hook/table * names below are therefore duplicated from Access::OPTION, Abilities\ExposurePolicy::OPTION, - * Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Admin\ConnectionStatus:: - * ENDPOINT_TRANSIENT, ChangeLog\ChangeLogCleanup::HOOK, ChangeLog\ChangeLog::TABLE and - * Admin\LogTab::PER_PAGE_OPTION on - * purpose; tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in - * sync with those constants. + * Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\ + * ReleaseStore::OPTION, Integrations\Divi\Release\CheckReportStore::OPTION, Integrations\ + * Divi\Release\DiviCheckRoute::LOCK, Admin\ConnectionStatus::ENDPOINT_TRANSIENT, + * Integrations\Divi\Release\ReleaseResolver::CACHE, ChangeLog\ChangeLogCleanup::HOOK, + * ChangeLog\ChangeLog::TABLE and Admin\LogTab::PER_PAGE_OPTION on purpose; + * tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in sync with + * those constants. * * Never touches the bundled wp-media/mcp-oauth library's data (option mcp_jwt_secret, * option wpmedia_mcp_oauth_rewrite_version, user meta mcp_refresh_jti_*, application @@ -28,8 +30,11 @@ defined('ABSPATH') || exit; */ final class Uninstaller { - private const OPTIONS = ['lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version']; - private const TRANSIENT = 'lit_mcp_endpoint_check'; + 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']; private const CRON_HOOK = 'lit_mcp_log_cleanup'; private const TABLE = 'lit_mcp_log'; @@ -61,7 +66,9 @@ final class Uninstaller foreach (self::OPTIONS as $option) { delete_option($option); } - delete_transient(self::TRANSIENT); + foreach (self::TRANSIENTS as $transient) { + delete_transient($transient); + } wp_clear_scheduled_hook(self::CRON_HOOK); $table = $wpdb->prefix . self::TABLE; diff --git a/tests/Stubs/WpRestResponse.php b/tests/Stubs/WpRestResponse.php new file mode 100644 index 0000000..e410bae --- /dev/null +++ b/tests/Stubs/WpRestResponse.php @@ -0,0 +1,32 @@ + $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 */ + public function get_headers(): array + { + return $this->headers; + } + } +} + +if (! class_exists('WP_REST_Response')) { + class WP_REST_Response extends WP_HTTP_Response + { + } +} diff --git a/tests/Unit/Admin/DiviTabTest.php b/tests/Unit/Admin/DiviTabTest.php new file mode 100644 index 0000000..3e347ec --- /dev/null +++ b/tests/Unit/Admin/DiviTabTest.php @@ -0,0 +1,213 @@ +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 + { + $reports = [null, self::report(false), self::report(true, '5.2.9'), self::report(true, '5.3.0', 'old')]; + foreach ($reports 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 ''; + }); + 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', $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); + } +} diff --git a/tests/Unit/Admin/SettingsPageTest.php b/tests/Unit/Admin/SettingsPageTest.php index 23314c5..848a723 100644 --- a/tests/Unit/Admin/SettingsPageTest.php +++ b/tests/Unit/Admin/SettingsPageTest.php @@ -158,4 +158,31 @@ final class SettingsPageTest extends TestCase $page->register(); } + + /** @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> */ + 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'], + ]; + } } diff --git a/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php b/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php new file mode 100644 index 0000000..8c5cd4a --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php @@ -0,0 +1,93 @@ +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(); + } +} diff --git a/tests/Unit/UninstallTest.php b/tests/Unit/UninstallTest.php index a370a91..87aba14 100644 --- a/tests/Unit/UninstallTest.php +++ b/tests/Unit/UninstallTest.php @@ -10,6 +10,10 @@ use LIT\MCP\Access; use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; +use LIT\MCP\Integrations\Divi\Release\CheckReportStore; +use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; +use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; +use LIT\MCP\Integrations\Divi\Release\ReleaseStore; use LIT\MCP\Limits; use Mockery; @@ -33,7 +37,11 @@ final class UninstallTest extends TestCase Functions\when('get_sites')->justReturn([1, 2]); Functions\expect('switch_to_blog')->twice(); Functions\expect('restore_current_blog')->twice(); - Functions\expect('delete_transient')->twice()->with(ConnectionStatus::ENDPOINT_TRANSIENT); + $transients = []; + Functions\when('delete_transient')->alias(static function (string $transient) use (&$transients): bool { + $transients[] = $transient; + return true; + }); Functions\expect('wp_clear_scheduled_hook')->twice()->with(ChangeLogCleanup::HOOK); Functions\expect('delete_metadata')->once()->with('user', 0, 'lit_mcp_log_per_page', '', true); $deleted = []; @@ -44,9 +52,19 @@ final class UninstallTest extends TestCase require dirname(__DIR__, 2) . '/uninstall.php'; - $own = [Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION]; + $own = [ + Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, + ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK, + ]; self::assertSame(array_merge($own, $own), $deleted); self::assertNotContains('mcp_jwt_secret', $deleted); self::assertNotContains('wpmedia_mcp_oauth_rewrite_version', $deleted); + self::assertSame( + [ + ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, + ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, + ], + $transients + ); } } diff --git a/tests/Unit/UninstallerTest.php b/tests/Unit/UninstallerTest.php index df5f23b..edb9038 100644 --- a/tests/Unit/UninstallerTest.php +++ b/tests/Unit/UninstallerTest.php @@ -11,6 +11,10 @@ use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\Admin\LogTab; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; +use LIT\MCP\Integrations\Divi\Release\CheckReportStore; +use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; +use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; +use LIT\MCP\Integrations\Divi\Release\ReleaseStore; use LIT\MCP\Limits; use LIT\MCP\Uninstaller; use Mockery; @@ -36,7 +40,11 @@ final class UninstallerTest extends TestCase Functions\when('is_multisite')->justReturn(false); Functions\expect('switch_to_blog')->never(); Functions\expect('restore_current_blog')->never(); - Functions\expect('delete_transient')->once()->with(ConnectionStatus::ENDPOINT_TRANSIENT); + $transients = []; + Functions\when('delete_transient')->alias(static function (string $transient) use (&$transients): bool { + $transients[] = $transient; + return true; + }); Functions\expect('wp_clear_scheduled_hook')->once()->with(ChangeLogCleanup::HOOK); // Review m-8: the Protokoll screen option is user meta (network-wide): removed once. Functions\expect('delete_metadata')->once()->with('user', 0, LogTab::PER_PAGE_OPTION, '', true); @@ -48,8 +56,12 @@ final class UninstallerTest extends TestCase Uninstaller::run(); + self::assertSame([ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE], $transients); self::assertSame( - [Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION], + [ + Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, + ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK, + ], $deleted ); } @@ -63,11 +75,23 @@ final class UninstallerTest extends TestCase Functions\when('get_sites')->justReturn([1, 2]); Functions\expect('switch_to_blog')->twice(); Functions\expect('restore_current_blog')->twice(); - Functions\expect('delete_transient')->twice()->with(ConnectionStatus::ENDPOINT_TRANSIENT); + $transients = []; + Functions\when('delete_transient')->alias(static function (string $transient) use (&$transients): bool { + $transients[] = $transient; + return true; + }); Functions\expect('wp_clear_scheduled_hook')->twice()->with(ChangeLogCleanup::HOOK); Functions\expect('delete_metadata')->once()->with('user', 0, LogTab::PER_PAGE_OPTION, '', true); Functions\when('delete_option')->justReturn(true); Uninstaller::run(); + + self::assertSame( + [ + ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, + ConnectionStatus::ENDPOINT_TRANSIENT, ReleaseResolver::CACHE, + ], + $transients + ); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index fa16d19..d8242fd 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -14,6 +14,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/Stubs/WpError.php'; require_once __DIR__ . '/Stubs/WpUser.php'; require_once __DIR__ . '/Stubs/WpRestRequest.php'; +require_once __DIR__ . '/Stubs/WpRestResponse.php'; require_once __DIR__ . '/Stubs/Wpdb.php'; require_once __DIR__ . '/Stubs/WpPost.php'; require_once __DIR__ . '/Stubs/WpTerm.php'; diff --git a/uninstall.php b/uninstall.php index 04abb0e..e68a9d5 100644 --- a/uninstall.php +++ b/uninstall.php @@ -4,8 +4,10 @@ * Uninstall WP-LIT-MCP: removes its own data only, on every site of a network. * * Removed: options lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version, - * transient lit_mcp_endpoint_check, cron hook lit_mcp_log_cleanup, table {prefix}lit_mcp_log, user meta - * lit_mcp_log_per_page (Protokoll screen option, removed once for all users). + * lit_mcp_divi_releases, lit_mcp_divi_check_report, lit_mcp_divi_check_lock, transients + * lit_mcp_endpoint_check and lit_mcp_divi_state, cron hook lit_mcp_log_cleanup, table + * {prefix}lit_mcp_log, user meta lit_mcp_log_per_page (Protokoll screen option, removed once + * for all users). * * Kept on purpose: data of the bundled wp-media/mcp-oauth library (option mcp_jwt_secret, * option wpmedia_mcp_oauth_rewrite_version, user meta mcp_refresh_jti_*, application passwords From b441febd818631beeb58f090928b0cf71ff77ae3 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 02:45:50 +0200 Subject: [PATCH 17/22] fix: atomic Divi check lock, failed-check notice, test fixes Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Admin/SettingsPage.php | 4 + .../Divi/Release/DiviCheckRoute.php | 51 ++++++++-- tests/Unit/Admin/DiviTabTest.php | 1 + tests/Unit/Admin/SettingsPageTest.php | 1 + .../Divi/Release/DiviCheckRouteTest.php | 96 ++++++++++++++++--- 5 files changed, 132 insertions(+), 21 deletions(-) diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php index 0d00b67..b81246f 100644 --- a/src/Admin/SettingsPage.php +++ b/src/Admin/SettingsPage.php @@ -165,6 +165,10 @@ final class SettingsPage 'warning', __('Es läuft bereits eine Divi-Prüfung; bitte in ein paar Minuten erneut versuchen.', 'wp-lit-mcp'), ], + 'divi_check_failed' => [ + 'error', + __('Die Divi-Prüfung ist abgebrochen; Details stehen im PHP-Error-Log.', 'wp-lit-mcp'), + ], 'divi_released' => ['success', __('Die Divi-Version ist für LIT MCP freigegeben.', 'wp-lit-mcp')], 'divi_release_refused' => [ 'error', diff --git a/src/Integrations/Divi/Release/DiviCheckRoute.php b/src/Integrations/Divi/Release/DiviCheckRoute.php index c1ae3df..2078f3b 100644 --- a/src/Integrations/Divi/Release/DiviCheckRoute.php +++ b/src/Integrations/Divi/Release/DiviCheckRoute.php @@ -7,6 +7,8 @@ namespace LIT\MCP\Integrations\Divi\Release; use Closure; use LIT\MCP\Admin\DiviTab; use LIT\MCP\Admin\SettingsPage; +use LIT\MCP\Log; +use Throwable; use WP_REST_Response; defined('ABSPATH') || exit; @@ -15,8 +17,12 @@ 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. + * One check at a time: an atomic "INSERT IGNORE" on the options table (self::LOCK) is the lock – add_option() reads + * then writes (wp-includes/option.php) and is not atomic, so two simultaneous requests could both "acquire" it. A + * lock older than LOCK_TTL is a dead check's, taken over with a conditional UPDATE (compare-and-swap on the value + * just read) so only one of several concurrent takers wins. A check that throws still releases the lock (finally) + * and redirects with divi_check_failed (logged, never shown to the browser as an unhandled 500). Answers 303 back + * to the tab. */ final class DiviCheckRoute { @@ -59,6 +65,9 @@ final class DiviCheckRoute ($this->reports ?? new CheckReportStore())->save($report); ReleaseGate::forget(); $notice = 'divi_checked'; + } catch (Throwable $e) { + Log::failure('divi check', $e); + $notice = 'divi_check_failed'; } finally { delete_option(self::LOCK); } @@ -73,16 +82,44 @@ final class DiviCheckRoute private static function lock(): bool { - if (add_option(self::LOCK, time(), '', false)) { + global $wpdb; + + if (self::insertLock($wpdb, time())) { return true; } - $since = (int) get_option(self::LOCK, 0); - if ($since > 0 && time() - $since < self::LOCK_TTL) { + // get_var(), not get_option(): the row was written with raw SQL, bypassing the options cache. + $since = $wpdb->get_var($wpdb->prepare( + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", + self::LOCK + )); + if ($since === null) { + // The lock was released between our failed insert and this read; try once more. + return self::insertLock($wpdb, time()); + } + if (time() - (int) $since < self::LOCK_TTL) { return false; } - delete_option(self::LOCK); - return (bool) add_option(self::LOCK, time(), '', false); + $taken = $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", + (string) time(), + self::LOCK, + $since + )); + + return (int) $taken === 1; + } + + /** INSERT IGNORE: 1 row affected = the lock did not exist and is now ours; 0 = someone else holds it. */ + private static function insertLock(\wpdb $wpdb, int $now): bool + { + $inserted = $wpdb->query($wpdb->prepare( + "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + self::LOCK, + (string) $now + )); + + return (int) $inserted === 1; } } diff --git a/tests/Unit/Admin/DiviTabTest.php b/tests/Unit/Admin/DiviTabTest.php index 3e347ec..a26c1a1 100644 --- a/tests/Unit/Admin/DiviTabTest.php +++ b/tests/Unit/Admin/DiviTabTest.php @@ -73,6 +73,7 @@ final class DiviTabTest extends TestCase { $reports = [null, self::report(false), self::report(true, '5.2.9'), self::report(true, '5.3.0', 'old')]; foreach ($reports as $report) { + $this->redirect = null; $releases = Mockery::mock(ReleaseStore::class); $releases->expects('add')->never(); diff --git a/tests/Unit/Admin/SettingsPageTest.php b/tests/Unit/Admin/SettingsPageTest.php index 848a723..0cea111 100644 --- a/tests/Unit/Admin/SettingsPageTest.php +++ b/tests/Unit/Admin/SettingsPageTest.php @@ -179,6 +179,7 @@ final class SettingsPageTest extends TestCase return [ ['divi_checked', 'success', 'Divi-Prüfung abgeschlossen'], ['divi_check_running', 'warning', 'bereits eine Divi-Prüfung'], + ['divi_check_failed', 'error', 'PHP-Error-Log'], ['divi_released', 'success', 'für LIT MCP freigegeben'], ['divi_release_refused', 'error', 'Prüfung erneut ausführen'], ['divi_withdrawn', 'success', 'Freigabe zurückgezogen'], diff --git a/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php b/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php index 8c5cd4a..56c45ad 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php @@ -11,6 +11,7 @@ use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use RuntimeException; final class DiviCheckRouteTest extends TestCase { @@ -24,6 +25,28 @@ final class DiviCheckRouteTest extends TestCase Functions\when('get_current_user_id')->justReturn(1); } + protected function tearDown(): void + { + unset($GLOBALS['wpdb']); + parent::tearDown(); + } + + /** Mock of the global $wpdb, its INSERT IGNORE returning $inserted rows affected. */ + private static function wpdb(int $inserted): \wpdb + { + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->expects('query')->once()->with(Mockery::on(static function (string $sql): bool { + return str_starts_with( + $sql, + "INSERT IGNORE INTO wp_options (option_name, option_value, autoload) " + . "VALUES ('lit_mcp_divi_check_lock', '" + ) && str_ends_with($sql, "', 'no')"); + }))->andReturn($inserted); + $GLOBALS['wpdb'] = $db; + + return $db; + } + public function testRegistersAPostRouteForAdministratorsOnly(): void { Functions\expect('register_rest_route')->once()->with( @@ -43,15 +66,12 @@ final class DiviCheckRouteTest extends TestCase public function testRunsTheCheckStoresTheReportAndRedirectsToTheTab(): void { + self::wpdb(1); $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(); @@ -63,8 +83,8 @@ final class DiviCheckRouteTest extends TestCase public function testASecondCheckWaitsForTheFirst(): void { - Functions\when('add_option')->justReturn(false); - Functions\when('get_option')->alias(static fn (): int => time()); + $db = self::wpdb(0); + $db->expects('get_var')->once()->andReturn((string) time()); Functions\expect('delete_option')->never(); $reports = Mockery::mock(CheckReportStore::class); $reports->expects('save')->never(); @@ -76,18 +96,66 @@ final class DiviCheckRouteTest extends TestCase 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); + $db = self::wpdb(0); + $db->expects('get_var')->once()->andReturn('1'); + $db->expects('query')->once()->with(Mockery::on(static function (string $sql): bool { + return str_starts_with($sql, "UPDATE wp_options SET option_value = '") + && str_ends_with( + $sql, + "' WHERE option_name = 'lit_mcp_divi_check_lock' AND option_value = '1'" + ); + }))->andReturn(1); + Functions\expect('delete_option')->once()->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(); + $response = (new DiviCheckRoute(static fn () => $check, $reports))->handle(); + + self::assertStringContainsString('lit_mcp_notice=divi_checked', $response->get_headers()['Location']); + } + + public function testATakeoverLostToAnotherRequestWaitsForIt(): void + { + $db = self::wpdb(0); + $db->expects('get_var')->once()->andReturn('1'); + $db->expects('query')->once()->with(Mockery::on(static fn (string $sql): bool => str_starts_with( + $sql, + "UPDATE wp_options SET option_value = '" + )))->andReturn(0); + 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 testTheLockIsReleasedAndTheCheckMarkedFailedWhenItThrows(): void + { + self::wpdb(1); + Functions\expect('delete_option')->once()->with(DiviCheckRoute::LOCK); + $check = Mockery::mock(CompatibilityCheck::class); + $check->expects('run')->andThrow(new RuntimeException('divi kaputt')); + $reports = Mockery::mock(CheckReportStore::class); + $reports->expects('save')->never(); + // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + + try { + $response = (new DiviCheckRoute(static fn () => $check, $reports))->handle(); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertStringContainsString('lit_mcp_notice=divi_check_failed', $response->get_headers()['Location']); + self::assertStringContainsString( + 'divi check failed: RuntimeException: divi kaputt', + (string) file_get_contents($log) + ); + unlink($log); } } From ed806a1cc844c613dbe0340a2e52403dae36af2a Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 02:52:17 +0200 Subject: [PATCH 18/22] feat: wp lit-mcp divi-check with diff and reference update Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Cli/DiviCheckCommand.php | 192 ++++++++++++++++++ src/Integrations/Divi/Release/LineDiff.php | 85 ++++++++ src/Plugin.php | 2 + tests/Unit/Cli/DiviCheckCommandTest.php | 174 ++++++++++++++++ .../Divi/Release/LineDiffTest.php | 35 ++++ 5 files changed, 488 insertions(+) create mode 100644 src/Cli/DiviCheckCommand.php create mode 100644 src/Integrations/Divi/Release/LineDiff.php create mode 100644 tests/Unit/Cli/DiviCheckCommandTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/LineDiffTest.php diff --git a/src/Cli/DiviCheckCommand.php b/src/Cli/DiviCheckCommand.php new file mode 100644 index 0000000..43bf24f --- /dev/null +++ b/src/Cli/DiviCheckCommand.php @@ -0,0 +1,192 @@ +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 $args + * @param array $assoc + */ + public function __invoke(array $args, array $assoc): void + { + $code = $this->run($assoc); + if ($code !== 0) { + \WP_CLI::halt($code); + } + } + + /** @param array $assoc */ + public function run(array $assoc): int + { + if (! current_user_can('manage_options')) { + ($this->out)('Bitte mit --user= 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; + } +} diff --git a/src/Integrations/Divi/Release/LineDiff.php b/src/Integrations/Divi/Release/LineDiff.php new file mode 100644 index 0000000..7845af1 --- /dev/null +++ b/src/Integrations/Divi/Release/LineDiff.php @@ -0,0 +1,85 @@ + $old + * @param list $new + * @return list " 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 $a + * @param list $b + * @return list + */ + 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; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 16468cb..be4c530 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -21,6 +21,7 @@ use LIT\MCP\Admin\ToolsTab; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; +use LIT\MCP\Cli\DiviCheckCommand; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration; @@ -140,6 +141,7 @@ final class Plugin // 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(); + DiviCheckCommand::register($diviCheck); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); (new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register(); diff --git a/tests/Unit/Cli/DiviCheckCommandTest.php b/tests/Unit/Cli/DiviCheckCommandTest.php new file mode 100644 index 0000000..b3dc4ff --- /dev/null +++ b/tests/Unit/Cli/DiviCheckCommandTest.php @@ -0,0 +1,174 @@ + */ + 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> $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', + " 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', + "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, " Date: Wed, 30 Sep 2026 02:59:27 +0200 Subject: [PATCH 19/22] fix: write reference sources before fingerprints.json, atomic on failure Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Cli/DiviCheckCommand.php | 20 +++++++++----- tests/Unit/Cli/DiviCheckCommandTest.php | 35 ++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/Cli/DiviCheckCommand.php b/src/Cli/DiviCheckCommand.php index 43bf24f..0ec9779 100644 --- a/src/Cli/DiviCheckCommand.php +++ b/src/Cli/DiviCheckCommand.php @@ -172,13 +172,21 @@ final class DiviCheckCommand 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); + try { + // Sources first, fingerprints.json last: the fingerprints decide the release gate, so a write that + // fails before this last one leaves the gate exactly as it was. + foreach ($sourceDir === null ? [] : $prints as $id => $print) { + DiviReference::writeSource($id, $print->source, $sourceDir); + } + DiviReference::writeExpected( + array_map(static fn (SourceFingerprint $print): string => $print->hash, $prints), + $this->expectedFile + ); + } catch (RuntimeException $e) { + ($this->out)('Referenz nicht vollständig aktualisiert: ' . $e->getMessage()); + + return 1; } delete_transient(ReleaseResolver::CACHE); ReleaseGate::forget(); diff --git a/tests/Unit/Cli/DiviCheckCommandTest.php b/tests/Unit/Cli/DiviCheckCommandTest.php index b3dc4ff..859e49e 100644 --- a/tests/Unit/Cli/DiviCheckCommandTest.php +++ b/tests/Unit/Cli/DiviCheckCommandTest.php @@ -44,8 +44,12 @@ final class DiviCheckCommandTest extends TestCase parent::tearDown(); } - /** @param array> $problems Step key => problems. */ - private function command(array $problems = []): DiviCheckCommand + /** + * @param array> $problems Step key => problems. + * @param bool $expectSave True asserts save() is called exactly once (the green happy path); otherwise it is + * merely allowed, since some tests (e.g. no admin) never reach the save() call. + */ + private function command(array $problems = [], bool $expectSave = false): DiviCheckCommand { $steps = array_map( static fn (string $key): CheckStep => new CheckStep($key, $problems[$key] ?? []), @@ -54,7 +58,11 @@ final class DiviCheckCommandTest extends TestCase $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'); + if ($expectSave) { + $reports->expects('save')->once(); + } else { + $reports->allows('save'); + } return new DiviCheckCommand( static fn () => $check, @@ -78,7 +86,7 @@ final class DiviCheckCommandTest extends TestCase public function testPrintsTheStepsAndSucceedsWhenGreen(): void { - self::assertSame(0, $this->command()->run([])); + self::assertSame(0, $this->command(expectSave: true)->run([])); self::assertContains('✓ Schreibtest auf einer Wegwerfseite', $this->lines); } @@ -134,6 +142,25 @@ final class DiviCheckCommandTest extends TestCase self::assertFileDoesNotExist($this->expectedFile, 'all or nothing'); } + public function testUpdateReferenceWritesNothingWhenASourceIsUnwritable(): void + { + $this->writeDivi(); + chmod($this->sourceDir, 0500); + + try { + $code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]); + + self::assertSame(1, $code); + self::assertStringContainsString( + 'Referenz nicht vollständig aktualisiert:', + implode("\n", $this->lines) + ); + self::assertFileDoesNotExist($this->expectedFile, 'sources are written before fingerprints.json'); + } finally { + chmod($this->sourceDir, 0777); + } + } + public function testUpdateReferenceWritesFingerprintsAndSources(): void { $this->writeDivi(); From 35ea47d8328faa4796a3349400f02c982854f9c5 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 03:19:31 +0200 Subject: [PATCH 20/22] chore: release 0.6.0 with Divi release gate, smoke test and acceptance notes Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/overview.md | 8 ++ .../2026-09-30-divi-absicherung-abnahme.md | 15 +++ readme.txt | 13 +- src/Plugin.php | 2 +- tests/Integration/divi-release-smoke.sh | 114 ++++++++++++++++++ wp-lit-mcp.php | 2 +- 6 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md create mode 100755 tests/Integration/divi-release-smoke.sh diff --git a/docs/guides/overview.md b/docs/guides/overview.md index e974322..68e9b6f 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -62,3 +62,11 @@ Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in ein - Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`), `message`, `hint` (und ggf. `fields`). Dem `hint` folgen. - `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen. + +## 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`). diff --git a/docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md b/docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md new file mode 100644 index 0000000..4a44158 --- /dev/null +++ b/docs/superpowers/notes/2026-09-30-divi-absicherung-abnahme.md @@ -0,0 +1,15 @@ +# Divi-Absicherung – manuelle Abnahme + +Stand 2026-09-30, 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. diff --git a/readme.txt b/readme.txt index 3ad93a2..05f35ec 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: mcp, ai, divi, pods, oauth Requires at least: 6.9 Tested up to: 7.1 Requires PHP: 8.1 -Stable tag: 0.5.0 +Stable tag: 0.6.0 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -190,8 +190,19 @@ Die Änderung wirkt auf allen Seiten mit diesem Modul. LIT MCP speichert erst na betroffenen Seiten nennt, und einer ausdrücklichen Bestätigung (confirm); vorher entsteht eine Revision des Bibliothekseintrags. +== 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= lit-mcp divi-check [--diff] [--update-reference]`. + == 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. + = 0.5.0 = * Divi Theme Builder: Vorlagen, Bedingungen und Layouts auflisten (lit/tb-list), Vorlagen mit Kopfzeile, Inhalt und Fußzeile anlegen – immer deaktiviert (lit/tb-create-template) –, Bedingungen setzen diff --git a/src/Plugin.php b/src/Plugin.php index be4c530..ffa69c9 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -51,7 +51,7 @@ defined('ABSPATH') || exit; */ final class Plugin { - public const VERSION = '0.5.0'; + public const VERSION = '0.6.0'; private static string $pluginFile = ''; private static ?SettingsPage $settingsPage = null; diff --git a/tests/Integration/divi-release-smoke.sh b/tests/Integration/divi-release-smoke.sh new file mode 100755 index 0000000..32baaf5 --- /dev/null +++ b/tests/Integration/divi-release-smoke.sh @@ -0,0 +1,114 @@ +#!/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). +# X-Forwarded-Proto: https is required here: without it the request to http://localhost with the +# site's own Host header never reaches the route (a 404 before any auth check even runs), the same +# header mcp-smoke.sh and tb-smoke.sh already send for their own curl calls against this DDEV site. +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" -H 'X-Forwarded-Proto: https' -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" -H 'X-Forwarded-Proto: https' "$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\":\"

x

\"}]}]}") +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":"

x

"}]}]}') +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 --skip-column-names misreports this as a write query (it treats "insert" anywhere in the SQL, +# even inside a quoted value like 'lit/divi-insert', as an INSERT and prints "Rows affected" instead of the +# result) - wp eval with $wpdb->get_var() runs the same COUNT() reliably. +REFUSED_LOGGED=$(wp eval ' +global $wpdb; +echo (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->prefix}lit_mcp_log WHERE result = %s AND ability = %s", + "refused", + "lit/divi-insert" +));') +[ "$REFUSED_LOGGED" -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\":\"

x

\"}]}]}") +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 diff --git a/wp-lit-mcp.php b/wp-lit-mcp.php index ef21670..3d206d7 100644 --- a/wp-lit-mcp.php +++ b/wp-lit-mcp.php @@ -3,7 +3,7 @@ /** * Plugin Name: WP-LIT-MCP * Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für KI-Assistenten. - * Version: 0.5.0 + * Version: 0.6.0 * Requires at least: 6.9 * Requires PHP: 8.1 * Tested up to: 7.1 From 43c0a78374987cc2ffdb0410c4bc0c134a3eb06c Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 03:33:16 +0200 Subject: [PATCH 21/22] =?UTF-8?q?fix:=20Divi-Reiter=20ohne=20Divi,=20Zusta?= =?UTF-8?q?ndstexte,=20Schreibsperren=20und=20Aufr=C3=A4um-Filter=20aus=20?= =?UTF-8?q?der=20Abschlusspr=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behebt die fünf Befunde der Abschlussprüfung von divi-absicherung: Divi-Reiter ohne aktives Divi zeigt nur noch einen Hinweis (kein Status, keine Prüfung, kein Aufräumen), Zustandstexte werden erst beim Rendern übersetzt, DiviPages::setFlags() und DiviThemeBuilder::liveId(true) prüfen jetzt selbst die Freigabesperre, WriteProbe::removeOrphans() löscht nur noch Entwürfe mit passendem Titel, und wp lit-mcp divi-check --update-reference verweigert sich ohne Entwicklungskopie, bevor irgendetwas geschrieben wird. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Admin/DiviTab.php | 27 +++++++++++++----- src/Cli/DiviCheckCommand.php | 10 +++++-- src/Integrations/Divi/Pages/DiviPages.php | 8 +++++- src/Integrations/Divi/Release/WriteProbe.php | 20 ++++++++----- .../Divi/ThemeBuilder/DiviThemeBuilder.php | 10 ++++++- tests/Unit/Admin/DiviTabTest.php | 28 +++++++++++++++++++ tests/Unit/Cli/DiviCheckCommandTest.php | 21 ++++++++++++-- .../Divi/Release/ReleaseGuardsTest.php | 3 ++ .../Divi/Release/WriteProbeTest.php | 19 +++++++++++-- 9 files changed, 122 insertions(+), 24 deletions(-) diff --git a/src/Admin/DiviTab.php b/src/Admin/DiviTab.php index 263707b..d1bff9c 100644 --- a/src/Admin/DiviTab.php +++ b/src/Admin/DiviTab.php @@ -31,12 +31,6 @@ final class DiviTab implements AdminTab 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(). @@ -123,6 +117,16 @@ final class DiviTab implements AdminTab public function render(): void { + if ($this->version() === '') { + echo '

' . esc_html__( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text. + 'Divi ist auf dieser Website nicht aktiv; die Divi-Freigabe ist ohne Bedeutung.', + 'wp-lit-mcp' + ) . '

'; + + return; + } + $removed = WriteProbe::removeOrphans(); $status = ReleaseGate::status(); $report = $this->reports()->latest(); @@ -162,7 +166,7 @@ final class DiviTab implements AdminTab printf( '%s%s – %s', esc_html__('Zustand', 'wp-lit-mcp'), - esc_html(self::STATES[$status->state->value]), + esc_html($this->stateLabel($status->state)), esc_html($this->explanation($status)) ); if ($status->problems !== []) { @@ -175,6 +179,15 @@ final class DiviTab implements AdminTab echo ''; } + private function stateLabel(ReleaseState $state): string + { + return match ($state) { + ReleaseState::Released => __('freigegeben', 'wp-lit-mcp'), + ReleaseState::Untested => __('ungetestet', 'wp-lit-mcp'), + ReleaseState::Incompatible => __('inkompatibel', 'wp-lit-mcp'), + }; + } + private function explanation(ReleaseStatus $status): string { if ($status->state === ReleaseState::Incompatible) { diff --git a/src/Cli/DiviCheckCommand.php b/src/Cli/DiviCheckCommand.php index 0ec9779..f91dc7d 100644 --- a/src/Cli/DiviCheckCommand.php +++ b/src/Cli/DiviCheckCommand.php @@ -161,6 +161,13 @@ final class DiviCheckCommand } } + $sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))(); + if ($sourceDir === null) { + ($this->out)('Referenz nicht aktualisiert: nur in einer Entwicklungskopie mit tests/ möglich.'); + + return 1; + } + $themeDir = ($this->themeDir ?? DiviEnvironment::themeDir(...))(); try { $prints = DiviReference::current((string) $themeDir); @@ -172,11 +179,10 @@ final class DiviCheckCommand return 1; } - $sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))(); try { // Sources first, fingerprints.json last: the fingerprints decide the release gate, so a write that // fails before this last one leaves the gate exactly as it was. - foreach ($sourceDir === null ? [] : $prints as $id => $print) { + foreach ($prints as $id => $print) { DiviReference::writeSource($id, $print->source, $sourceDir); } DiviReference::writeExpected( diff --git a/src/Integrations/Divi/Pages/DiviPages.php b/src/Integrations/Divi/Pages/DiviPages.php index 147c653..fa35d4a 100644 --- a/src/Integrations/Divi/Pages/DiviPages.php +++ b/src/Integrations/Divi/Pages/DiviPages.php @@ -6,8 +6,10 @@ 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\DiviLocked; use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Integrations\Divi\Release\Mirror; +use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use RuntimeException; use WP_Post; @@ -50,9 +52,13 @@ final class DiviPages implements DiviAdapter ); } - /** @throws RuntimeException When a flag does not read back as set. */ + /** + * @throws RuntimeException When a flag does not read back as set. + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + */ public static function setFlags(int $postId): void { + ReleaseGate::assertWritable($postId); foreach (self::FLAGS as $key => $value) { update_post_meta($postId, $key, $value); } diff --git a/src/Integrations/Divi/Release/WriteProbe.php b/src/Integrations/Divi/Release/WriteProbe.php index dfab546..6a3b3e2 100644 --- a/src/Integrations/Divi/Release/WriteProbe.php +++ b/src/Integrations/Divi/Release/WriteProbe.php @@ -114,25 +114,31 @@ class WriteProbe return $problems; } - /** @return list IDs of check pages a dead check left behind, deleted now. */ + /** + * @return list IDs of check pages a dead check left behind, deleted now. Only drafts whose title starts + * with self::TITLE are considered – the marker meta alone is not enough (it is only a query hint here). + */ public static function removeOrphans(): array { - $ids = get_posts([ + $posts = get_posts([ 'post_type' => 'page', - 'post_status' => 'any', + 'post_status' => 'draft', // 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', + 'fields' => 'all', '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; + foreach (is_array($posts) ? $posts : [] as $post) { + if (! $post instanceof WP_Post || ! str_starts_with((string) $post->post_title, self::TITLE)) { + continue; + } + if (self::delete((int) $post->ID)) { + $removed[] = (int) $post->ID; } } diff --git a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php index c2a9871..29dbd55 100644 --- a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php +++ b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php @@ -126,9 +126,17 @@ class DiviThemeBuilder implements DiviAdapter ); } - /** The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. */ + /** + * The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. + * + * @throws DiviLocked When $create and Divi is not released (spec §5.5) – before anything is written. + */ public function liveId(bool $create = false): int { + if ($create) { + ReleaseGate::assertWritable(); + } + return function_exists('et_theme_builder_get_theme_builder_post_id') ? (int) et_theme_builder_get_theme_builder_post_id(true, $create) : 0; diff --git a/tests/Unit/Admin/DiviTabTest.php b/tests/Unit/Admin/DiviTabTest.php index a26c1a1..c5cd754 100644 --- a/tests/Unit/Admin/DiviTabTest.php +++ b/tests/Unit/Admin/DiviTabTest.php @@ -200,6 +200,34 @@ final class DiviTabTest extends TestCase self::assertStringContainsString('Freigabe zurückziehen', $this->rendered($status, self::report())); } + public function testWithoutDiviShowsOnlyAShortNotice(): void + { + ReleaseGate::useResolver(static function (): ReleaseStatus { + self::fail('resolver called although Divi is not active'); + }); + $reports = Mockery::mock(CheckReportStore::class); + $reports->expects('latest')->never(); + $tab = new DiviTab( + $reports, + Mockery::mock(ReleaseStore::class), + FakeChangeLog::forbidden(), + new AdminPostAction(static function (): void { + }), + static fn (): string => '', + static fn (string $version): string => 'key' + ); + + ob_start(); + $tab->render(); + $html = (string) ob_get_clean(); + + self::assertStringContainsString( + 'Divi ist auf dieser Website nicht aktiv; die Divi-Freigabe ist ohne Bedeutung.', + $html + ); + self::assertStringNotContainsString('Prüfung starten', $html); + } + public function testIncompatibleListsTheProblems(): void { $html = $this->rendered( diff --git a/tests/Unit/Cli/DiviCheckCommandTest.php b/tests/Unit/Cli/DiviCheckCommandTest.php index 859e49e..df93374 100644 --- a/tests/Unit/Cli/DiviCheckCommandTest.php +++ b/tests/Unit/Cli/DiviCheckCommandTest.php @@ -49,8 +49,11 @@ final class DiviCheckCommandTest extends TestCase * @param bool $expectSave True asserts save() is called exactly once (the green happy path); otherwise it is * merely allowed, since some tests (e.g. no admin) never reach the save() call. */ - private function command(array $problems = [], bool $expectSave = false): DiviCheckCommand - { + private function command( + array $problems = [], + bool $expectSave = false, + bool $noSourceDir = false + ): DiviCheckCommand { $steps = array_map( static fn (string $key): CheckStep => new CheckStep($key, $problems[$key] ?? []), [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE] @@ -72,7 +75,7 @@ final class DiviCheckCommandTest extends TestCase }, fn (): ?string => $this->themeDir, $this->expectedFile, - fn (): ?string => $this->sourceDir + $noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir ); } @@ -105,6 +108,18 @@ final class DiviCheckCommandTest extends TestCase self::assertFileDoesNotExist($this->expectedFile); } + public function testUpdateReferenceRefusesWithoutADevelopmentSourceDir(): void + { + $code = $this->command(noSourceDir: true)->run(['update-reference' => true]); + + self::assertSame(1, $code); + self::assertStringContainsString( + 'Referenz nicht aktualisiert: nur in einer Entwicklungskopie mit tests/ möglich.', + implode("\n", $this->lines) + ); + self::assertFileDoesNotExist($this->expectedFile); + } + public function testDiffShowsWhatChangedInAMirroredFunction(): void { file_put_contents( diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php index 3accd9a..be2c82d 100644 --- a/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php +++ b/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php @@ -7,6 +7,7 @@ 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\DiviPages; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\ReleaseGate; @@ -56,6 +57,8 @@ final class ReleaseGuardsTest extends TestCase 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)]; + yield 'pages setFlags' => [static fn () => DiviPages::setFlags(7)]; + yield 'tb liveId create' => [static fn () => (new DiviThemeBuilder())->liveId(true)]; } /** @dataProvider writes */ diff --git a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php index 6e0aa16..4f92374 100644 --- a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php +++ b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php @@ -149,11 +149,24 @@ final class WriteProbeTest extends TestCase { 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]); + && $query['post_status'] === 'draft' + && $query['fields'] === 'all' + && ($query['date_query'][0]['before'] ?? null) === '10 minutes ago'))->andReturn([ + new WP_Post(['ID' => 51, 'post_title' => WriteProbe::TITLE . ' 2026-01-01 00:00']), + new WP_Post(['ID' => 52, 'post_title' => WriteProbe::TITLE . ' 2026-01-02 00:00']), + ]); self::assertSame([51, 52], WriteProbe::removeOrphans()); self::assertSame([51, 52], $this->deleted); } + + public function testKeepsAMarkedDraftWhoseTitleDoesNotMatch(): void + { + Functions\when('get_posts')->justReturn([ + new WP_Post(['ID' => 60, 'post_title' => 'Ein anderer Entwurf']), + ]); + + self::assertSame([], WriteProbe::removeOrphans()); + self::assertSame([], $this->deleted); + } } From 3d14a2e187891fa7306fee7f070649c6ff3a8c37 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 03:36:25 +0200 Subject: [PATCH 22/22] docs: Divi safeguard security audit Co-Authored-By: Claude Opus 5.5 (1M context) --- ...6-09-30-divi-absicherung-security-audit.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/superpowers/notes/2026-09-30-divi-absicherung-security-audit.md diff --git a/docs/superpowers/notes/2026-09-30-divi-absicherung-security-audit.md b/docs/superpowers/notes/2026-09-30-divi-absicherung-security-audit.md new file mode 100644 index 0000000..6fc4f2f --- /dev/null +++ b/docs/superpowers/notes/2026-09-30-divi-absicherung-security-audit.md @@ -0,0 +1,40 @@ +# Divi-Absicherung – Security-Audit + +Stand 2026-09-30, Branch `divi-absicherung`, Version 0.6.0. Durchgeführt als abschließender Gesamt-Review des Branches +(903246d..35ea47d) mit Sicherheitsschwerpunkt; Korrekturen in 43c0a78, nachgeprüft. + +## Geprüft + +- **Schreiben trotz nicht freigegebener Divi-Version:** Kein Weg gefunden. Zentral lehnt `DiviToolGate` jedes nicht + lesende Tool der Gruppen Divi, Divi-Bibliothek und Divi Theme Builder vor `execute` ab (ohne `readonly`-Annotation gilt + ein Tool als schreibend). `lit-content-create` mit `divi` lehnt vor dem Anlegen des Entwurfs ab. In den Adaptern ruft + jede schreibende Methode zuerst `ReleaseGate::assertWritable()` auf: `DiviSaver::save/saveNew`, + `DiviLibraryStore::create`, alle Schreibmethoden von `DiviThemeBuilder` inkl. `liveId(true)`, `DiviPages::setFlags` + (die letzten beiden nachgezogen in 43c0a78). Ein in `WriteFailed` verpacktes `DiviLocked` wird als Sperre gemeldet. +- **Fail-closed:** Ausnahme im Resolver → `incompatible` (geloggt); fehlende oder unlesbare `fingerprints.json` bzw. + Divi-Dateien → rote Prüfung, nie eine Ausnahme nach außen; Divi nicht aktiv → Version leer → `untested`. +- **Prüfmodus:** Ausnahme gilt nur für die ID der Wegwerfseite, nur während `whileProbing()`, wird im `finally` + zurückgesetzt, ist nicht von außen setzbar; Aufrufe ohne Post-ID (Bibliothek, Theme Builder) sind nie ausgenommen. +- **Freigabe:** nur `manage_options` + Nonce (`check_admin_referer`), nur aus frischem grünem Bericht, serverseitig gegen + Divi-Version, Plugin-Version und Zustandsschlüssel geprüft; Zurückziehen nutzt die installierte Version, keine Eingabe. +- **REST-Route der Prüfung:** Permission `manage_options`, Cookie-Anmeldung mit `wp_rest`-Nonce; atomare Sperre per + `INSERT IGNORE` bzw. bedingtem `UPDATE` (vorbereitete Statements, Tabellenname aus `$wpdb`), Freigabe im `finally`; + Fehler im Prüflauf → Hinweis statt 500-Antwort. +- **Ausgabe im Reiter:** durchgehend escaped (`esc_html`, `esc_attr`, `esc_url`). +- **Wegwerfseite:** immer endgültig gelöscht; verwaiste Seiten nur als Entwurf mit Marker und passendem Titel, älter als + 10 Minuten. +- **Deinstallation:** entfernt `lit_mcp_divi_releases`, `lit_mcp_divi_check_report`, `lit_mcp_divi_check_lock` und den + Transient `lit_mcp_divi_state` auf jeder Website. +- **WP-CLI:** `--update-reference` schreibt nur in einer Entwicklungskopie (mit `tests/`), Referenzquellen zuerst, + `fingerprints.json` zuletzt; ein Schreibfehler lässt die Sperre unverändert. + +## Bewusst nicht geändert + +- Administratoren mit Anwendungspasswort können die Prüf-Route ohne Nonce aufrufen – keine Rechteausweitung, Freigeben + bleibt hinter admin-post und Nonce. +- `lit-content-create`-Ablehnungen stehen nicht als „abgelehnt“ im Protokoll (Fehler geht an den Client). +- `lit-site-status` liest die Fingerabdrücke ohne Zwischenspeicher (selten aufgerufen). + +## Ergebnis + +Keine kritischen oder wichtigen offenen Befunde.