diff --git a/docs/superpowers/plans/2026-10-18-divi-faehigkeiten.md b/docs/superpowers/plans/2026-10-18-divi-faehigkeiten.md new file mode 100644 index 0000000..e1c4167 --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-divi-faehigkeiten.md @@ -0,0 +1,14070 @@ +# Zwischenschritt vor 4c – Kern entkoppeln und Divi-Freigabe je Fähigkeit – 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:** Nach einem Divi-Update werden nur die MCP-Tools abgeschaltet, deren Divi-Aufrufe sich geändert haben; alle anderen laufen weiter. Dazu wird zuerst der generische Kern (Registrar, WordPress-, Media- und Core-Integration) von Divi entkoppelt, damit die neue Sperre nicht noch tiefer im Kern verankert wird. + +**Architecture:** (1) Der `AbilityRegistrar` kennt nur noch zwei neutrale Schnittstellen: `AbilityGuard` (Ablehnen, Ergebnis ergänzen, Ausgabeschema ergänzen) und `ToolRefusal` (Ausnahme, die sich selbst in einen `WP_Error` übersetzt). Rückwärtskanten aus WordPress/Media/Core zu Divi werden über kleine Ports umgedreht; Divi-spezifische Boot-Schritte (Upgrader-Hook, Prüf-Route, CLI, Admin-Tab) registriert die Divi-Integration selbst. (2) Jeder Eintrag im Divi-Manifest (Call, Mirror, Konstante) gehört zu einer oder mehreren **Fähigkeiten** (`DiviFeature`, z. B. „Seiten schreiben“, „Presets schreiben“, „Theme Builder lesen“). Für Calls kommt ein **Signatur-Fingerabdruck** (Reflection) hinzu, der wie die Mirror-Fingerabdrücke in `fingerprints.json` steht. Der Resolver baut daraus eine `CompatibilityMatrix` (Fähigkeit → Zustand + Probleme). (3) Jedes Divi-Tool erklärt, welche Fähigkeiten es braucht (`RequiresDiviFeatures`); der neue `DiviFeatureGate` lehnt nur Tools ab, deren Fähigkeiten nicht nutzbar sind – Lesen wie Schreiben – mit `divi_incompatible`/`divi_untested` und einem Hinweis, welche Fähigkeit betroffen ist. Adapter prüfen weiterhin fail-closed, jetzt je Fähigkeit (`ReleaseGate::assertFeature()`). (4) Anzeige je Fähigkeit in `lit-site-status`, im Admin-Tab „Divi“, als Admin-Hinweis und in `wp lit-mcp divi-check`. + +**Tech Stack:** PHP ≥ 8.1, WordPress 7.x (Abilities API, Optionen/Transients, Admin, WP-CLI), Divi 5.15.1 (nur über Adapter), `wordpress/mcp-adapter` 0.5, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs, DDEV + bash/jq-Smokes. + +**Spec:** Dieser Plan (Abschnitt „Entscheidungen“) auf Grundlage der Architektur-Review vom 2026-10-18 (Teil A „Divi-Kompatibilität granular“, Teil B Punkt 1 „Generischer Kern hängt an Divi“) und der Benutzerentscheidungen vom 2026-10-18 (E1–E4 unten). Bestehende Regeln: Divi-Absicherung §3 (Adapter, Manifest, Mirror, `DiviBoundaryTest`), Spec §5.5 (Freigabe), Rulings P9/P12/P13 (Bibliothek), Q17 (Theme Builder). + +**Voraussetzung:** Branch `etappe-4`, Stand e7793fd (4a und 4b fertig). 2624 Unit-Tests grün, `composer lint` sauber. Divi 5.15.1 installiert und in `DiviReleases::TESTED`. + +**Einordnung:** 4a, 4b (fertig) → **dieser Zwischenschritt** → 4c Farben und Variablen → 4d Theme-Optionen und Schriften → 4e Schleifen-Inhalte, Guides, Audit, Version. Nicht Teil dieses Plans (späterer Wartbarkeits-Plan): `Confirmation`-Helfer für das 14-fach kopierte `confirm`-Muster, `LibraryMakeGlobalAbility` teilen, `Composer` aufteilen, statische Adapter zu Instanzen, Folgearbeit aus 4b (`PresetStack`, `PageRewriter` aufteilen, `DetachTarget`). + +## 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 auf. +- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9; Divi 5.15.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse/ein Interface/ein Enum pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, höchstens 120 Zeichen pro Zeile. Nach jedem Task: `composer test` und `composer lint` sauber. +- **Verhalten bleibt gleich, bis Task 9 umschaltet.** Tasks 1–8 sind verhaltensneutral (bis auf die ausdrücklich genannten Ausnahmen: Task 3 verlegt `WriteProbe::removeOrphans()` aus dem Render-Pfad). Erst Task 9 ersetzt `DiviToolGate` durch `DiviFeatureGate`. +- **Divi-Grenze (Divi-Absicherung §3) bleibt vollständig:** Divi-Namen nur in Adaptern aus `DiviAdapters::CLASSES`, jeder benutzte Name im Manifest, `DiviBoundaryTest` grün. Reflection auf Divi-Symbole (Signatur-Fingerabdruck) passiert nur in `Release/` über die Manifest-Einträge (Strings), nie in Abilities. +- **Fail closed:** Lässt sich der Zustand nicht ermitteln (Exception im Resolver, kaputter Cache, fehlende Referenz für eine Fähigkeit bei nicht freigegebener Version), ist die betroffene Fähigkeit gesperrt; ein Resolver-Fehler sperrt alle Fähigkeiten. Adapter-Schreibmethoden rufen vor jedem Schreiben `ReleaseGate::assertFeature(...)`; Mirrors (Nachbau von Divi-Abläufen) schreiben nie mit abweichendem Fingerabdruck. +- **Kern ohne Divi:** Nach Task 2 importiert nichts unter `src/Abilities`, `src/Integrations/Core`, `src/Integrations/WordPress`, `src/Integrations/Media`, `src/Admin` (außer `Admin/DiviTab.php`), `src/Guides`, `src/Server` eine Klasse aus `LIT\MCP\Integrations\Divi`. Ein neuer Test (`CoreIndependenceTest`) erzwingt das per Token-Scan. Ausnahmen nur ausdrücklich in seiner Liste mit Begründung (`Plugin.php` als Composition Root, `Uninstaller.php` falls nötig). +- **Sprache:** Meldungen, Hinweise, Guides auf Deutsch mit echten Umlauten; neutral „KI-Assistent“, „MCP-Client“. Fehlercodes ohne `lit_`-Präfix in Texten. Indirekte Tools `lit/`, direkte `lit-` (ToolNamingTest). +- Tests: Abilities über `LIT\MCP\Tests\Unit\TestCase`; vorbelegte Stubs nie mit `Functions\expect()` prüfen. Reine Klassen erben von `PHPUnit\Framework\TestCase`. TDD (RED mit erwarteter Meldung, dann GREEN). `tests/Unit/TestCase.php` setzt heute `ReleaseGate::useResolver(... released('5.2.1'))` – nach Task 7 muss der Standard „alle Fähigkeiten freigegeben“ sein. +- Implementierer führen **keinen** clean-code-review- oder anderen Review-Skill aus; sie committen und schreiben ihren Report. Commits: `git commit -m "" -m "Co-Authored-By: Claude "` mit dem Namen des ausführenden Modells; nur die Dateien des Tasks stagen. + +## Entscheidungen (verbindlich) + +- **E1 (Benutzer):** Eine nicht freigegebene Divi-Version wird **je Fähigkeit automatisch freigegeben**, wenn alle Anforderungen dieser Fähigkeit unverändert sind: jeder Call existiert, nimmt die Argumentzahl an **und** sein Signatur-Fingerabdruck stimmt mit der Referenz überein; jeder Mirror-Fingerabdruck stimmt; jede Konstante ist definiert. Fehlt für eine Anforderung die Referenz, ist die Fähigkeit `untested` (Admin-Freigabe wie heute). Weicht etwas ab, ist sie `incompatible`. Der Filter `lit_mcp_divi_auto_release` (bool, Standard `true`) schaltet die Automatik ab; dann gilt für nicht freigegebene Versionen wieder „alles untested“. Eine Admin-Freigabe (`ReleaseStore`) gibt alle Fähigkeiten ohne Probleme frei. +- **E2 (Benutzer):** **Lese-Tools** werden ebenfalls abgelehnt, wenn eine ihrer Fähigkeiten nicht nutzbar ist (`divi_incompatible`/`divi_untested` mit Hinweis) – statt in `internal_error` zu laufen. Der heutige Hinweis in `warnings` für Lese-Tools bei gesperrtem Schreiben entfällt (das Lese-Tool läuft, wenn seine Fähigkeiten nutzbar sind). +- **E3 (Benutzer):** Gesperrte Tools **bleiben registriert** und lehnen beim Aufruf ab. Zusätzlich: `lit-site-status` meldet je Fähigkeit Zustand, Probleme und betroffene Tools; der Admin-Tab „Divi“ zeigt dieselbe Tabelle; ein Admin-Hinweis erscheint, sobald eine Fähigkeit nicht nutzbar ist; `wp lit-mcp divi-check` gibt die Matrix aus und endet mit Exit ≠ 0, wenn eine Fähigkeit nicht nutzbar ist. +- **E4 (Benutzer):** Umfang = Kern-Entkopplung + Sperre je Fähigkeit samt Anzeige. Weitere Wartbarkeit kommt später. +- **D1 Fähigkeiten:** `DiviFeature` (string-backed Enum) mit genau diesen Fällen: `PageRead` (`page-read`), `PageWrite` (`page-write`), `Catalog` (`catalog`), `DynamicSources` (`dynamic-sources`), `DesignRead` (`design-read`), `PresetWrite` (`preset-write`), `LibraryRead` (`library-read`), `LibraryWrite` (`library-write`), `Roles` (`roles`), `ThemeBuilderRead` (`tb-read`), `ThemeBuilderWrite` (`tb-write`). Methoden `isWrite(): bool`, `label(): string` (deutsch, z. B. „Seiten schreiben“). +- **D2 Zuordnung:** Jeder Manifest-Eintrag trägt seine Fähigkeiten. `Manifest` bekommt einen Standard (`features`) je Adapter; `Call`, `Mirror` und Konstanten können ihn überschreiben. Ein Test verlangt, dass jede Anforderung mindestens eine Fähigkeit hat. Gemeinsam genutzte Calls (z. B. `ET_Core_PageResource::remove_static_resources` in Pages und Theme Builder) tragen alle betroffenen Fähigkeiten – ein Bruch sperrt bewusst beide. +- **D3 Registrierungs-Checks** (Post-Typen, Taxonomien, Block-Typen) bleiben informativ (Bericht, `lit-site-status`), sperren nicht – wie heute. +- **D4 Schreibprobe:** `WriteProbe` bleibt der Seitenpfad. Sie ist nur für die Admin-Freigabe nötig (E1-Automatik braucht sie nicht). +- **D5 Gemeinsamer Speicherpfad:** `DiviSaver::save()`/`saveNew()` prüft `PageWrite` und zusätzlich die Fähigkeit des Speicherbereichs: `SaveScope::LibraryItem`/`GlobalItem` → `LibraryWrite`, `SaveScope::ThemeBuilderLayout` → `ThemeBuilderWrite`. +- **D6 Kompatibilität der Fehlercodes:** Codes bleiben `divi_untested` (Fähigkeit untested) und `divi_incompatible` (Fähigkeit incompatible), HTTP 423; neu sind die Details `features: list<{id, label, state, problems}>` und ein Hinweis, der die betroffene Fähigkeit nennt und sagt, dass andere Divi-Werkzeuge weiter funktionieren. +- **D7 Cache:** Der Transient `lit_mcp_divi_state` speichert künftig die Matrix (Fähigkeit → Probleme) für Code-, Signatur- und Mirror-Checks zusammen; ein Eintrag im alten Format zählt als Cache-Miss. `EnvironmentProbe`/`lit-site-status` lesen die gecachte Matrix (keine Neuberechnung der Mirrors je Aufruf). + +## Review Focus + +1. **Fail closed:** Resolver-Exception, kaputter/alter Cache, fehlende Referenz, unbekannte Version mit abgeschalteter Automatik → betroffene Fähigkeiten gesperrt; kein Adapter schreibt mit gesperrter Fähigkeit (`ReleaseGuardsTest` je Fähigkeit) (Tasks 6, 7). +2. **Mirror-Sicherheit:** Abweichender Mirror-Fingerabdruck sperrt genau die Fähigkeiten dieses Mirrors, auch bei automatischer Freigabe (Task 6). +3. **Automatik nur bei vollständiger Übereinstimmung:** ein geänderter Signatur-Fingerabdruck (z. B. neuer Pflichtparameter, geänderter Typ) → `incompatible` (Tasks 5, 6). +4. **Kein Tool ohne Erklärung:** jedes Tool der Gruppen `divi`, `divi-library`, `divi-theme-builder` und `lit/content-create` erklärt seine Fähigkeiten; die Tabelle im Test ist vollständig (Task 8). +5. **Gate und Adapter einig:** dasselbe Tool wird vor dem Lauf abgelehnt, statt mitten im Schreiben an `assertFeature` zu scheitern (Tasks 7, 9). +6. **Kern-Unabhängigkeit:** `CoreIndependenceTest` grün; `AbilityRegistrar` ohne Divi-Import (Tasks 1–3). + +--- + +## File Structure + +``` +src/Abilities/AbilityGuard.php (neu) Interface: refusal/annotate/outputSchema +src/Abilities/ToolRefusal.php (neu) Interface für Ausnahmen mit eigenem WP_Error +src/Abilities/AbilityRegistrar.php (geändert) list, ToolRefusal statt DiviLocked +src/Integrations/Bootable.php (neu) optionales Interface: boot() für integrationseigene Hooks +src/Integrations/IntegrationRegistry.php (geändert) boot() ruft Bootable-Integrationen +src/Integrations/Core/EnvironmentComponent.php (neu) Port: Komponente für lit-site-status +src/Integrations/Core/EnvironmentProbe.php (geändert) list statt Divi-Klassen +src/Integrations/WordPress/BuilderDetector.php (neu) Port: Page-Builder erkennen (usesBuilder, isAvailable) +src/Integrations/WordPress/Content/PostPresenter.php (geändert) BuilderDetector +src/Integrations/WordPress/Schema/ContentSchema.php (geändert) ausgeschlossene Post-Typen per Konstruktor +src/Integrations/Media/MediaUsageSource.php (neu) Port: zusätzliche Fundstellen (Divi-Logo) +src/Integrations/Media/MediaUsageFinder.php (geändert) list +src/Integrations/Divi/DiviIntegration.php (geändert) implements Bootable; liefert Ports +src/Integrations/Divi/DiviBuilderDetector.php (neu) BuilderDetector für Divi +src/Integrations/Divi/DiviEnvironmentComponent.php(neu) EnvironmentComponent für Divi (Matrix) +src/Integrations/Divi/DiviLogoUsage.php (neu) MediaUsageSource für das Divi-Logo +src/Plugin.php (geändert) Composition Root ohne Divi-Einzelteile +src/Admin/DiviTab.php (geändert) removeOrphans im load-Hook; Matrix-Tabelle; Admin-Hinweis +src/Integrations/Divi/Release/DiviFeature.php (neu) Enum der Fähigkeiten +src/Integrations/Divi/Release/Requirement.php (neu) Interface: label(), features() +src/Integrations/Divi/Release/Call.php, Mirror.php, Manifest.php (geändert) Fähigkeiten +src/Integrations/Divi/Release/ConstantRequirement.php (neu) Konstante mit Fähigkeiten +src/Integrations/Divi/Release/SignatureFingerprint.php (neu) Reflection-Fingerabdruck eines Calls +src/Integrations/Divi/Release/DiviReference.php (geändert) Signaturen in fingerprints.json +src/Integrations/Divi/Release/fingerprints.json (geändert) Einträge "sig:" +src/Integrations/Divi/Release/Problem.php (neu) Befund mit Fähigkeiten und Art +src/Integrations/Divi/Release/ManifestCheck.php (geändert) problems(): list +src/Integrations/Divi/Release/FeatureStatus.php (neu) Zustand einer Fähigkeit +src/Integrations/Divi/Release/CompatibilityMatrix.php (neu) Fähigkeit → FeatureStatus +src/Integrations/Divi/Release/ReleaseResolver.php (geändert) baut Matrix (E1, D7) +src/Integrations/Divi/Release/ReleaseStatus.php (geändert) trägt Matrix; Texte je Fähigkeit +src/Integrations/Divi/Release/ReleaseGate.php (geändert) feature(), assertFeature() +src/Integrations/Divi/Release/DiviLocked.php (geändert) implements ToolRefusal; Fähigkeiten +src/Integrations/Divi/Release/RequiresDiviFeatures.php (neu) Interface für Tools +src/Integrations/Divi/Release/DiviFeatureGate.php (neu) AbilityGuard je Fähigkeit (ersetzt DiviToolGate) +src/Integrations/Divi/Release/DiviToolGate.php (gelöscht in Task 9) +src/Integrations/Divi/**/ *Ability.php (geändert) diviFeatures() +src/Integrations/WordPress/Content/ContentCreateAbility.php (geändert) über Port, siehe Task 8 +src/Integrations/Divi/**/ (Adapter, DiviSaver, DesignChange) (geändert) assertFeature statt assertWritable +src/Cli/DiviCheckCommand.php (geändert) Matrix-Ausgabe, Signaturen in --update-reference +docs/guides/*.md, docs/superpowers/notes/… (geändert) Freigabe je Fähigkeit +tests/Unit/** (neu/geändert je Task), tests/Unit/Architecture/CoreIndependenceTest.php (neu) +tests/Integration/divi-release-smoke.sh (geändert) Matrix im Site-Status +``` + +## Tasks und Schnittstellen (Überblick) + +| Task | Inhalt | Modell | +|---|---|---| +| 1 | `AbilityGuard`, `ToolRefusal`; `AbilityRegistrar` ohne Divi-Import; `DiviToolGate` implementiert `AbilityGuard`, `DiviLocked` implementiert `ToolRefusal` | Sonnet | +| 2 | Ports für Rückwärtskanten: `BuilderDetector`, ausgeschlossene Post-Typen, `MediaUsageSource`, `EnvironmentComponent`; Divi-Implementierungen; `CoreIndependenceTest` | Sonnet | +| 3 | `Bootable`: Divi-Boot-Schritte (Upgrader-Hook, Prüf-Route, CLI, Admin-Tab) in `DiviIntegration::boot()`; `Plugin.php` als schlanke Composition Root; `WriteProbe::removeOrphans` aus dem Render-Pfad; Verdrahtungstest | Opus | +| 4 | `DiviFeature`, `Requirement`, Fähigkeiten an `Manifest`/`Call`/`Mirror`/`ConstantRequirement`; alle acht Adapter getaggt; Test „jede Anforderung hat eine Fähigkeit“ | Sonnet | +| 5 | `SignatureFingerprint` (Reflection) und Signaturen in `fingerprints.json`/`DiviReference`; `--update-reference` schreibt sie; Referenz für Divi 5.15.1 erzeugen | Sonnet | +| 6 | `Problem`, `FeatureStatus`, `CompatibilityMatrix`; `ManifestCheck::problems()`; `ReleaseResolver` baut die Matrix mit E1-Automatik, Filter, Cache D7; `ReleaseStatus` trägt die Matrix | Opus | +| 7 | `ReleaseGate::feature()`/`assertFeature()`; `DiviLocked` mit Fähigkeiten; alle 14 `assertWritable`-Stellen umgestellt (D5); `TestCase`-Standard; `ReleaseGuardsTest` je Fähigkeit | Opus | +| 8 | `RequiresDiviFeatures` an allen Divi-Tools und `lit/content-create`; vollständige Erwartungstabelle im Test | Sonnet | +| 9 | `DiviFeatureGate` ersetzt `DiviToolGate` (E2, D6); Verdrahtung; `DiviContentComposer::refusal` je Fähigkeit | Sonnet | +| 10 | Anzeige: `lit-site-status` (Matrix + betroffene Tools), Admin-Tab-Tabelle + Admin-Hinweis, `divi-check`-Ausgabe/Exit-Code, Texte je Fähigkeit | Sonnet | +| 11 | Guides, Entscheidungsnotiz, `divi-release-smoke.sh` anpassen und laufen lassen | Sonnet | + +### Verbindliche Signaturen + +```php +// Task 1 – src/Abilities +interface AbilityGuard +{ + /** Refusal before execute() runs, or null. */ + public function refusal(Ability $ability, array $input): ?WP_Error; + /** The result, possibly with added warnings. */ + public function annotate(Ability $ability, mixed $result): mixed; + /** The output schema, possibly extended (e.g. warnings). */ + public function outputSchema(Ability $ability, ?array $schema): ?array; +} +interface ToolRefusal // implemented by exceptions +{ + public function toError(): WP_Error; +} +// AbilityRegistrar::__construct(IntegrationRegistry $registry, ?ExposurePolicy $policy = null, array $guards = []) +// $guards: list, applied in order (first refusal wins; annotate/outputSchema chained). +// errorFor(): partial WriteFailed first (unchanged), then the first ToolRefusal in the getPrevious() chain, then +// rolled-back WriteFailed, else Errors::internal(). DiviLocked implements ToolRefusal. + +// Task 2 – Ports +interface BuilderDetector // src/Integrations/WordPress/BuilderDetector.php +{ + public function isAvailable(): bool; + public function usesBuilder(int $postId): bool; +} +interface EnvironmentComponent // src/Integrations/Core/EnvironmentComponent.php +{ + /** @return array{id: string, active: bool, version: ?string, problems: list, release: ?array} */ + public function describe(): array; +} +interface MediaUsageSource // src/Integrations/Media/MediaUsageSource.php +{ + /** @return list> usages of attachment $id in the format MediaUsageFinder returns today */ + public function usagesOf(int $attachmentId): array; +} +// ContentSchema::__construct(..., array $excludedPostTypes = []) – Divi's internal post types come from +// DiviIntegration (no class constant reference from WordPress to Divi). + +// Task 3 +interface Bootable // src/Integrations/Bootable.php +{ + /** Registers the integration's own hooks, routes, CLI commands and admin tabs. Called once by Plugin. */ + public function boot(): void; +} + +// Task 4 – src/Integrations/Divi/Release +enum DiviFeature: string { /* D1 */ public function isWrite(): bool; public function label(): string; } +interface Requirement { public function label(): string; /** @return list */ public function features(): array; } +// Call::__construct(string $symbol, ?int $args = null, array $features = []) (leer = Manifest-Standard) +// Mirror::__construct(string $id, string $file, string $function, ?string $class, string $what, array $features = []) +// ConstantRequirement::__construct(string $name, array $features = []) +// Manifest::__construct(string $area, array $calls = [], array $constants = [], ..., array $mirrors = [], +// array $features = []) // $features = Standard für Einträge ohne eigene +// Manifest::featuresOf(Requirement $r): list // eigene oder Standard + +// Task 5 +final class SignatureFingerprint +{ + /** sha256 over parameters (count, names?, types, by-ref, variadic, default present) and return type. + * null when the symbol does not exist. Never includes the file. */ + public static function of(Call $call): ?string; +} +// DiviReference::expected(): array enthält zusätzlich Schlüssel "sig:". + +// Task 6 +final class Problem { public function __construct(public readonly string $label, public readonly array $features, + public readonly string $kind /* 'call'|'signature'|'mirror'|'constant'|'reference-missing'|'resolver' */) {} } +final class FeatureStatus { public function __construct(public readonly DiviFeature $feature, + public readonly ReleaseState $state, public readonly array $problems /* list */, + public readonly ?string $source /* 'code'|'database'|'auto'|null */) {} public function usable(): bool; } +final class CompatibilityMatrix { + /** @param array $features keyed by DiviFeature value */ + public function __construct(array $features); + public static function allReleased(string $source = 'code'): self; + public static function allIncompatible(string $problem): self; + public function of(DiviFeature $feature): FeatureStatus; + /** @return list */ public function unusable(): array; + public function toArray(): array; // for lit-site-status and the admin tab +} +// ManifestCheck::problems(): list (code + signature + mirror + constants; Registrierung bleibt failed()) +// ReleaseStatus bekommt public readonly CompatibilityMatrix $matrix; writable() = alle Schreib-Fähigkeiten nutzbar. + +// Task 7 +// ReleaseGate::feature(DiviFeature $feature): FeatureStatus +// ReleaseGate::assertFeature(DiviFeature $feature, ?int $postId = null): void // wirft DiviLocked +// ReleaseGate::assertWritable(?int $postId = null) bleibt als „alle Schreib-Fähigkeiten“ (nur noch Altpfade/Probe) +// DiviLocked::__construct(ReleaseStatus $status, array $features = []) // betroffene Fähigkeiten; implements ToolRefusal + +// Task 8 +interface RequiresDiviFeatures +{ + /** @return list the features this call needs for $input ([] = none, e.g. content-create without divi) */ + public function diviFeatures(array $input): array; +} + +// Task 9 +final class DiviFeatureGate implements AbilityGuard +{ + public function __construct(?ChangeLog $log = null); +} +``` + + +### Abstimmung zwischen den Tasks (gilt vor den Einzelabschnitten; Controller-Rulings vom 2026-10-18) + +Die Tasks wurden parallel ausgearbeitet und in Repo-Kopien gebaut (Tasks 1–3 und 4–7 jeweils grün). Wo sie sich berühren, +gilt Folgendes – die Task-Abschnitte sind daran angeglichen oder müssen es beim Umsetzen sein: + +- **A1 Verhaltensänderung ab Task 6 (Ausnahme zu „verhaltensneutral bis Task 9“):** Ab Task 6 wirkt die E1-Automatik + über den alten `DiviToolGate`. Bis Task 9 sperrt dieser weiterhin alle Divi-Schreib-Tools, sobald irgendeine + Schreib-Fähigkeit gesperrt ist (wie heute). Für Divi 5.15.1 (freigegeben) ändert sich nichts. +- **A2 D7 präzisiert:** Gecacht (Transient `lit_mcp_divi_state`) werden nur die Mirror-Probleme. Code-, Signatur- und + Konstanten-Prüfungen laufen je Request live (Reflection ist billig; ein Divi-Hotfix ohne Versionssprung wird sofort + erkannt; ein früher Request ohne geladenes Divi cacht nichts Falsches). Altes Cache-Format = Miss. `stateKey` bleibt; + `md5_file(fingerprints.json)` deckt die Signatur-Referenzen ab. +- **A3 E1 präzisiert:** Fehlt die Referenz eines **Mirrors**, ist das ein harter Befund (`mirror`) – auch bei + freigegebener Version (sonst würde ein Nachbau ungeprüft schreiben). `reference-missing` gibt es nur für + Call-Signaturen; es sperrt nur bei nicht freigegebener Version (Fähigkeit `untested`). +- **A4 Signatur-Fingerabdruck (Task 5):** ohne Parameternamen und ohne Default-Werte (Adapter rufen Divi positional). + Folge: Ein neuer optionaler Parameter am Ende sperrt die Fähigkeit (fail closed, gewollt); ein geänderter + Default-Wert fällt nicht auf. 14 der 29 Divi-Symbole sind untypisiert – dort sichert der Hash nur Aufrufform und + Parameterzahl. Texte in Tasks 10/11 sagen deshalb „Aufrufe unverändert“, nie „Verhalten geprüft“. +- **A5 Zuordnung Writer-Lesehilfen (Tasks 4 und 8):** `GlobalPreset::get_group_preset_default_attr` und + `ModuleRegistration::get_module_settings` (beide über `DesignSystemWriter::groupsOf()`, genutzt auch vom Composer) + tragen `catalog` und `preset-write`. `lit/divi-preset-get` erklärt `design-read` + `catalog`; + `lit/divi-presets-assign` erklärt zusätzlich `preset-write` (Rollenrecht über `DesignRights::presets()`). +- **A6 `DiviIntegration`-Konstruktor über die Tasks:** Task 2 ergänzt Port-Methoden (`builderDetector()`, + `mediaUsageSources()`, `environmentComponent()`, statisch `internalPostTypes()`/`builderEnabledFor()`); Task 3 + ersetzt die sechs Closures durch `?ToolAvailability $tools` und zieht die Verdrahtung nach `Plugin::integrations()` + – **Task 3 übernimmt dabei die Port-Argumente aus Task 2 unverändert** (Schritt 0: grep, was Task 2 in `Plugin.php` + verdrahtet hat). Task 10 hängt `?Closure $allAbilities` als letzten Parameter an. +- **A7 Guard-Verdrahtung bleibt in `Plugin.php`** (Composition Root); Task 9 tauscht dort `DiviToolGate` gegen + `DiviFeatureGate`. `src/Cli` liegt außerhalb des `CoreIndependenceTest`-Scans (Divi-CLI-Befehl). +- **A8 `ReleaseStatus`:** sechster Konstruktorparameter heißt `matrix` (benannt nutzbar); ohne Matrix gilt der Zustand + für alle Fähigkeiten. `notice()` entfällt in Task 10 (nach Task 9 ohne Aufrufer); `assertWritable()` darf Task 9 + entfernen, wenn es keine Aufrufer mehr hat. +- **A9 `lit/content-create`:** meldet seine Divi-Fähigkeiten über das neutrale Kern-Interface + `LIT\MCP\Abilities\DeclaresRequirements` (Fähigkeits-IDs als Strings, Task 8) – kein Divi-Import im WordPress-Kern. +- **A10 `wp lit-mcp divi-check`:** Exit 0 = Prüfung grün und alle Fähigkeiten nutzbar; 1 = Prüfung rot; 2 = Prüfung + grün, aber mindestens eine Fähigkeit nicht nutzbar (Task 10). +- **A11 phpcs und Enums:** Meldet phpcs `$this` in Enum-Methoden (`ForbiddenThisUseContexts`), setzt Task 4 ein + gezieltes `// phpcs:ignore` mit Begründung. +- **A12 Später (4c/4d):** `DesignArea::feature()` braucht für Farben/Variablen/Theme-Optionen eine neue Fähigkeit + (z. B. `DesignWrite`) – nicht Teil dieses Plans. +- **Zusätzliche Dateien** aus der Ausarbeitung (ergänzen die File Structure): `src/Abilities/ToolAvailability.php`, + `src/Abilities/DeclaresRequirements.php`, `src/Integrations/WordPress/NoBuilderDetector.php`, + `src/Integrations/Media/AttachmentFile.php`, `src/Integrations/Divi/Release/FeatureSets.php`, + `src/Integrations/Divi/Release/FeatureOverview.php`, Filter `SettingsPage::TABS_FILTER`, Test-Hilfen + `tests/Support/{FakeBuilderDetector,ReleaseMatrices,DiviFeatureFixtures,MatrixFixtures,FeatureFakeTool}.php`. +- **Testzahlen** in den RED/GREEN-Schritten sind Richtwerte (Teile wurden gegen e7793fd plus Stubs gebaut); maßgeblich + ist „alle grün“. + +--- + +### Task 1: `AbilityGuard` und `ToolRefusal` – der `AbilityRegistrar` kennt kein Divi mehr + +**Files:** +- Create: `src/Abilities/AbilityGuard.php` +- Create: `src/Abilities/ToolRefusal.php` +- Modify: `src/Abilities/AbilityRegistrar.php` (nimmt `list`, `errorFor()` über `ToolRefusal`; kein Divi-Import) +- Modify: `src/Integrations/Divi/Release/DiviToolGate.php` (`implements AbilityGuard`, `refusal(Ability, array $input)`) +- Modify: `src/Integrations/Divi/Release/DiviLocked.php` (`implements ToolRefusal`) +- Modify: `src/Plugin.php` (übergibt `[new DiviToolGate(...)]`) +- Modify: `tests/Unit/Abilities/AbilityRegistrarTest.php` (Testhelfer `diviRegistrar()`, sieben neue Tests) +- Modify: `tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php` (vier Aufrufe von `refusal()` bekommen `[]`) + +**Interfaces:** +- Consumes: `Ability` (vorhanden), `Errors`, `WriteFailed` (vorhanden), `IntegrationRegistry::groupMap()` (vorhanden). +- Produces (genau wie im Gerüst): + - `interface AbilityGuard { refusal(Ability $ability, array $input): ?WP_Error; annotate(Ability $ability, mixed $result): mixed; outputSchema(Ability $ability, ?array $schema): ?array; }` + - `interface ToolRefusal { toError(): WP_Error; }` (von Ausnahmen implementiert; erweitert `Throwable` bewusst nicht – die Kette wird über `instanceof` in der `getPrevious()`-Kette gesucht). + - `AbilityRegistrar::__construct(IntegrationRegistry $registry, ?ExposurePolicy $policy = null, array $guards = [])`. Ohne Guards wird nichts geprüft und nichts ergänzt (der Registrar baut keinen Divi-Gate mehr selbst). `errorFor()`: partielles `WriteFailed` zuerst, dann die erste `ToolRefusal` in der `getPrevious()`-Kette, dann zurückgerolltes `WriteFailed`, sonst `Errors::internal()` – Reihenfolge wie heute. + - `DiviToolGate implements AbilityGuard`; `DiviLocked implements ToolRefusal`. Das Verhalten beider ist unverändert. +- Der Registrar fragt die Guards der Reihe nach: die erste Ablehnung gewinnt (spätere Guards werden nicht gefragt), `annotate()` und `outputSchema()` werden hintereinander angewendet. + +Bestehende Tests, die sich ändern: +- `AbilityRegistrarTest::diviRegistrar()` (Testhelfer; betrifft `testUntestedDiviRefusesAWritingDiviToolBeforeItRuns` und `testDiviReadToolsGetWarningsInTheirSchema`): `new AbilityRegistrar($registry, null, new DiviToolGate(...))` wird zu `new AbilityRegistrar($registry, null, [new DiviToolGate(...)])`. `testALockThrownInsideAWriteBecomesTheLockError` und `testAPartialWriteStaysPartialEvenWithALockInside` bleiben unverändert (sie laufen über `registrarFor()` ohne Guards; `DiviLocked` wird jetzt als `ToolRefusal` erkannt). +- `DiviToolGateTest::testReleasedDiviRefusesNothingAndAddsNothing` und `testWriteToolOfEveryDiviGroupIsRefused`: die vier Aufrufe `$gate->refusal($ability)` werden zu `$gate->refusal($ability, [])`. + +- [ ] **Step 1: Failing Tests schreiben** + +In `tests/Unit/Abilities/AbilityRegistrarTest.php` die Imports, den Helfer `diviRegistrar()` und die neuen Tests ergänzen (die Hilfsmethoden `guardedRegistrar()`, `guard()` und `refusalException()` kommen vor `executeLogged()`, die Tests direkt dahinter): + +```diff +--- a/tests/Unit/Abilities/AbilityRegistrarTest.php ++++ b/tests/Unit/Abilities/AbilityRegistrarTest.php +@@ -6,8 +6,11 @@ namespace LIT\MCP\Tests\Unit\Abilities; + + use Brain\Monkey\Functions; + use LIT\MCP\Abilities\Ability; ++use LIT\MCP\Abilities\AbilityGuard; + use LIT\MCP\Abilities\AbilityRegistrar; ++use LIT\MCP\Abilities\Errors; + use LIT\MCP\Abilities\ExposurePolicy; ++use LIT\MCP\Abilities\ToolRefusal; + use LIT\MCP\Abilities\WriteFailed; + use LIT\MCP\Integrations\Divi\Release\DiviLocked; + use LIT\MCP\Integrations\Divi\Release\DiviToolGate; +@@ -20,6 +23,7 @@ use LIT\MCP\Server\RequestContext; + use LIT\MCP\Tests\Unit\TestCase; + use Mockery; + use RuntimeException; ++use Throwable; + use WP_Error; + + final class AbilityRegistrarTest extends TestCase +@@ -56,7 +60,7 @@ final class AbilityRegistrarTest extends TestCase + $integration->allows('abilities')->andReturn([$ability]); + $registry = new IntegrationRegistry($integration); + +- return new AbilityRegistrar($registry, null, new DiviToolGate($registry->groupMap(...))); ++ return new AbilityRegistrar($registry, null, [new DiviToolGate($registry->groupMap(...))]); + } + + private function ability(bool $readonly = true): Mockery\MockInterface +@@ -247,6 +251,147 @@ final class AbilityRegistrarTest extends TestCase + self::assertToolError($this->executeLogged($ability), 'partial_write'); + } + ++ /** @param list $guards */ ++ private function guardedRegistrar(Ability $ability, array $guards): AbilityRegistrar ++ { ++ $integration = Mockery::mock(Integration::class); ++ $integration->allows('id')->andReturn('core'); ++ $integration->allows('isAvailable')->andReturn(true); ++ $integration->allows('abilities')->andReturn([$ability]); ++ ++ return new AbilityRegistrar(new IntegrationRegistry($integration), null, $guards); ++ } ++ ++ /** ++ * A guard without any Divi knowledge: refuses with $refusal (or lets through), appends $tag to the result's ++ * "trail" and to the schema's "trail" property; $seen collects the inputs it was asked about. ++ */ ++ private function guard(string $tag, ?WP_Error $refusal = null, array &$seen = []): AbilityGuard ++ { ++ return new class ($tag, $refusal, $seen) implements AbilityGuard { ++ public function __construct(private string $tag, private ?WP_Error $refusal, private array &$seen) ++ { ++ } ++ ++ public function refusal(Ability $ability, array $input): ?WP_Error ++ { ++ $this->seen[] = $input; ++ ++ return $this->refusal; ++ } ++ ++ public function annotate(Ability $ability, mixed $result): mixed ++ { ++ $result['trail'] = [...($result['trail'] ?? []), $this->tag]; ++ ++ return $result; ++ } ++ ++ public function outputSchema(Ability $ability, ?array $schema): ?array ++ { ++ $schema['properties']['trail'][] = $this->tag; ++ ++ return $schema; ++ } ++ }; ++ } ++ ++ private function refusalException(WP_Error $error, ?Throwable $previous = null): RuntimeException ++ { ++ return new class ($error, $previous) extends RuntimeException implements ToolRefusal { ++ public function __construct(private WP_Error $error, ?Throwable $previous) ++ { ++ parent::__construct('refused', 0, $previous); ++ } ++ ++ public function toError(): WP_Error ++ { ++ return $this->error; ++ } ++ }; ++ } ++ ++ public function testWithoutGuardsNothingIsCheckedOrAdded(): void ++ { ++ $ability = $this->ability(false); ++ $ability->expects('execute')->with(['a' => 1])->andReturn(['ok' => true]); ++ ++ $args = $this->registrarFor($ability)->argsFor($ability); ++ ++ self::assertSame(['ok' => true], ($args['execute_callback'])(['a' => 1])); ++ self::assertSame(['type' => 'object'], $args['output_schema']); ++ } ++ ++ public function testAGuardRefusesBeforeTheAbilityRuns(): void ++ { ++ $seen = []; ++ $ability = $this->ability(false); ++ $ability->expects('execute')->never(); ++ $registrar = $this->guardedRegistrar($ability, [$this->guard('a', Errors::forbidden('Nein.'), $seen)]); ++ ++ $result = ($registrar->argsFor($ability)['execute_callback'])(['x' => 1]); ++ ++ self::assertToolError($result, 'forbidden'); ++ self::assertSame([['x' => 1]], $seen); ++ } ++ ++ public function testTheFirstRefusalWinsAndLaterGuardsAreNotAsked(): void ++ { ++ $seenSecond = []; ++ $ability = $this->ability(false); ++ $ability->expects('execute')->never(); ++ $registrar = $this->guardedRegistrar($ability, [ ++ $this->guard('a'), ++ $this->guard('b', Errors::notFound('Weg.')), ++ $this->guard('c', Errors::forbidden('Nein.'), $seenSecond), ++ ]); ++ ++ self::assertToolError(($registrar->argsFor($ability)['execute_callback'])([]), 'not_found'); ++ self::assertSame([], $seenSecond); ++ } ++ ++ public function testGuardsAnnotateTheResultAndExtendTheSchemaInOrder(): void ++ { ++ $ability = $this->ability(false); ++ $ability->allows('execute')->andReturn(['ok' => true]); ++ $registrar = $this->guardedRegistrar($ability, [$this->guard('a'), $this->guard('b')]); ++ ++ $args = $registrar->argsFor($ability); ++ ++ self::assertSame(['ok' => true, 'trail' => ['a', 'b']], ($args['execute_callback'])([])); ++ self::assertSame(['a', 'b'], $args['output_schema']['properties']['trail']); ++ } ++ ++ public function testAToolRefusalThrownByAnAbilityIsItsErrorWithoutDivi(): void ++ { ++ $ability = $this->ability(); ++ $ability->allows('execute')->andThrow($this->refusalException(Errors::forbidden('Gesperrt.'))); ++ ++ self::assertToolError($this->executeLogged($ability), 'forbidden'); ++ } ++ ++ public function testAToolRefusalInsideARolledBackWriteIsItsErrorButPartialStaysPartial(): void ++ { ++ $refusal = $this->refusalException(Errors::notFound('Weg.')); ++ $rolledBack = $this->ability(); ++ $rolledBack->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $refusal)); ++ $partial = $this->ability(); ++ $partial->allows('execute')->andThrow(WriteFailed::partial('Teilweise gespeichert.', $refusal)); ++ ++ self::assertToolError($this->executeLogged($rolledBack), 'not_found'); ++ self::assertToolError($this->executeLogged($partial), 'partial_write'); ++ } ++ ++ public function testTheFirstRefusalInTheCauseChainWins(): void ++ { ++ $inner = $this->refusalException(Errors::notFound('Innen.')); ++ $outer = $this->refusalException(Errors::forbidden('Außen.'), $inner); ++ $ability = $this->ability(); ++ $ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $outer)); ++ ++ self::assertToolError($this->executeLogged($ability), 'forbidden'); ++ } ++ + /** + * Runs the execute callback with error_log() redirected (a PHP internal, not stubbable – like the tests above). + */ +``` + +In `tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php` die vier Aufrufe anpassen: + +```diff +--- a/tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php ++++ b/tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php +@@ -47,7 +47,7 @@ final class DiviToolGateTest extends TestCase + { + $gate = $this->gate(FakeChangeLog::forbidden()); + +- self::assertNull($gate->refusal($this->ability('lit/divi-insert', false))); ++ self::assertNull($gate->refusal($this->ability('lit/divi-insert', false), [])); + self::assertSame(['ok' => true], $gate->annotate($this->ability('lit/divi-outline', true), ['ok' => true])); + } + +@@ -58,10 +58,10 @@ final class DiviToolGateTest extends TestCase + $gate = $this->gate($log); + + foreach (['lit/divi-insert', 'lit/library-save', 'lit/tb-create-template'] as $name) { +- self::assertToolError($gate->refusal($this->ability($name, false)), 'divi_untested', $name); ++ self::assertToolError($gate->refusal($this->ability($name, false), []), 'divi_untested', $name); + } +- self::assertNull($gate->refusal($this->ability('lit/content-create', false)), 'not a Divi tool (adapter lock)'); +- self::assertNull($gate->refusal($this->ability('lit/divi-outline', true)), 'reading stays allowed'); ++ self::assertNull($gate->refusal($this->ability('lit/content-create', false), []), 'not a Divi tool (adapter lock)'); ++ self::assertNull($gate->refusal($this->ability('lit/divi-outline', true), []), 'reading stays allowed'); + self::assertSame(['refused', 'refused', 'refused'], array_column($log->records, 'result')); + self::assertSame('Abgelehnt: Divi 5.3.0 ist nicht freigegeben.', $log->records[0]['summary']); + } +``` + +- [ ] **Step 2: RED** + +Run: `composer test -- --filter 'AbilityRegistrarTest|DiviToolGateTest'` +Expected: FAIL, 8 Fehler in `AbilityRegistrarTest`: `testUntestedDiviRefusesAWritingDiviToolBeforeItRuns` und `testDiviReadToolsGetWarningsInTheirSchema` mit `TypeError: LIT\MCP\Abilities\AbilityRegistrar::__construct(): Argument #3 ($divi) must be of type ?LIT\MCP\Integrations\Divi\Release\DiviToolGate, array given`; die sechs neuen Guard-/Refusal-Tests mit `Error: Interface "LIT\MCP\Abilities\AbilityGuard" not found` bzw. `Error: Interface "LIT\MCP\Abilities\ToolRefusal" not found`. `DiviToolGateTest` bleibt grün (PHP nimmt überzählige Argumente an). + +- [ ] **Step 3: Die beiden Schnittstellen anlegen** + +`src/Abilities/AbilityGuard.php`: + +```php + $input + */ + public function refusal(Ability $ability, array $input): ?WP_Error; + + /** The result of a finished call, possibly with added warnings. */ + public function annotate(Ability $ability, mixed $result): mixed; + + /** + * The output schema, possibly extended (e.g. by a warnings property). + * + * @param array|null $schema + * @return array|null + */ + public function outputSchema(Ability $ability, ?array $schema): ?array; +} +``` + +`src/Abilities/ToolRefusal.php`: + +```php + $guards Applied in order: the first refusal wins, annotate() and outputSchema() chain. ++ */ + public function __construct( + private IntegrationRegistry $registry, + private ?ExposurePolicy $policy = null, +- private ?DiviToolGate $divi = null ++ private array $guards = [] + ) { + } + +@@ -55,7 +57,7 @@ final class AbilityRegistrar + /** @return array */ + public function argsFor(Ability $ability): array + { +- $divi = $this->divi ?? new DiviToolGate($this->registry->groupMap(...)); ++ $guards = $this->guards; + $args = [ + 'label' => $ability->label(), + 'description' => $ability->description(), +@@ -77,14 +79,22 @@ final class AbilityRegistrar + return Errors::internal(); + } + }, +- 'execute_callback' => static function (mixed $input = null) use ($ability, $divi): mixed { +- $refusal = $divi->refusal($ability); +- if ($refusal !== null) { +- return $refusal; ++ 'execute_callback' => static function (mixed $input = null) use ($ability, $guards): mixed { ++ $input = is_array($input) ? $input : []; ++ foreach ($guards as $guard) { ++ $refusal = $guard->refusal($ability, $input); ++ if ($refusal !== null) { ++ return $refusal; ++ } + } + + try { +- return $divi->annotate($ability, $ability->execute(is_array($input) ? $input : [])); ++ $result = $ability->execute($input); ++ foreach ($guards as $guard) { ++ $result = $guard->annotate($ability, $result); ++ } ++ ++ return $result; + } catch (Throwable $e) { + Log::failure($ability->name(), $e); + +@@ -104,7 +114,10 @@ final class AbilityRegistrar + ], + ]; + +- $output = $divi->outputSchema($ability, $ability->outputSchema()); ++ $output = $ability->outputSchema(); ++ foreach ($guards as $guard) { ++ $output = $guard->outputSchema($ability, $output); ++ } + if ($output !== null) { + $args['output_schema'] = $output; + } +@@ -113,19 +126,31 @@ final class AbilityRegistrar + } + + /** +- * A partial write stays partial_write; otherwise a Divi release lock anywhere in the cause chain is its refusal +- * (spec §5.5); a rolled-back write is write_failed; anything else internal_error. ++ * A partial write stays partial_write; otherwise the first ToolRefusal anywhere in the cause chain is the answer ++ * (e.g. the Divi release lock, spec §5.5); a rolled-back write is write_failed; anything else internal_error. + */ + private static function errorFor(Throwable $e): WP_Error + { + if ($e instanceof WriteFailed && ! $e->rolledBack) { + return $e->toError(); + } +- $locked = DiviLocked::in($e); +- if ($locked !== null) { +- return $locked->toError(); ++ $refusal = self::refusalIn($e); ++ if ($refusal !== null) { ++ return $refusal->toError(); + } + + return $e instanceof WriteFailed ? $e->toError() : Errors::internal(); + } ++ ++ /** The first ToolRefusal in $e's chain of previous exceptions (callers wrap failures in WriteFailed). */ ++ private static function refusalIn(Throwable $e): ?ToolRefusal ++ { ++ for ($cause = $e; $cause !== null; $cause = $cause->getPrevious()) { ++ if ($cause instanceof ToolRefusal) { ++ return $cause; ++ } ++ } ++ ++ return null; ++ } + } +``` + +- [ ] **Step 5: `DiviToolGate` und `DiviLocked` an die Schnittstellen binden, `Plugin.php` verdrahten** + +`src/Integrations/Divi/Release/DiviToolGate.php` (nur Signatur und Docblock; `refusal()` benutzt `$input` nicht – Task 9 ersetzt den Gate durch den `DiviFeatureGate`, der denselben Vertrag hat): + +```diff +--- a/src/Integrations/Divi/Release/DiviToolGate.php ++++ b/src/Integrations/Divi/Release/DiviToolGate.php +@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Release; + + use Closure; + use LIT\MCP\Abilities\Ability; ++use LIT\MCP\Abilities\AbilityGuard; + use LIT\MCP\ChangeLog\ChangeLog; + use LIT\MCP\Integrations\Divi\Library\LibraryAccess; + use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess; +@@ -17,9 +18,9 @@ defined('ABSPATH') || exit; + * The central release lock of the Divi tools (spec §5.5): a writing tool of the groups Divi, Divi-Bibliothek or Divi + * Theme Builder is refused before it runs while Divi is not released (logged with result "refused"); a reading tool + * runs and gets the notice in warnings. Other tools that reach Divi (lit/content-create with divi) meet the adapter +- * lock (ReleaseGate::assertWritable()). ++ * lock (ReleaseGate::assertWritable()). The AbilityRegistrar receives it as an AbilityGuard from Plugin. + */ +-final class DiviToolGate ++final class DiviToolGate implements AbilityGuard + { + public const GROUPS = ['divi', LibraryAccess::GROUP, ThemeBuilderAccess::GROUP]; + +@@ -39,7 +40,7 @@ final class DiviToolGate + return in_array(($this->groups)()[$ability->name()] ?? '', self::GROUPS, true); + } + +- public function refusal(Ability $ability): ?WP_Error ++ public function refusal(Ability $ability, array $input): ?WP_Error + { + if (! $this->covers($ability) || self::readsOnly($ability)) { + return null; +``` + +`src/Integrations/Divi/Release/DiviLocked.php`: + +```diff +--- a/src/Integrations/Divi/Release/DiviLocked.php ++++ b/src/Integrations/Divi/Release/DiviLocked.php +@@ -4,6 +4,7 @@ declare(strict_types=1); + + namespace LIT\MCP\Integrations\Divi\Release; + ++use LIT\MCP\Abilities\ToolRefusal; + use RuntimeException; + use Throwable; + use WP_Error; +@@ -11,7 +12,7 @@ use WP_Error; + defined('ABSPATH') || exit; + + /** A write refused because the installed Divi is not released (spec §5.5); AbilityRegistrar returns toError(). */ +-final class DiviLocked extends RuntimeException ++final class DiviLocked extends RuntimeException implements ToolRefusal + { + public function __construct(public readonly ReleaseStatus $status) + { +``` + +`src/Plugin.php` (Composition Root: dort steht Divi weiter, im Registrar nicht mehr): + +```diff +--- a/src/Plugin.php ++++ b/src/Plugin.php +@@ -144,7 +144,11 @@ final class Plugin + DiviCheckCommand::register($diviCheck); + + $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); +- (new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register(); ++ (new AbilityRegistrar( ++ $registry, ++ $policy, ++ [new DiviToolGate($registry->groupMap(...), $changeLog)] ++ ))->register(); + (new ServerRegistrar( + $registry, + new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames(), $policy), +``` + +- [ ] **Step 6: GREEN** + +Run: `composer test -- --filter 'AbilityRegistrarTest|DiviToolGateTest'` +Expected: OK (28 Tests). + +- [ ] **Step 7: Gesamtlauf und Lint** + +Run: `composer test` +Expected: OK, 2631 Tests (2624 + 7 neue). + +Run: `composer lint` +Expected: keine Ausgabe außer dem Fortschritt, Exit 0. + +- [ ] **Step 8: Commit** + +```bash +git add src/Abilities/AbilityGuard.php src/Abilities/ToolRefusal.php src/Abilities/AbilityRegistrar.php \ + src/Integrations/Divi/Release/DiviToolGate.php src/Integrations/Divi/Release/DiviLocked.php src/Plugin.php \ + tests/Unit/Abilities/AbilityRegistrarTest.php tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php +git commit -m "refactor: AbilityRegistrar nimmt AbilityGuard-Liste und ToolRefusal statt DiviToolGate/DiviLocked; DiviToolGate implementiert AbilityGuard, DiviLocked ToolRefusal; Test mit Fake-Guard und Fake-Refusal ohne Divi" -m "Co-Authored-By: Claude " +``` + +--- + +### Task 2: Ports für die Rückwärtskanten – Kern ohne Divi, `CoreIndependenceTest` + +**Files:** +- Create: `src/Integrations/WordPress/BuilderDetector.php` (Port) +- Create: `src/Integrations/WordPress/NoBuilderDetector.php` (Standard ohne Builder) +- Create: `src/Integrations/Core/EnvironmentComponent.php` (Port) +- Create: `src/Integrations/Media/MediaUsageSource.php` (Port) +- Create: `src/Integrations/Media/AttachmentFile.php` (Dateistamm eines Anhangs, aus `MediaUsageFinder` herausgelöst) +- Create: `src/Integrations/Divi/DiviBuilderDetector.php` +- Create: `src/Integrations/Divi/DiviLogoUsage.php` +- Create: `src/Integrations/Divi/DiviEnvironmentComponent.php` +- Modify: `src/Integrations/WordPress/Content/PostPresenter.php` (Detektor statt `DiviPages`/`DiviModules`; `usesDivi()`, `diviNote()`, `diviHint()` werden Instanzmethoden) +- Modify: `src/Integrations/WordPress/Content/ContentUpdateAbility.php`, `src/Integrations/WordPress/Revisions/RevisionsListAbility.php` (nehmen einen `?PostPresenter`) +- Modify: `src/Integrations/WordPress/WordPressIntegration.php` (`?BuilderDetector $builders`) +- Modify: `src/Integrations/WordPress/Schema/ContentSchema.php` (`array $excludedPostTypes`; kein `DiviModules`) +- Modify: `src/Integrations/Media/MediaUsageFinder.php` (`list`; `siteUsage()` öffentlich; kein `DesignSystemReader`) +- Modify: `src/Integrations/Media/MediaIntegration.php` (`list $usageSources`) +- Modify: `src/Integrations/Core/EnvironmentProbe.php`, `src/Integrations/Core/CoreIntegration.php` (`list`) +- Modify: `src/Integrations/Divi/DiviIntegration.php` (liefert die Ports: `builderDetector()`, `mediaUsageSources()`, `environmentComponent()`, statisch `internalPostTypes()`, `builderEnabledFor()`) +- Modify: `src/Plugin.php` (verdrahtet die Ports) +- Modify: `src/Abilities/DiviMarkup.php` (nur Docblock: Entscheidung „bleibt“) +- Create: `tests/Unit/Architecture/CoreIndependenceTest.php` +- Create: `tests/Support/FakeBuilderDetector.php` +- Create: `tests/Unit/Integrations/Divi/DiviBuilderDetectorTest.php`, `DiviLogoUsageTest.php`, `DiviEnvironmentComponentTest.php` +- Test (geändert): `tests/Unit/Integrations/Core/EnvironmentProbeTest.php` (neu geschrieben), `Core/CoreIntegrationTest.php`, `Divi/DiviIntegrationTest.php`, `Media/MediaUsageFinderTest.php`, `WordPress/Content/PostPresenterTest.php`, `WordPress/Content/ContentUpdateAbilityTest.php`, `WordPress/Revisions/RevisionsListAbilityTest.php`, `WordPress/Schema/ContentSchemaTest.php` (alle unter `tests/Unit/Integrations/`) + +**Interfaces:** +- Consumes: Task 1 (`AbilityRegistrar` ist bereits Divi-frei), `DiviPages::usesBuilder()`, `DiviModules::isAvailable()`, `DiviModules::builderEnabledFor()`, `DiviModules::INTERNAL_POST_TYPES`, `DesignSystemReader::diviLogo()`, `DiviCompatibility::problems()`, `DiviEnvironment::version()`, `ReleaseGate::status()->toArray()` (alle vorhanden, bleiben unverändert). +- Produces (Signaturen wie im Gerüst, Ergänzungen kursiv benannt): + - `interface BuilderDetector { isAvailable(): bool; usesBuilder(int $postId): bool; }` in `LIT\MCP\Integrations\WordPress`; *`NoBuilderDetector`* (beide `false`) ist der Standard von `PostPresenter`. + - `interface EnvironmentComponent { describe(): array{id: string, active: bool, version: ?string, problems: list, release: ?array}; }` in `LIT\MCP\Integrations\Core`. + - `interface MediaUsageSource { usagesOf(int $attachmentId): array; }` in `LIT\MCP\Integrations\Media` (Rückgabe im Format von `MediaUsageFinder::usages()`; der Schlüssel `where` benennt den Ort und ist je Quelle eindeutig – `MediaUsageFinder` legt die Fundstelle unter `site:` ab, so bleibt `site:divi_logo` erhalten). *`MediaUsageFinder::siteUsage()` wird dafür `public static`; `AttachmentFile::stem(int): string` ist der bisher private `fileStem()`.* + - `ContentSchema::__construct(array $sources, ?callable $diviEnabled = null, array $excludedPostTypes = [])`. `ContentSchema::EXCLUDED` enthält nur noch die zehn WordPress-Typen; die Liste für den Filter `lit_mcp_excluded_post_types` ist `EXCLUDED` plus `$excludedPostTypes` (bei Divi also wie heute 18 Einträge). Ohne Prüfung meldet `diviEnabled()` `false`. + - `EnvironmentProbe::__construct(array $components = [])` (`list`): zuerst die übergebenen Komponenten in ihrer Reihenfolge, danach `pods` und `the-events-calendar` wie bisher. + - `PostPresenter::__construct(ContentSchema $schema, ?BuilderDetector $builders = null)`; `usesDivi(WP_Post)`, `diviNote(?bool)`, `diviHint(?bool)` sind **nicht mehr statisch** (Abweichung vom bisherigen Aufruf `PostPresenter::diviHint()`; die einzigen Aufrufer `ContentUpdateAbility` und `RevisionsListAbility` bekommen den Presenter als letzten, optionalen Konstruktorparameter). + - `WordPressIntegration::__construct(..., ?Closure $globalIdsVisible = null, ?BuilderDetector $builders = null)`, `MediaIntegration::__construct(..., Limits $limits, array $usageSources = [])`, `CoreIntegration::__construct(string $guidesDirectory, array $components = [])`, `MediaUsageFinder::__construct(Closure $mediaMetaKeys, ?\wpdb $db = null, array $sources = [])`. + - `DiviIntegration`: `builderDetector(): BuilderDetector`, `mediaUsageSources(): array` (`[DiviLogoUsage]`), `environmentComponent(): EnvironmentComponent`, `static internalPostTypes(): array`, `static builderEnabledFor(string): bool`. Die beiden statischen Methoden gibt es, weil `Plugin::contentSchema()` das Schema **vor** der `DiviIntegration` baut (die Integration bekommt das Schema im Konstruktor): Reihenfolge Schema → Divi bleibt, nur die Divi-Angaben kommen statisch aus `Plugin.php` hinein. + - `DiviEnvironmentComponent::__construct(?DiviCompatibility $compatibility = null)` liefert genau die heutige Form des Divi-Eintrags (Task 10 erweitert sie um die Matrix). +- Entscheidung `src/Abilities/DiviMarkup.php`: bleibt in `Abilities`. Es importiert nichts aus Divi, erkennt nur Textmuster (`']); + +- $full = (new PostPresenter($schema))->full($post); ++ $presenter = new PostPresenter($schema, new FakeBuilderDetector(false, [5])); ++ ++ $full = $presenter->full($post); + + self::assertTrue($full['divi']); + self::assertNull($full['content']); + self::assertNull($full['featured_image']); +- self::assertSame([PostPresenter::diviHint(false)], $full['hints']); ++ self::assertSame([$presenter->diviHint(false)], $full['hints']); + self::assertInstanceOf(stdClass::class, $full['terms']); + self::assertInstanceOf(stdClass::class, $full['fields']); + self::assertSame(Rev::ofPost($post), $full['rev']); +@@ -95,14 +98,35 @@ final class PostPresenterTest extends TestCase + + public function testDiviHintNamesTheDiviToolsOnlyWhenDiviIsActive(): void + { +- self::assertStringContainsString('lit-divi-outline', PostPresenter::diviHint(true)); +- self::assertStringContainsString('lit-content-update', PostPresenter::diviHint(true)); ++ $presenter = new PostPresenter(Mockery::mock(ContentSchema::class)); ++ ++ self::assertStringContainsString('lit-divi-outline', $presenter->diviHint(true)); ++ self::assertStringContainsString('lit-content-update', $presenter->diviHint(true)); + foreach (['lit-divi-insert', 'lit-divi-update', 'lit-divi-move', 'lit-divi-delete'] as $tool) { +- self::assertStringContainsString($tool, PostPresenter::diviHint(true)); ++ self::assertStringContainsString($tool, $presenter->diviHint(true)); + } +- self::assertStringNotContainsString('vorerst im Divi-Builder', PostPresenter::diviHint(true)); +- self::assertStringNotContainsString('lit-divi', PostPresenter::diviHint(false)); +- self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, PostPresenter::diviHint()); ++ self::assertStringNotContainsString('vorerst im Divi-Builder', $presenter->diviHint(true)); ++ self::assertStringNotContainsString('lit-divi', $presenter->diviHint(false)); ++ self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, $presenter->diviHint()); ++ } ++ ++ public function testTheBuilderDetectorDecidesWhichNoteApplies(): void ++ { ++ $schema = Mockery::mock(ContentSchema::class); ++ ++ self::assertSame( ++ PostPresenter::DIVI_NOTE, ++ (new PostPresenter($schema, new FakeBuilderDetector(true)))->diviNote() ++ ); ++ self::assertSame( ++ PostPresenter::DIVI_INACTIVE_NOTE, ++ (new PostPresenter($schema, new FakeBuilderDetector(false)))->diviNote() ++ ); ++ self::assertSame( ++ PostPresenter::DIVI_INACTIVE_NOTE, ++ (new PostPresenter($schema))->diviNote(), ++ 'without a detector no builder is assumed' ++ ); + } + + public function testPasswordAndProtectedReadonlyMetaFieldsAreOmitted(): void +@@ -131,9 +155,21 @@ final class PostPresenterTest extends TestCase + + public function testDiviIsAlsoDetectedFromBlockMarkup(): void + { +- Functions\when('get_post_meta')->justReturn(''); ++ $presenter = new PostPresenter(Mockery::mock(ContentSchema::class), new FakeBuilderDetector(true, [])); + +- self::assertTrue(PostPresenter::usesDivi(new WP_Post(['post_content' => 'x y']))); +- self::assertFalse(PostPresenter::usesDivi(new WP_Post(['post_content' => '']))); ++ self::assertTrue($presenter->usesDivi(new WP_Post(['post_content' => 'x y']))); ++ self::assertFalse($presenter->usesDivi(new WP_Post(['post_content' => '']))); ++ } ++ ++ public function testDiviIsDetectedFromTheBuilderFlagOfTheDetector(): void ++ { ++ $presenter = new PostPresenter(Mockery::mock(ContentSchema::class), new FakeBuilderDetector(true, [7])); ++ ++ self::assertTrue($presenter->usesDivi(new WP_Post(['ID' => 7, 'post_content' => '[et_pb_section]']))); ++ self::assertFalse($presenter->usesDivi(new WP_Post(['ID' => 8, 'post_content' => '[et_pb_section]']))); ++ self::assertFalse( ++ (new PostPresenter(Mockery::mock(ContentSchema::class)))->usesDivi(new WP_Post(['ID' => 7])), ++ 'without a detector only block markup counts' ++ ); + } + } +``` + +```diff +--- a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php ++++ b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php +@@ -13,6 +13,7 @@ use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility; + use LIT\MCP\Integrations\WordPress\Content\PostPresenter; + use LIT\MCP\Integrations\WordPress\Content\PostWriter; + use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; ++use LIT\MCP\Tests\Support\FakeBuilderDetector; + use LIT\MCP\Tests\Support\FakeChangeLog; + use LIT\MCP\Tests\Support\SchemaFixture; + use LIT\MCP\Tests\Unit\TestCase; +@@ -46,9 +47,12 @@ final class ContentUpdateAbilityTest extends TestCase + return $schema; + } + +- private function ability(PostWriter $writer, ?ChangeLog $log = null): ContentUpdateAbility +- { +- return new ContentUpdateAbility($this->schema(), $writer, $log ?? FakeChangeLog::forbidden()); ++ private function ability( ++ PostWriter $writer, ++ ?ChangeLog $log = null, ++ ?PostPresenter $presenter = null ++ ): ContentUpdateAbility { ++ return new ContentUpdateAbility($this->schema(), $writer, $log ?? FakeChangeLog::forbidden(), $presenter); + } + + public function testRevIsRequired(): void +@@ -110,11 +114,11 @@ final class ContentUpdateAbilityTest extends TestCase + + public function testRawContentOnDiviPagesIsUnsupported(): void + { +- Functions\when('get_post_meta')->justReturn('on'); + $writer = Mockery::mock(PostWriter::class); + $writer->expects('prepare')->never(); ++ $presenter = new PostPresenter($this->schema(), new FakeBuilderDetector(false, [5])); + +- $result = $this->ability($writer) ++ $result = $this->ability($writer, null, $presenter) + ->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'content' => '

Neu

']); + + self::assertToolError($result, 'unsupported'); +``` + +```diff +--- a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php ++++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php +@@ -8,6 +8,7 @@ use Brain\Monkey\Functions; + use LIT\MCP\Abilities\Cursor; + use LIT\MCP\Integrations\WordPress\Content\PostPresenter; + use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility; ++use LIT\MCP\Tests\Support\FakeBuilderDetector; + use LIT\MCP\Tests\Support\SchemaFixture; + use LIT\MCP\Tests\Unit\TestCase; + use WP_Post; +@@ -24,11 +25,11 @@ final class RevisionsListAbilityTest extends TestCase + Functions\when('get_post_meta')->justReturn(''); + } + +- private function ability(): RevisionsListAbility ++ private function ability(?PostPresenter $presenter = null): RevisionsListAbility + { + $schema = SchemaFixture::managed(); + +- return new RevisionsListAbility($schema); ++ return new RevisionsListAbility($schema, $presenter); + } + + public function testListsRevisionsNewestFirstWithCursor(): void +@@ -60,11 +61,14 @@ final class RevisionsListAbilityTest extends TestCase + 811 => new WP_Post(['ID' => 811, 'post_content' => '

Normal

']), + ]); + +- $result = $this->ability()->execute(['post_id' => 5]); ++ $presenter = new PostPresenter(SchemaFixture::managed(), new FakeBuilderDetector(true)); ++ ++ $result = $this->ability($presenter)->execute(['post_id' => 5]); + + self::assertTrue($result['items'][0]['divi']); + self::assertFalse($result['items'][1]['divi']); +- self::assertSame([PostPresenter::diviHint()], $result['hints']); ++ self::assertSame([$presenter->diviHint()], $result['hints']); ++ self::assertStringContainsString(PostPresenter::DIVI_NOTE, $result['hints'][0]); + } + + public function testRevisionsOfATrashedPostCanStillBeListed(): void +``` + +```diff +--- a/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php ++++ b/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php +@@ -37,7 +37,7 @@ final class ContentSchemaTest extends TestCase + { + $this->stubPostTypes(); + +- $schema = new ContentSchema([]); ++ $schema = new ContentSchema([], null, ['et_pb_layout']); + + self::assertSame(['lit_person', 'page', 'post'], $schema->postTypeNames()); + self::assertTrue($schema->isManaged('page')); +@@ -80,7 +80,8 @@ final class ContentSchemaTest extends TestCase + new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable'), + new FieldDefinition('zz_hidden', 'Verborgen', 'string', 'pods', FieldDefinition::HIDDEN), + ])], +- static fn (string $type): bool => $type === 'page' ++ static fn (string $type): bool => $type === 'page', ++ ['et_pb_layout'] + ); + + $withoutFields = $schema->describe('page', false); +@@ -118,17 +119,34 @@ final class ContentSchemaTest extends TestCase + self::assertSame(['logo', 'photo'], (new ContentSchema([$source]))->mediaFieldKeys()); + } + +- /** Spec §3: Divi's post types come from the Katalog adapter – the list itself stays the same. */ +- public function testExcludedPostTypesStayTheSame(): void ++ public function testWordPressExcludesItsOwnPostTypesAndIntegrationsAddTheirs(): void + { ++ $this->stubPostTypes(); ++ ++ self::assertSame( ++ ['et_pb_layout', 'lit_person', 'page', 'post'], ++ (new ContentSchema([]))->postTypeNames(), ++ 'without an integration the Divi layouts are an ordinary post type' ++ ); ++ self::assertSame( ++ ['page', 'post'], ++ (new ContentSchema([], null, ['et_pb_layout', 'lit_person']))->postTypeNames() ++ ); + 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', ++ 'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', + ], + ContentSchema::EXCLUDED + ); + } ++ ++ public function testTheBuilderIsNeverOnWithoutACheck(): void ++ { ++ self::assertFalse((new ContentSchema([]))->diviEnabled('page')); ++ $schema = new ContentSchema([], static fn (string $type): bool => $type === 'page'); ++ ++ self::assertTrue($schema->diviEnabled('page')); ++ self::assertFalse($schema->diviEnabled('post')); ++ } + } +``` + +- [ ] **Step 4: RED** + +Run: `composer test` +Expected: FAIL mit 26 Fehlern und 2 Failures (alle in den oben genannten Tests): `Error: Interface "LIT\MCP\Integrations\WordPress\BuilderDetector" not found` (PostPresenter-, ContentUpdate-, RevisionsList-Tests), `Error: Interface "LIT\MCP\Integrations\Core\EnvironmentComponent" not found` (Probe-/CoreIntegration-Tests), `Error: Class "LIT\MCP\Integrations\Divi\DiviLogoUsage" not found` (und `DiviBuilderDetector`, `DiviEnvironmentComponent`), `Error: Call to undefined method LIT\MCP\Integrations\Divi\DiviIntegration::builderDetector()` (und `internalPostTypes()`, `builderEnabledFor()`), `Error: Call to private method LIT\MCP\Integrations\Media\MediaUsageFinder::siteUsage()`; Failures: `CoreIndependenceTest::testTheCoreUsesNoDiviClass` und `ContentSchemaTest::testWordPressExcludesItsOwnPostTypesAndIntegrationsAddTheirs` (`et_pb_layout` wird ohne Divi-Liste noch ausgeschlossen). + +- [ ] **Step 5: Ports und Divi-Implementierungen anlegen** + +`src/Integrations/WordPress/BuilderDetector.php`: + +```php +, release: ?array} + * problems: what the component misses or has changed; release: whether LIT MCP may write with it + * (null for components without such a lock). + */ + public function describe(): array; +} +``` + +`src/Integrations/Media/MediaUsageSource.php`: + +```php + + * Usages of attachment $attachmentId in the format of MediaUsageFinder::usages(); use + * MediaUsageFinder::siteUsage() for site-wide ones (post_id 0). "where" names the place and is + * unique per source (it keys the usage, so a place is reported once). + */ + public function usagesOf(int $attachmentId): array; +} +``` + +`src/Integrations/Media/AttachmentFile.php`: + +```php + 'divi', + 'active' => $active, + 'version' => $version, + 'problems' => $active ? ($this->compatibility ?? new DiviCompatibility())->problems() : [], + 'release' => $active ? ReleaseGate::status()->toArray() : null, + ]; + } +} +``` + +- [ ] **Step 6: Den Kern auf die Ports umstellen** + +`PostPresenter` (Detektor statt Divi-Klassen; Instanzmethoden): + +```diff +--- a/src/Integrations/WordPress/Content/PostPresenter.php ++++ b/src/Integrations/WordPress/Content/PostPresenter.php +@@ -6,8 +6,8 @@ namespace LIT\MCP\Integrations\WordPress\Content; + + use LIT\MCP\Abilities\JsonMap; + use LIT\MCP\Abilities\Rev; +-use LIT\MCP\Integrations\Divi\Pages\DiviPages; +-use LIT\MCP\Integrations\Divi\Schema\DiviModules; ++use LIT\MCP\Integrations\WordPress\BuilderDetector; ++use LIT\MCP\Integrations\WordPress\NoBuilderDetector; + use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; + use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition; + use WP_Post; +@@ -18,12 +18,9 @@ defined('ABSPATH') || exit; + * Output format of an entry for the content tools. Not final so abilities can be + * unit-tested with a mock. + * +- * Layering exception: diviNote()/diviHint() call DiviModules::isAvailable() (Divi +- * integration) to decide whether the Divi tools exist – the WordPress integration +- * otherwise never depends on Divi. Kept because the alternative (injecting +- * availability through every constructor that reaches PostPresenter) would spread the +- * same one-line check across content-get, content-update and revisions-list for no +- * behavioral gain; $toolsAvailable can still be passed in explicitly for tests. ++ * The builder (Divi) is known only through the BuilderDetector port: usesDivi() asks it for the builder flag ++ * of a post, diviNote()/diviHint() for whether the builder's tools exist; $toolsAvailable can still be passed in ++ * explicitly. Without a detector no builder is assumed (NoBuilderDetector). + */ + class PostPresenter + { +@@ -36,21 +33,24 @@ class PostPresenter + public const DIVI_INACTIVE_NOTE = 'Der Divi-5-Builder ist auf dieser Website nicht aktiv, daher gibt es keine ' + . 'Divi-Werkzeuge; Divi-Inhalte nur im Divi-Builder bearbeiten.'; + +- /** $toolsAvailable defaults to whether the Divi integration is available (Divi 5 loaded). */ +- public static function diviNote(?bool $toolsAvailable = null): string ++ private BuilderDetector $builders; ++ ++ public function __construct(private ContentSchema $schema, ?BuilderDetector $builders = null) + { +- return ($toolsAvailable ?? DiviModules::isAvailable()) ? self::DIVI_NOTE : self::DIVI_INACTIVE_NOTE; ++ $this->builders = $builders ?? new NoBuilderDetector(); + } + +- public static function diviHint(?bool $toolsAvailable = null): string ++ /** $toolsAvailable defaults to whether the builder is available (Divi 5 loaded). */ ++ public function diviNote(?bool $toolsAvailable = null): string + { +- return 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. ' +- . self::diviNote($toolsAvailable) . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update ' +- . '(direkt: lit-content-update) änderbar.'; ++ return ($toolsAvailable ?? $this->builders->isAvailable()) ? self::DIVI_NOTE : self::DIVI_INACTIVE_NOTE; + } + +- public function __construct(private ContentSchema $schema) ++ public function diviHint(?bool $toolsAvailable = null): string + { ++ return 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. ' ++ . $this->diviNote($toolsAvailable) . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update ' ++ . '(direkt: lit-content-update) änderbar.'; + } + + /** @return array */ +@@ -67,14 +67,14 @@ class PostPresenter + 'modified' => (string) $post->post_modified, + 'url' => (string) get_permalink($post), + 'edit_url' => (string) get_edit_post_link($post->ID, 'raw'), +- 'divi' => self::usesDivi($post), ++ 'divi' => $this->usesDivi($post), + ]; + } + + /** @return array */ + public function full(WP_Post $post): array + { +- $divi = self::usesDivi($post); ++ $divi = $this->usesDivi($post); + + return array_merge($this->summary($post), [ + 'excerpt' => (string) $post->post_excerpt, +@@ -90,13 +90,13 @@ class PostPresenter + 'fields' => JsonMap::of($this->fields($post)), + 'featured_image' => $this->featuredImage($post), + 'rev' => Rev::ofPost($post), +- 'hints' => $divi ? [self::diviHint()] : [], ++ 'hints' => $divi ? [$this->diviHint()] : [], + ]); + } + +- public static function usesDivi(WP_Post $post): bool ++ public function usesDivi(WP_Post $post): bool + { +- return DiviPages::usesBuilder((int) $post->ID) ++ return $this->builders->usesBuilder((int) $post->ID) + || str_contains((string) $post->post_content, ''), $page]; + yield 'save library item' => [ + static fn () => (new DiviSaver())->save($post, 'x', SaveScope::LibraryItem), + [DiviFeature::PageWrite, DiviFeature::LibraryWrite], + ]; + yield 'save global item' => [ + static fn () => (new DiviSaver())->save($post, 'x', SaveScope::GlobalItem), + [DiviFeature::PageWrite, DiviFeature::LibraryWrite], + ]; + yield 'save tb layout' => [ + static fn () => (new DiviSaver())->save($post, 'x', SaveScope::ThemeBuilderLayout), + [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite], + ]; + yield 'saveNew' => [static fn () => (new DiviSaver())->saveNew(7, ''), $page]; + yield 'saveNew library item' => [ + static fn () => (new DiviSaver())->saveNew(7, 'x', SaveScope::LibraryItem), + [DiviFeature::PageWrite, DiviFeature::LibraryWrite], + ]; + yield 'pages setFlags' => [static fn () => DiviPages::setFlags(7), $page]; + yield 'library create' => [static fn () => (new DiviLibraryStore())->create( + $spec, + new NewDraft(null, FakeChangeLog::forbidden(), 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag') + ), [DiviFeature::LibraryWrite]]; + yield 'tb liveId create' => [static fn () => (new DiviThemeBuilder())->liveId(true), $tb]; + yield 'tb insertLayout' => [static fn () => (new DiviThemeBuilder())->insertLayout('header', 'Kopf'), $tb]; + yield 'tb storeTemplate' => [static fn () => (new DiviThemeBuilder())->storeTemplate(3, [ + 'title' => 'V', 'layouts' => [], 'use_on' => [], 'exclude_from' => [], + ]), $tb]; + yield 'tb attach' => [static fn () => (new DiviThemeBuilder())->attach(3, 9), $tb]; + yield 'tb detach' => [static fn () => (new DiviThemeBuilder())->detach(3, 9), $tb]; + yield 'tb setConditions' => [static fn () => (new DiviThemeBuilder())->setConditions(9, ['singular'], []), $tb]; + yield 'tb setEnabled' => [static fn () => (new DiviThemeBuilder())->setEnabled(9, true), $tb]; + yield 'tb unmarkUnused' => [static fn () => (new DiviThemeBuilder())->unmarkUnused(9), $tb]; + yield 'design savePresets' => [ + static fn () => (new DesignSystemWriter())->savePresets(['module' => [], 'group' => []], false), + [DiviFeature::PresetWrite], + ]; + } + + /** + * @dataProvider writes + * @param list $needs + */ + public function testRefusesBeforeTheFirstWriteWhenAFeatureItNeedsIsLocked(callable $write, array $needs): void + { + foreach ([ReleaseState::Untested, ReleaseState::Incompatible] as $state) { + foreach ($needs as $feature) { + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([$feature], $state)); + + try { + $write(); + self::fail(sprintf('%s %s must refuse', $feature->value, $state->value)); + } catch (DiviLocked $locked) { + self::assertSame([$feature], $locked->features); + } + } + } + } + + /** + * @dataProvider writes + * @param list $needs + */ + public function testPassesTheGateWhenOnlyOtherFeaturesAreLocked(callable $write, array $needs): void + { + $others = array_values(array_filter( + DiviFeature::cases(), + static fn (DiviFeature $feature): bool => ! in_array($feature, $needs, true) + )); + ReleaseGate::useResolver( + static fn (): ReleaseStatus => ReleaseMatrices::status($others, ReleaseState::Incompatible) + ); + // Past the gate the method may write or fail on the missing Divi – anything but the lock. + foreach (self::WRITES as $name) { + Functions\when($name)->alias(static function () use ($name): never { + throw new RuntimeException($name . ' reached'); + }); + } + + try { + $write(); + } catch (DiviLocked $locked) { + self::fail('refused although only other features are locked: ' . $locked->getMessage()); + } catch (Throwable) { + // Reached the code behind the gate. + } + self::addToAssertionCount(1); + } + + public function testTheCheckPageMayBeWrittenWhileProbing(): void + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0')); + 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); + } +} +``` + +`tests/Unit/Integrations/Divi/Release/DiviLockedTest.php` (neu): + +```php +getMessage()); + self::assertEquals($status->refusalFor([DiviFeature::PresetWrite]), $locked->toError()); + $payload = self::assertToolError($locked->toError(), 'divi_incompatible'); + self::assertSame(['preset-write'], array_column($payload['features'], 'id')); + } + + public function testWithoutFeaturesTheErrorIsTheGeneralWriteLock(): void + { + $status = new ReleaseStatus(ReleaseState::Untested, '5.3.0'); + $locked = new DiviLocked($status); + + self::assertSame('Divi 5.3.0 is untested for LIT MCP – write refused', $locked->getMessage()); + self::assertEquals($status->refusal(), $locked->toError()); + } +} +``` + +`tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php` vollständig (Nachher; neu gegenüber Task 6: Imports +`CompatibilityMatrix`, `ReleaseMatrices`, die Tests `testTheTestCaseDefaultReleasesEveryFeature` (umbenannt), +`testFeatureReadsTheMatrixOfThisRequest`, `testAssertFeatureRefusesOnlyALockedFeature`, +`testAssertFeaturesNamesEveryLockedFeatureOnce`, `testAssertWritableNeedsEveryWritingFeature`, Probe mit +`assertFeatures`/`assertFeature`, Resolver-Fehler sperrt auch eine Lese-Fähigkeit): + +```php + new ReleaseStatus(ReleaseState::Untested, '5.3.0')); + } + + public function testTheTestCaseDefaultReleasesEveryFeature(): void + { + self::assertTrue(ReleaseGate::status()->writable()); + self::assertSame([], ReleaseGate::status()->matrix->unusable()); + ReleaseGate::assertWritable(7); + ReleaseGate::assertFeatures(DiviFeature::cases()); + } + + public function testFeatureReadsTheMatrixOfThisRequest(): void + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::Catalog])); + + self::assertSame(ReleaseState::Untested, ReleaseGate::feature(DiviFeature::Catalog)->state); + self::assertTrue(ReleaseGate::feature(DiviFeature::PageRead)->usable()); + } + + public function testAssertFeatureRefusesOnlyALockedFeature(): void + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status( + [DiviFeature::PresetWrite], + ReleaseState::Incompatible + )); + ReleaseGate::assertFeature(DiviFeature::PageWrite); + + try { + ReleaseGate::assertFeature(DiviFeature::PresetWrite, 7); + self::fail('a locked feature must refuse'); + } catch (DiviLocked $locked) { + self::assertSame([DiviFeature::PresetWrite], $locked->features); + self::assertToolError($locked->toError(), 'divi_incompatible'); + } + } + + public function testAssertFeaturesNamesEveryLockedFeatureOnce(): void + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status( + [DiviFeature::LibraryWrite, DiviFeature::ThemeBuilderWrite] + )); + + try { + ReleaseGate::assertFeatures([ + DiviFeature::PageWrite, + DiviFeature::ThemeBuilderWrite, + DiviFeature::LibraryWrite, + DiviFeature::ThemeBuilderWrite, + ]); + self::fail('locked features must refuse'); + } catch (DiviLocked $locked) { + self::assertSame([DiviFeature::ThemeBuilderWrite, DiviFeature::LibraryWrite], $locked->features); + } + } + + public function testAssertWritableNeedsEveryWritingFeature(): void + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::Catalog])); + ReleaseGate::assertWritable(); + + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::LibraryWrite])); + try { + ReleaseGate::assertWritable(); + self::fail('a locked writing feature must refuse'); + } catch (DiviLocked $locked) { + self::assertSame([DiviFeature::LibraryWrite], $locked->features); + } + + ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0')); + try { + ReleaseGate::assertWritable(); + self::fail('an untested Divi must lock writes'); + } catch (DiviLocked $locked) { + self::assertSame(CompatibilityMatrix::writeFeatures(), $locked->features); + } + } + + public function testAssertWritableThrowsTheLockWithTheStatus(): void + { + $this->untested(); + + try { + ReleaseGate::assertWritable(7); + self::fail('an untested Divi must lock writes'); + } catch (DiviLocked $locked) { + self::assertSame('5.3.0', $locked->status->version); + self::assertToolError($locked->toError(), 'divi_untested'); + } + } + + public function testProbingAllowsOnlyThatPostAndOnlyMeanwhile(): void + { + $this->untested(); + + $result = ReleaseGate::whileProbing(41, static function (): string { + ReleaseGate::assertWritable(41); + ReleaseGate::assertFeatures(DiviFeature::cases(), 41); + + return 'written'; + }); + self::assertSame('written', $result); + + foreach ([static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertWritable(42)), + static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertWritable()), + static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertFeature( + DiviFeature::PageWrite, + 42 + )), + static fn () => ReleaseGate::assertWritable(41)] as $attempt) { + try { + $attempt(); + self::fail('only post 41, only while probing'); + } catch (DiviLocked) { + self::addToAssertionCount(1); + } + } + } + + public function testProbingEndsEvenWhenTheWriteThrows(): void + { + $this->untested(); + + try { + ReleaseGate::whileProbing(41, static function (): never { + throw new RuntimeException('boom'); + }); + } catch (RuntimeException) { + } + + $this->expectException(DiviLocked::class); + ReleaseGate::assertWritable(41); + } + + public function testStatusIsComputedOncePerRequestUntilForgotten(): void + { + $calls = 0; + ReleaseGate::useResolver(static function () use (&$calls): ReleaseStatus { + ++$calls; + + return ReleaseStatus::released('5.2.1'); + }); + + ReleaseGate::status(); + ReleaseGate::status(); + self::assertSame(1, $calls); + + ReleaseGate::forget(); + ReleaseGate::status(); + self::assertSame(2, $calls); + } + + public function testAFailingResolverLocksWrites(): void + { + ReleaseGate::useResolver(static function (): never { + throw new RuntimeException('theme API gone'); + }); + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + + try { + $status = ReleaseGate::status(); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertSame(ReleaseState::Incompatible, $status->state); + self::assertCount(count(DiviFeature::cases()), $status->matrix->unusable(), 'every feature is locked'); + self::assertSame( + ['Der Freigabe-Zustand ließ sich nicht ermitteln (Details im PHP-Error-Log).'], + $status->matrix->of(DiviFeature::PageRead)->problems + ); + self::assertStringContainsString('theme API gone', (string) file_get_contents($log)); + unlink($log); + $this->expectException(DiviLocked::class); + ReleaseGate::assertFeature(DiviFeature::PageRead); + } + + public function testFindsTheLockInsideAWrappedFailure(): void + { + $locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0')); + $wrapped = WriteFailed::rolledBack('Nicht gespeichert.', new RuntimeException('draft', 0, $locked)); + + self::assertSame($locked, DiviLocked::in($wrapped)); + self::assertNull(DiviLocked::in(new RuntimeException('other'))); + } +} +``` + +`tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php` – Import `use LIT\MCP\Integrations\Divi\Release\FeatureStatus;` +(nach `DiviFeature`) ergänzen und am Ende der Klasse anfügen: + +```php + public function testRefusalForAnUntestedFeatureNamesItAndKeepsTheOthersWorking(): void + { + $error = ReleaseMatrices::status([DiviFeature::PageWrite]) + ->refusalFor([DiviFeature::PageWrite, DiviFeature::PageRead]); + $payload = self::assertToolError($error, 'divi_untested'); + + self::assertSame(423, $error->get_error_data()['status']); + + self::assertSame( + 'Achtung: Divi 5.3.0 ist auf dieser Website für „Seiten schreiben“ noch nicht für LIT MCP freigegeben. ' + . 'Das ist gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung ausführt ' + . 'und die Version freigibt. Es wurde nichts geändert.', + $payload['message'] + ); + self::assertSame( + 'Gesperrt: „Seiten schreiben“. Andere Divi-Werkzeuge funktionieren weiter, sofern ihre Fähigkeiten ' + . 'freigegeben sind. Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben.', + $payload['hint'] + ); + self::assertSame( + [['id' => 'page-write', 'label' => 'Seiten schreiben', 'state' => 'untested', 'problems' => []]], + $payload['features'] + ); + self::assertArrayNotHasKey('problems', $payload); + } + + public function testRefusalForTakesTheWorstStateAndListsEveryLockedFeature(): void + { + $matrix = ReleaseMatrices::matrix([DiviFeature::ThemeBuilderWrite], ReleaseState::Incompatible); + $features = []; + foreach (DiviFeature::cases() as $feature) { + $features[$feature->value] = $matrix->of($feature); + } + $features['library-write'] = new FeatureStatus(DiviFeature::LibraryWrite, ReleaseState::Untested); + $status = ReleaseStatus::of('5.99.0', new CompatibilityMatrix($features)); + + $payload = self::assertToolError( + $status->refusalFor([DiviFeature::PageWrite, DiviFeature::LibraryWrite, DiviFeature::ThemeBuilderWrite]), + 'divi_incompatible' + ); + + self::assertSame( + 'Divi 5.99.0 hat Stellen geändert, die LIT MCP für „Theme Builder schreiben“ nutzt. Das ist gesperrt, bis ' + . 'LIT MCP aktualisiert wurde. Es wurde nichts geändert.', + $payload['message'] + ); + self::assertStringStartsWith( + 'Gesperrt: „Bibliothek schreiben“ und „Theme Builder schreiben“. Andere Divi-Werkzeuge', + $payload['hint'] + ); + self::assertStringEndsWith('dass LIT MCP aktualisiert werden muss.', $payload['hint']); + self::assertSame(['library-write', 'tb-write'], array_column($payload['features'], 'id')); + self::assertSame(['untested', 'incompatible'], array_column($payload['features'], 'state')); + self::assertSame(['Seiten: et_x() – Signatur geändert'], $payload['problems']); + } + + public function testRefusalForNamesThreeFeatures(): void + { + $locked = [DiviFeature::PageWrite, DiviFeature::LibraryWrite, DiviFeature::PresetWrite]; + $payload = self::assertToolError(ReleaseMatrices::status($locked)->refusalFor($locked), 'divi_untested'); + + self::assertStringStartsWith( + 'Gesperrt: „Seiten schreiben“, „Bibliothek schreiben“ und „Presets schreiben“.', + $payload['hint'] + ); + } + + public function testUsableFeaturesHaveNoRefusal(): void + { + $this->expectException(LogicException::class); + + ReleaseMatrices::status([DiviFeature::Catalog])->refusalFor([DiviFeature::PageWrite, DiviFeature::PageRead]); + } +``` + +`tests/Unit/Integrations/Divi/Pages/SaveScopeTest.php` – Import `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` +und nach `testThemeBuilderLayoutsGetFlagsAndClearEveryPage()`: + +```php + /** D5: every save needs page-write, a library or Theme Builder save the feature of its area too. */ + public function testFeaturesOfEachScope(): void + { + self::assertSame([DiviFeature::PageWrite], SaveScope::Page->features()); + self::assertSame([DiviFeature::PageWrite, DiviFeature::LibraryWrite], SaveScope::LibraryItem->features()); + self::assertSame([DiviFeature::PageWrite, DiviFeature::LibraryWrite], SaveScope::GlobalItem->features()); + self::assertSame( + [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite], + SaveScope::ThemeBuilderLayout->features() + ); + } +``` + +`tests/Unit/Integrations/Divi/Design/PresetAreaTest.php` – Import `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` +und in `testWritesTheRestFormatAndReadsBack()` nach der `name()/label()`-Zeile: + +```php + self::assertSame(DiviFeature::PresetWrite, $area->feature()); +``` + +`tests/Unit/Integrations/Divi/Design/DesignChangeTest.php` – Imports `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` +(vor `DiviLocked`) und `use LIT\MCP\Tests\Support\ReleaseMatrices;` (nach `FakeChangeLog`); in der anonymen +`DesignArea` von `area()` nach `label()`: + +```php + public function feature(): DiviFeature + { + return DiviFeature::PresetWrite; + } +``` + +und vor `testTooLargeSnapshotRefusesTheChange()`: + +```php + public function testTheLockFollowsTheFeatureOfTheArea(): void + { + $others = array_values(array_filter( + DiviFeature::cases(), + static fn (DiviFeature $feature): bool => $feature !== DiviFeature::PresetWrite + )); + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status($others)); + $stored = ['v' => 1]; + $area = self::area($stored); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('s1'); + $snapshots->expects('attachLog'); + + $written = (new DesignChange($snapshots, new FakeChangeLog(9)))->run( + 'lit/divi-presets-set', + $area, + ['confirm' => true, 'design_rev' => $area->rev($stored)], + self::plan(['v' => 2]) + ); + self::assertTrue($written['ok'], 'every other feature locked: presets are still written'); + + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::PresetWrite])); + try { + (new DesignChange(Mockery::mock(SnapshotStore::class), FakeChangeLog::forbidden()))->run( + 'lit/divi-presets-set', + $area, + ['confirm' => true, 'design_rev' => $area->rev($stored)], + self::plan(['v' => 3]) + ); + self::fail('preset-write locked must refuse'); + } catch (DiviLocked $locked) { + self::assertSame([DiviFeature::PresetWrite], $locked->features); + } + } + +``` + +- [ ] **Step 2: Tests laufen lassen – sie schlagen fehl** + +Run: `composer test` +Expected: FAIL – u. a. `Error: Call to undefined method …ReleaseGate::assertFeatures()` / `::assertFeature()` / +`::feature()`, `Call to undefined method …ReleaseStatus::refusalFor()`, `Call to undefined method +…SaveScope::features()`, `Call to undefined method …PresetArea::feature()`, `Undefined property …DiviLocked::$features`; +in `ReleaseGuardsTest` scheitern die „passes the gate“-Fälle mit `refused although only other features are locked` +(heute sperrt jede Methode bei jeder gesperrten Schreib-Fähigkeit). + +- [ ] **Step 3: `ReleaseStatus::refusalFor()` (D6)** + +`src/Integrations/Divi/Release/ReleaseStatus.php` – nach der Konstante `NOTICE` einfügen: + +```php + private const FEATURE_UNTESTED = 'Achtung: %1$s ist auf dieser Website für %2$s noch nicht für LIT MCP ' + . 'freigegeben. Das ist gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung ' + . 'ausführt und die Version freigibt. Es wurde nichts geändert.'; + private const FEATURE_UNTESTED_HINT = 'Gesperrt: %s. Andere Divi-Werkzeuge funktionieren weiter, sofern ihre ' + . 'Fähigkeiten freigegeben sind. Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben.'; + private const FEATURE_INCOMPATIBLE = '%1$s hat Stellen geändert, die LIT MCP für %2$s nutzt. Das ist gesperrt, ' + . 'bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.'; + private const FEATURE_INCOMPATIBLE_HINT = 'Gesperrt: %s. Andere Divi-Werkzeuge funktionieren weiter, sofern ihre ' + . 'Fähigkeiten freigegeben sind. Den Nutzer informieren, dass LIT MCP aktualisiert werden muss.'; +``` + +Vor `/** The warning a reading Divi tool carries while writes are locked. */` einfügen: + +```php + /** + * The error of a call refused because some of $features are locked (D6): divi_incompatible when one of them is + * incompatible, else divi_untested; HTTP 423. The message names the features in that state, the hint every + * locked one; details.features lists them (id, label, state, problems), details.problems the problems of an + * incompatible refusal as before. + * + * @param list $features What the refused call needs. + * @throws LogicException When all of them are usable. + */ + public function refusalFor(array $features): WP_Error + { + $locked = []; + foreach ($features as $feature) { + $status = $this->matrix->of($feature); + if (! $status->usable()) { + $locked[$feature->value] = $status; + } + } + if ($locked === []) { + throw new LogicException('usable features refuse nothing'); + } + + $locked = array_values($locked); + $incompatible = array_values(array_filter( + $locked, + static fn (FeatureStatus $status): bool => $status->state === ReleaseState::Incompatible + )); + $details = ['features' => array_map(static fn (FeatureStatus $status): array => $status->summary(), $locked)]; + if ($incompatible !== []) { + $details['problems'] = array_values(array_unique(array_merge( + ...array_map(static fn (FeatureStatus $status): array => $status->problems, $locked) + ))); + } + + return Errors::make( + $incompatible !== [] ? 'divi_incompatible' : 'divi_untested', + sprintf( + $incompatible !== [] ? self::FEATURE_INCOMPATIBLE : self::FEATURE_UNTESTED, + $this->versionLabel(), + self::named($incompatible !== [] ? $incompatible : $locked) + ), + sprintf( + $incompatible !== [] ? self::FEATURE_INCOMPATIBLE_HINT : self::FEATURE_UNTESTED_HINT, + self::named($locked) + ), + 423, + $details + ); + } +``` + +Vor dem Docblock von `toArray()` einfügen: + +```php + /** + * "„Seiten schreiben“", "„A“ und „B“", "„A“, „B“ und „C“". + * + * @param non-empty-list $statuses + */ + private static function named(array $statuses): string + { + $labels = array_map( + static fn (FeatureStatus $status): string => '„' . $status->feature->label() . '“', + $statuses + ); + $last = array_pop($labels); + + return $labels === [] ? $last : implode(', ', $labels) . ' und ' . $last; + } +``` + +- [ ] **Step 4: `DiviLocked` mit Fähigkeiten** + +`src/Integrations/Divi/Release/DiviLocked.php` vollständig (Vorher: Stand nach Task 1 mit `implements ToolRefusal` +und nur `$status`): + +```php + $features The locked features the refused call needs; [] = writing in general. */ + public function __construct(public readonly ReleaseStatus $status, public readonly array $features = []) + { + parent::__construct(sprintf( + '%s is %s for LIT MCP – %s refused', + $status->versionLabel(), + $status->state->value, + $features === [] + ? 'write' + : implode(', ', array_map(static fn (DiviFeature $feature): string => $feature->value, $features)) + )); + } + + public function toError(): WP_Error + { + return $this->features === [] ? $this->status->refusal() : $this->status->refusalFor($this->features); + } + + /** The lock anywhere in $e's chain of previous exceptions (callers wrap failures in WriteFailed). */ + public static function in(Throwable $e): ?self + { + for ($cause = $e; $cause !== null; $cause = $cause->getPrevious()) { + if ($cause instanceof self) { + return $cause; + } + } + + return null; + } +} +``` + +- [ ] **Step 5: `ReleaseGate` – `feature()`, `assertFeature()`, `assertFeatures()`** + +`src/Integrations/Divi/Release/ReleaseGate.php` vollständig: + +```php + (new ReleaseResolver())->resolve())(); + } catch (Throwable $e) { + Log::failure('divi release status', $e); + self::$status = ReleaseStatus::of('', CompatibilityMatrix::allIncompatible(self::UNRESOLVED)); + } + + return self::$status; + } + + /** @param (Closure(): ReleaseStatus)|null $resolver null = ReleaseResolver; tests set their own. */ + public static function useResolver(?Closure $resolver): void + { + self::$resolver = $resolver; + self::$status = null; + self::$probe = null; + } + + /** The next status() computes again (after a check, a release or a withdrawal). */ + public static function forget(): void + { + self::$status = null; + } + + public static function feature(DiviFeature $feature): FeatureStatus + { + return self::status()->matrix->of($feature); + } + + /** @throws DiviLocked When $feature is not usable – before anything is written. */ + public static function assertFeature(DiviFeature $feature, ?int $postId = null): void + { + self::assertFeatures([$feature], $postId); + } + + /** + * Every one of $features usable, else DiviLocked with the locked ones. The probe post passes whatever the status. + * + * @param list $features + * @throws DiviLocked + */ + public static function assertFeatures(array $features, ?int $postId = null): void + { + if (self::$probe !== null && $postId === self::$probe) { + return; + } + + $status = self::status(); + $locked = []; + foreach ($features as $feature) { + if (! $status->matrix->of($feature)->usable()) { + $locked[$feature->value] = $feature; + } + } + if ($locked !== []) { + throw new DiviLocked($status, array_values($locked)); + } + } + + /** + * Every writing feature (CompatibilityMatrix::writeFeatures()) – for callers that know no feature. + * + * @throws DiviLocked + */ + public static function assertWritable(?int $postId = null): void + { + self::assertFeatures(CompatibilityMatrix::writeFeatures(), $postId); + } + + public static function whileProbing(int $postId, callable $write): mixed + { + $previous = self::$probe; + self::$probe = $postId; + + try { + return $write(); + } finally { + self::$probe = $previous; + } + } +} +``` + +- [ ] **Step 6: `SaveScope::features()` und `DiviSaver` (D5)** + +`src/Integrations/Divi/Pages/SaveScope.php` – nach `namespace` den Import und nach `clearsSiteCss()` die Methode: + +```php +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +``` + +```php + /** @return list What a write in this scope needs (D5): page-write and the feature of its area. */ + public function features(): array + { + return match ($this) { + self::Page => [DiviFeature::PageWrite], + self::LibraryItem, self::GlobalItem => [DiviFeature::PageWrite, DiviFeature::LibraryWrite], + self::ThemeBuilderLayout => [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite], + }; + } +``` + +`src/Integrations/Divi/Pages/DiviSaver.php`, `save()` (Vorher → Nachher): + +```php + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + */ + public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array + { + $postId = (int) $post->ID; + ReleaseGate::assertWritable($postId); +``` + +```php + * @throws DiviLocked When a feature of $scope is not released (spec §5.5, D5) – before anything is written. + */ + public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array + { + $postId = (int) $post->ID; + ReleaseGate::assertFeatures($scope->features(), $postId); +``` + +`saveNew()` (Vorher → Nachher): + +```php + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + */ + public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array + { + ReleaseGate::assertWritable($postId); +``` + +```php + * @throws DiviLocked When a feature of $scope is not released (spec §5.5, D5) – before anything is written. + */ + public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array + { + ReleaseGate::assertFeatures($scope->features(), $postId); +``` + +- [ ] **Step 7: Adapter auf ihre Fähigkeit umstellen** + +`src/Integrations/Divi/Pages/DiviPages.php` – Import `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` (nach +`DiviAdapter`) und `setFlags()` (Vorher → Nachher): + +```php + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + */ + public static function setFlags(int $postId): void + { + ReleaseGate::assertWritable($postId); +``` + +```php + * @throws DiviLocked When page-write is not released (spec §5.5) – before anything is written. + */ + public static function setFlags(int $postId): void + { + ReleaseGate::assertFeature(DiviFeature::PageWrite, $postId); +``` + +`src/Integrations/Divi/Library/DiviLibraryStore.php` – Import `DiviFeature` (nach `DiviAdapter`) und `create()`: + +```php + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + */ + public function create(LibraryItemSpec $spec, NewDraft $draft): int + { + ReleaseGate::assertWritable(); +``` + +```php + * @throws DiviLocked When library-write is not released (spec §5.5) – before anything is written. + */ + public function create(LibraryItemSpec $spec, NewDraft $draft): int + { + ReleaseGate::assertFeature(DiviFeature::LibraryWrite); +``` + +`src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php` – Import `DiviFeature` (nach `DiviAdapter`); alle **acht** +Vorkommen (`liveId`, `insertLayout`, `storeTemplate`, `attach`, `detach`, `setConditions`, `setEnabled`, +`unmarkUnused`): + +```php + ReleaseGate::assertWritable(); +``` + +→ + +```php + ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite); +``` + +(Einrückung wie vorgefunden) und in deren `@throws DiviLocked`-Zeilen `When Divi is not released (spec §5.5)` → +`When tb-write is not released (spec §5.5)` (in `liveId`: `When $create and tb-write is not released …`). +Kontrolle: `grep -c 'assertFeature(DiviFeature::ThemeBuilderWrite)' src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php` +→ `8`. + +`src/Integrations/Divi/Design/DesignSystemWriter.php` – Import `DiviFeature` (nach `DiviAdapter`) und `savePresets()`: + +```php + * @throws DiviLocked + * @throws RuntimeException When Divi refuses the inventory. + */ + public function savePresets(array $rest, bool $mayRemove): array + { + ReleaseGate::assertWritable(); +``` + +```php + * @throws DiviLocked When preset-write is not released – before anything is written. + * @throws RuntimeException When Divi refuses the inventory. + */ + public function savePresets(array $rest, bool $mayRemove): array + { + ReleaseGate::assertFeature(DiviFeature::PresetWrite); +``` + +- [ ] **Step 8: `DesignArea::feature()` und `DesignChange`** + +`src/Integrations/Divi/Design/DesignArea.php` – nach `namespace` `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` +und nach `label()`: + +```php + /** The Divi feature a write of this area needs (DesignChange asks ReleaseGate::assertFeature() for it). */ + public function feature(): DiviFeature; +``` + +`src/Integrations/Divi/Design/PresetArea.php` – gleicher Import und nach `label()`: + +```php + public function feature(): DiviFeature + { + return DiviFeature::PresetWrite; + } +``` + +`src/Integrations/Divi/Design/DesignChange.php`, `apply()` (Vorher → Nachher): + +```php + ReleaseGate::assertWritable(); + try { + $snapshotId = $this->snapshots->save($area->name(), $current, $ability); +``` + +```php + ReleaseGate::assertFeature($area->feature()); + try { + $snapshotId = $this->snapshots->save($area->name(), $current, $ability); +``` + +Kontrolle: `grep -rn 'assertWritable' src` → nur noch die Definition in `ReleaseGate.php` (und der Docblock in +`DiviToolGate`, der mit Task 9 verschwindet). + +- [ ] **Step 9: `TestCase`-Standard: alle Fähigkeiten freigegeben** + +`tests/Unit/TestCase.php` – Import `use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;` (vor `ReleaseGate`) +und (Vorher → Nachher): + +```php + // Divi is released unless a test says otherwise (the release gate would ask WordPress and Divi). + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseStatus::released('5.2.1')); +``` + +```php + // Every Divi feature is released unless a test says otherwise (the gate would ask WordPress and Divi). + ReleaseGate::useResolver( + static fn (): ReleaseStatus => ReleaseStatus::of('5.2.1', CompatibilityMatrix::allReleased()) + ); +``` + +**Bestehende Tests mit `ReleaseStatus`/`DiviLocked`** (grep `ReleaseStatus(`, `DiviLocked(`, `useResolver(` in +`tests/`): `AbilityRegistrarTest` (223, 234, 243), `DiviTabTest` (148–249), `DesignSystemWriterTest:41`, +`DesignChangeTest:370`, `PageRewriterTest:459`, `DiviDesignDetachAbilityTest:381/385`, `DiviToolGateTest:43`, +`WriteProbeTest:28/50/103`, `ContentCreateDiviTest:202` bleiben **unverändert** gültig: `new ReleaseStatus(Untested| +Incompatible, …)` erzeugt eine uniforme Matrix (alle Fähigkeiten gesperrt), `new DiviLocked($status)` ohne Fähigkeiten +liefert wie bisher `$status->refusal()` (Codes und Texte gleich), `assertWritable()` wirft bei uniform gesperrtem +Status mit allen Schreib-Fähigkeiten und demselben Code. Geprüft: alle grün. + +- [ ] **Step 10: Tests laufen lassen – sie bestehen** + +Run: `php vendor/bin/phpunit --filter '/ReleaseGuardsTest|DiviLockedTest|ReleaseGateTest|ReleaseStatusTest|SaveScopeTest|PresetAreaTest|DesignChangeTest/'` +Expected: PASS (ReleaseGuardsTest 35 = 17 Methoden × 2 + Probe, DiviLockedTest 2, ReleaseGateTest 11, +ReleaseStatusTest 14, SaveScopeTest 2, PresetAreaTest 4, DesignChangeTest 17). + +- [ ] **Step 11: Gesamtlauf und Lint** + +Run: `composer test && composer lint` +Expected: alle Tests grün (Stand nach Task 6 + 34), phpcs ohne Meldung. + +- [ ] **Step 12: Commit** + +```bash +git add src/Integrations/Divi/Release/ReleaseGate.php src/Integrations/Divi/Release/DiviLocked.php \ + src/Integrations/Divi/Release/ReleaseStatus.php src/Integrations/Divi/Pages/SaveScope.php \ + src/Integrations/Divi/Pages/DiviSaver.php src/Integrations/Divi/Pages/DiviPages.php \ + src/Integrations/Divi/Library/DiviLibraryStore.php src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php \ + src/Integrations/Divi/Design/DesignSystemWriter.php src/Integrations/Divi/Design/DesignArea.php \ + src/Integrations/Divi/Design/PresetArea.php src/Integrations/Divi/Design/DesignChange.php tests/Unit/TestCase.php \ + tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php tests/Unit/Integrations/Divi/Release/DiviLockedTest.php \ + tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php \ + tests/Unit/Integrations/Divi/Pages/SaveScopeTest.php tests/Unit/Integrations/Divi/Design/PresetAreaTest.php \ + tests/Unit/Integrations/Divi/Design/DesignChangeTest.php +git commit -m "feat: Divi-Adapter sperren je Fähigkeit – ReleaseGate::feature/assertFeature(s), DiviLocked und Ablehnung nennen die betroffenen Fähigkeiten (D6), DiviSaver prüft Seiten schreiben und die Fähigkeit des Speicherbereichs (D5), DesignArea::feature; Test-Standard alle Fähigkeiten freigegeben" -m "Co-Authored-By: Claude " +``` + +--- + +### Offene Punkte (Tasks 6–7) + +1. **Verhaltensänderung schon in Task 6 (bitte in „Global Constraints“ als Ausnahme nennen):** Die E1-Automatik wirkt + ab Task 6. Eine nicht freigegebene Divi-Version, bei der alle Referenzen stimmen, ist sofort „released (auto)“ – + auch für den alten `DiviToolGate`, weil `writable()` = alle Schreib-Fähigkeiten nutzbar. Umgekehrt sperren + `DiviToolGate` und `DiviContentComposer::refusal()` bis Task 9 **alle** Schreib-Tools, sobald **eine** + Schreib-Fähigkeit gesperrt ist (konservativ; Text weiter `refusal()` nach dem schlechtesten Zustand). Die Adapter + prüfen bis Task 7 weiter `assertWritable()` (alle Schreib-Fähigkeiten). Wer das nicht will, setzt den Filter in + Task 6 standardmäßig auf `false` und schaltet ihn in Task 9 auf `true` – dann müsste + `testAnUnknownVersionWithoutProblemsIsReleasedAutomatically` u. a. den Filter selbst setzen. +2. **Mirror ohne Referenz = `mirror`** (siehe Signatur-Änderung 1): bitte im Gerüst bei `ManifestCheck::problems()` + und in der Entscheidungsnotiz (Task 11) so festhalten. Folge für E1: „fehlt die Referenz → untested“ gilt nur für + Call-Signaturen. +3. **Cache nur für Mirrors** (Begründung im Task-Text): D7-Wortlaut „Matrix für Code-, Signatur- und Mirror-Checks + zusammen“ bitte anpassen auf „Mirror-Probleme mit Art und Fähigkeiten; Code- und Signatur-Checks live“. Die + Fähigkeiten im Cache stammen aus dem Manifest zum Zeitpunkt der Prüfung; ändert sich die Zuordnung ohne neue + Plugin-Version (nur in der Entwicklung), gilt bis zum Ablauf (1 Tag) oder `wp transient delete lit_mcp_divi_state` + die alte Zuordnung. +4. **Unbekannte Version `''`** (Divi nicht aktiv/nicht erkannt): nie Automatik, `ReleaseStore` wird nicht gefragt – + alles `untested` bzw. `incompatible` (fail closed; heute ebenfalls `Untested`). +5. **Filter nur bei literalem `true`:** `apply_filters(...) === true`; `1`, `'1'` o. ä. schalten die Automatik ab + (fail closed). Im Guide (Task 11) so beschreiben (`add_filter('lit_mcp_divi_auto_release', '__return_false')`). +6. **Problem ohne Fähigkeit sperrt alle** (fail closed). Task 4s Test „jede Anforderung hat eine Fähigkeit“ verhindert + den Fall im echten Manifest. +7. **Signatur jedes Calls, auch `args === null`:** `codeProblems()` vergleicht für jeden existierenden Call die + Signatur. Schreibt Task 5 für Calls mit `args === null` keine Referenz, sind deren Fähigkeiten bei unbekannter + Version `untested` – dann entweder Task 5 schreibt alle oder hier `if ($call->args === null) return null;` vor dem + Referenz-Vergleich (bewusst nicht gemacht: E1 verlangt „jeder Call … Signatur stimmt“). +8. **`toArray()` des Status unverändert** (kein `features`-Schlüssel), `source` ist `null` bei gemischter Freigabe, + `auto` bei voller Automatik. Die Matrix für `lit-site-status`/Admin-Tab/CLI kommt aus `$status->matrix->toArray()` + in Task 10; prüfen, ob das Ausgabeschema von `lit-site-status` den Wert `auto` für `source` zulässt (heute + vermutlich `string|null`, dann ok). +9. **Admin-Prüfung (`CompatibilityCheck`) kennt keine Signaturen** – bewusst: eine Admin-Freigabe deckt geänderte + Signaturen (E1). Wenn die Prüfung sie trotzdem anzeigen soll: Task 10. +10. **`DesignArea::feature()` für 4c/4d:** D1 kennt nur `PresetWrite` als Design-Schreib-Fähigkeit. Farben, + Variablen und Theme-Optionen brauchen eine eigene Fähigkeit (z. B. `DesignWrite`), sonst müssten sie + `PresetWrite` melden und würden bei einem Bruch der Preset-Calls mit gesperrt. +11. **Texte D6:** „Andere Divi-Werkzeuge funktionieren weiter, sofern ihre Fähigkeiten freigegeben sind“ statt einer + unbedingten Aussage – bei Resolver-Fehler sind alle Fähigkeiten gesperrt, dann wäre „funktionieren weiter“ falsch. + `details.problems` bleibt bei `divi_incompatible` erhalten (Rückwärtskompatibilität für Clients, die es lesen). +12. **`assertWritable()` hat in `src` keinen Aufrufer mehr** (nur Tests: `WriteProbeTest`, `ReleaseGateTest`). Laut + Gerüst bleibt es; Task 9 kann es mit `DiviToolGate` entfernen oder für die Probe nutzen. +13. **`ReleaseStatusTest` prüft deutsche Labels wörtlich** („Seiten schreiben“, „Bibliothek schreiben“, „Presets + schreiben“, „Theme Builder schreiben“). Weichen die Labels aus Task 4 ab, nur die Literale anpassen. +14. **`ReleaseGuardsTest` „passes the gate“** fängt jede Ausnahme außer `DiviLocked` (hinter dem Tor scheitert jede + Methode am fehlenden Divi). Aussagekraft per Mutation geprüft: `attach()` auf `PageWrite` umgestellt → beide + Tests für „tb attach“ schlagen fehl. + +**Verifiziert:** Test- und Produktionscode beider Tasks liefen in einer Kopie des Repos (Stand e7793fd) mit +Minimal-Stubs für Task 1 (`ToolRefusal`), Task 4 (`DiviFeature`, `Requirement`, `ConstantRequirement`, +`Call`/`Mirror`/`Manifest` mit Fähigkeiten, `featuresOf()`) und Task 5 (`SignatureFingerprint::of()`; in der Kopie +noch mit eigener Konstante `'sig:' . $call->symbol` statt `DiviReference::signatureKey()` – nach Abgleich mit +`part-4-5.md` im Plan auf `signatureKey()` umgestellt, für die Test-Symbole ohne führenden Backslash gleichwertig, +nicht erneut gelaufen): nach Task 6 +2656 Tests grün (Basis 2624 + 32), nach Task 7 2690 grün (+34), `composer lint` ohne Meldung; RED-Läufe mit den oben +genannten Meldungen beobachtet (Task 6 auf Basis-`src`, Task 7 auf Task-6-`src`); Mutationstest für +`ReleaseGuardsTest` (siehe Punkt 14). Nicht verifiziert: Zusammenspiel mit den echten Fassungen von Tasks 1–5 +(nur auf dem Papier mit `part-4-5.md` abgeglichen), DDEV-Smoke (Task 11). + +--- + +# Teil 8–9: Fähigkeiten je Tool (`RequiresDiviFeatures`) und `DiviFeatureGate` + +> Dieser Teil ersetzt im Plan „Divi-Freigabe je Fähigkeit“ die Tasks 8 und 9. Er setzt Task 1 (`AbilityGuard`, `ToolRefusal`, `AbilityRegistrar` mit `list`, `DiviToolGate implements AbilityGuard`), Task 4 (`DiviFeature`), Task 6 (`CompatibilityMatrix`, `FeatureStatus`, `ReleaseStatus::$matrix`) und Task 7 (`ReleaseGate::feature()`, `DiviLocked` mit Fähigkeiten) voraus. Aufgebaut und getestet wurde er in einer Kopie des Repos, in der diese Teile nach den Gerüst-Signaturen minimal vorweggenommen waren (Kopie gelöscht). Alle Codeblöcke sind der dort grüne Stand (2690 Unit-Tests, `composer lint` sauber). + +## Abweichungen von den verbindlichen Signaturen (zuerst lesen) + +Das Gerüst bleibt unverändert, außer hier: + +1. **NEU – `LIT\MCP\Abilities\DeclaresRequirements`** (`src/Abilities/DeclaresRequirements.php`, Kern): + `public function requirements(array $input): array` liefert `list`, die IDs von Fähigkeiten einer anderen Integration. So meldet `lit/content-create` (WordPress) seine Divi-Fähigkeiten, **ohne** eine Divi-Klasse zu importieren (Task 2 verbietet das; `CoreIndependenceTest`). Die Gate liest die IDs und übersetzt sie über `DiviFeature::tryFrom()`; unbekannte IDs ignoriert sie. +2. **NEU – Port-Methode `ContentComposer::requirements(): array`** (`list`, neutrale IDs). Implementierer: `DiviContentComposer` (liefert die `DiviFeature`-Werte) und die anonyme Testklasse in `ContentCreateDiviTest`. `ContentCreateAbility implements Ability, DeclaresRequirements` gibt sie nur zurück, wenn die Eingabe den Schlüssel `divi` enthält, sonst `[]`. Der bestehende Verdrahtungsweg (`DiviIntegration::contentComposer()` → `WordPressIntegration` → `ContentCreateAbility`) bleibt; es gibt **keine** neue Closure und keine Änderung in `Plugin.php`/`DiviIntegration` dafür. +3. **NEU – `LIT\MCP\Integrations\Divi\Release\FeatureSets`** mit `merge(list ...$lists): list`, `ofEntry(mixed $postId, bool $write): list` (was ein Ziel-Eintrag dazu fordert) und `declaredBy(Ability $ability, array $input): list` (liest `RequiresDiviFeatures` **und** `DeclaresRequirements`; die Gate und die Tests benutzen genau diese eine Stelle). +4. **`RequiresDiviFeatures` (Signatur wie im Gerüst), geschärfte Bedeutung:** `diviFeatures($input)` liefert die **Obermenge**, die der Aufruf für diese Eingabe erreichen kann – jede Fähigkeit, die ein Adapter später mit `assertFeature()` prüft, muss darin stehen (Review Focus 5). +5. **`DiviWriteAbility`** (abstrakte Basis von 7 Tools) implementiert `RequiresDiviFeatures` selbst: neu `abstract protected function baseFeatures(): array` (je Tool) und ein nicht finales `diviFeatures()`, das `baseFeatures()` mit `FeatureSets::ofEntry($input['post_id'] ?? null, true)` verbindet. `DiviNodeCopyAbility` überschreibt es (zusätzlich `source_post_id`). +6. **`DiviFeatureGate`:** Konstruktor wie im Gerüst (`?ChangeLog $log = null`), zusätzlich `public static function lockFor(array $features): ?DiviLocked` (`DiviContentComposer::refusal()` stellt dieselbe Frage). Die Gate erkennt Tools **über die Schnittstellen**, nicht über Gruppen: `DiviToolGate::GROUPS`, `covers()` und der `Closure $groups`-Parameter entfallen mit der Klasse. `annotate()` gibt das Ergebnis unverändert zurück (E2). +7. **Test-Helfer `tests/Support/DiviFeatureFixtures.php`** (neu) baut ein `ReleaseStatus` mit einzelnen gesperrten Fähigkeiten. Er benutzt den **benannten Parameter `matrix:`** von `ReleaseStatus::__construct` (Task 6, siehe Offene Punkte). Hat Task 7 für `ReleaseGuardsTest` schon einen gleichwertigen Helfer angelegt, diesen verwenden und `DiviFeatureFixtures` darauf abbilden. + +## Entscheidungen (Task 8 und 9) + +- **E8-1 Obermenge statt Vorabauflösung.** Der Aufruf wird abgelehnt, sobald eine Fähigkeit fehlt, die er erreichen *kann*. Zu viele Fähigkeiten sperren ein Tool öfter (sicher), zu wenige ließen den Adapter mitten im Lauf sperren (Teilschreiben, `partial_write`). Eingabeabhängig wird nur dort verfeinert, wo es billig und eindeutig ist: **(a)** der Ziel-Eintrag `post_id` (`get_post_type()` + `edit_post`, keine Divi-Aufrufe, Objektcache), **(b)** ein Schalter, der den Schreibpfad gar nicht erreicht (`dry_run` bei `lit/divi-replace-text`, `from_node` bei `lit/divi-presets-set`, `id` bei `lit/library-list`, `nodes` bei `lit/tb-create-template`, `divi` bei `lit/content-create`). +- **E8-2 Ziel-Eintrag (`FeatureSets::ofEntry`).** Seite/Beitrag/sonstiger Typ: nichts zusätzlich. Bibliothekseintrag (`et_pb_layout`): `roles`; geschrieben zusätzlich `library-read` (`LibraryAccess::canEdit` fragt `isGlobal`) und `library-write` (D5). Theme-Builder-Layout (`et_header_layout`/`et_body_layout`/`et_footer_layout`): `roles`; geschrieben zusätzlich `tb-read` (`ThemeBuilderEdits` liest die Vorlagen) und `tb-write` (D5). Eine unbekannte oder nicht bearbeitbare ID fügt **nichts** hinzu: das Tool antwortet selbst „nicht gefunden“/„verboten“, und eine Sperre verrät nie, von welcher Art eine ID ist (Ruling P12). +- **E8-3 `roles` nur, wo die Rolle Zugriff oder Ergebnis entscheidet** (Zugriff auf Bibliotheks-/TB-Einträge, `lit/library-*`, `lit/tb-*`, Feldliste von `lit/divi-dynamic-sources`). Nicht gezählt wird die reine Anzeige: `libraryIdsVisible()`/`revealsIds()` in Outline, Ergebnis-Outline und `SimilarSections`. `DiviRoles::allows()` fällt ohne Divi-Funktion weich auf „nicht erlaubt“ – das verbirgt IDs, schreibt nie falsch. +- **E8-4 `dynamic-sources` statisch für jedes Tool mit gebundener Composition.** `DiviComposition::references()` baut bei jedem Aufbau eines `VariableRef` die Karte `DynamicContentMap::forPost($postId)`; bricht der Aufruf, fällt die Karte still auf die Standardkarte – ein Schreiben würde gegen die falsche Karte geprüft. Ob die Eingabe dynamische Werte enthält, wird nicht geraten (zu fragil). `lit/divi-presets-set` bindet keinen Eintrag (`postId` null) und braucht sie nicht. +- **E8-5 Site-weite Umschreiber** (`lit/divi-presets-assign`, `lit/divi-design-detach`) speichern in jeder Art Beitrag (Seiten, Bibliothekseinträge, TB-Layouts). Ein Sperren erst beim ersten Bibliothekseintrag hinterließe schon gespeicherte Seiten (`partial_write`); darum eine feste Obermenge. `post_ids` verfeinert sie nicht. +- **E8-6 `PageRead`** heißt: ein Eintrag wird über `DiviPageLoader` geladen oder Link-Ziele werden aufgelöst. Alle Tools, die einen Eintrag laden, nennen sie; Tools ohne Eintrag (Katalog, Designsystem, Presets, Bibliothek-/TB-Listen, Snapshots) nicht. +- **E8-7 `lit/divi-design-snapshots` meldet `[]`:** `SnapshotStore` ist eine WordPress-Option, kein Divi-Aufruf – das Tool bleibt auch bei gesperrtem Divi nutzbar (Rückfall: ein Admin findet damit den letzten Stand). +- **E9-1 Lese-Tools werden abgelehnt (E2), aber nicht protokolliert.** Das Änderungsprotokoll bekommt nur Ablehnungen schreibender Tools („Abgelehnt: …“, `result: refused`, wie bisher); eine abgelehnte Leseanfrage ändert nichts, und ein Divi-Update würde das Protokoll sonst mit jedem Outline-Aufruf füllen. Der Admin sieht die Sperre in Task 10 (Tab, Hinweis, `lit-site-status`). +- **E9-2 `outputSchema()` ergänzt `warnings` weiter** – für die Tools mit `RequiresDiviFeatures` (genau die 28 Divi-Tools wie heute die drei Gruppen), nicht für `lit/content-create`. Begründung: die Gate fügt künftig keine Warnung mehr hinzu, aber das veröffentlichte Ausgabeschema von 28 Tools ändert sich nicht (Clients cachen Tool-Listen; ein optionales Array-Feld ist harmlos), und die Gate soll nicht nebenbei ein Schema ändern. Wer es später entfernen will, tut das in einem eigenen Schritt mit Schema-Test. +- **E9-3 `DiviContentComposer::refusal()`** prüft die Fähigkeiten des Composers (`FEATURES`: `page-read`, `page-write`, `catalog`, `design-read`, `dynamic-sources`), nicht mehr „global schreibbar“. Die Gate lehnt `lit/content-create` mit `divi` schon vor dem Lauf ab; `refusal()` bleibt als zweite Absicherung im Tool (direkte Aufrufe, Reihenfolge der Prüfungen). + +### Task 8: Jedes Divi-Tool und `lit/content-create` erklärt seine Fähigkeiten + +**Files:** +- Neu: `src/Integrations/Divi/Release/RequiresDiviFeatures.php`, `src/Integrations/Divi/Release/FeatureSets.php`, `src/Abilities/DeclaresRequirements.php` +- Neu (Tests): `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php`, `tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php` +- Ändern (Port): `src/Integrations/WordPress/Content/ContentComposer.php`, `src/Integrations/WordPress/Content/ContentCreateAbility.php`, `src/Integrations/Divi/Pages/DiviContentComposer.php` +- Ändern (Basis): `src/Integrations/Divi/Pages/DiviWriteAbility.php` +- Ändern (28 Abilities, je ein Methoden-Block): `src/Integrations/Divi/Pages/` (`DiviOutlineAbility`, `DiviNodeGetAbility`, `DiviInsertAbility`, `DiviUpdateAbility`, `DiviMoveAbility`, `DiviDeleteAbility`, `DiviDuplicateNodeAbility`, `DiviReplaceTextAbility`, `DiviNodeCopyAbility`, `DiviDynamicSourcesAbility`), `src/Integrations/Divi/Schema/DiviCatalogAbility.php`, `src/Integrations/Divi/Design/` (`DiviDesignSystemAbility`, `DiviPresetGetAbility`, `DiviPresetsSetAbility`, `DiviPresetsDeleteAbility`, `DiviDesignSnapshotsAbility`, `DiviDesignRestoreAbility`, `DiviPresetsAssignAbility`, `DiviDesignDetachAbility`), `src/Integrations/Divi/Library/` (`LibraryListAbility`, `LibraryFindSimilarAbility`, `LibraryInsertAbility`, `LibraryMakeGlobalAbility`, `LibrarySaveAbility`), `src/Integrations/Divi/ThemeBuilder/` (`TbListAbility`, `TbCreateTemplateAbility`, `TbAssignAbility`, `TbActivateAbility`) +- Ändern (Tests): `tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php`, `tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php` + +**Interfaces:** + +```php +// src/Integrations/Divi/Release/RequiresDiviFeatures.php (Signatur des Gerüsts) +interface RequiresDiviFeatures { /** @return list */ public function diviFeatures(array $input): array; } + +// src/Abilities/DeclaresRequirements.php (neu, Kern, ohne Divi-Import) +interface DeclaresRequirements { /** @return list */ public function requirements(array $input): array; } + +// src/Integrations/WordPress/Content/ContentComposer.php (neue Port-Methode) +/** @return list IDs der Fähigkeiten, die das Anlegen eines Eintrags braucht */ +public function requirements(): array; + +// src/Integrations/Divi/Release/FeatureSets.php +final class FeatureSets { + public static function merge(array ...$lists): array; // list, jede einmal + public static function ofEntry(mixed $postId, bool $write): array; // list + public static function declaredBy(Ability $ability, array $input): array; // list +} + +// src/Integrations/Divi/Pages/DiviWriteAbility.php +abstract protected function baseFeatures(): array; // list +public function diviFeatures(array $input): array; // baseFeatures + ofEntry(post_id, true) +``` + +#### Die Tabelle: Tool → Fähigkeiten → Begründung + +Kürzel: `page-read`, `page-write`, `catalog`, `dynamic-sources`, `design-read`, `preset-write`, `library-read`, `library-write`, `roles`, `tb-read`, `tb-write` (`DiviFeature`-Werte). „Ziel“ = Zusatz aus `FeatureSets::ofEntry(post_id, …)` (Bibliothekseintrag schreibend: `roles`, `library-read`, `library-write`; TB-Layout schreibend: `roles`, `tb-read`, `tb-write`; lesend jeweils nur `roles`; Seite/Beitrag/unbekannt: nichts). + +| Tool | Immer | Je Eingabe zusätzlich | Begründung aus dem Code | +|---|---|---|---| +| `lit/divi-outline` | page-read, design-read | Ziel lesend (`post_id`) | `DiviPageLoader::load` (PageRead); Preset-Namen aus `DesignSystemReader::read` (DesignRead). Globale Module nennt die Outline nur als ID und löst sie nie auf: weder `library-read` noch `catalog`. `libraryIdsVisible()` ist reine Anzeige (E8-3). | +| `lit/divi-node-get` | page-read, design-read | Ziel lesend | wie Outline; `ShortformMap`/`AttrMap` sind reines PHP. | +| `lit/divi-catalog` | catalog | – | nur `DiviModules::catalog()`. | +| `lit/divi-design-system` | design-read | – | nur `DesignSystemReader::read`. | +| `lit/divi-insert` | page-read, page-write, catalog, design-read, dynamic-sources | Ziel schreibend | Laden + `DiviSaver::save` (PageWrite; D5 zusätzlich der Bereich = Ziel). `loadWritable` → `diviEnabled` und Composer/`StructureRules` (Catalog); die Qualitätswarnungen des Ergebnisses lesen das Designsystem (DesignRead); der Composer baut `VariableRef` mit `DynamicContentMap::forPost` (DynamicSources, E8-4). | +| `lit/divi-update` | wie insert | Ziel schreibend | `Composer::update` prüft jeden geänderten Wert. | +| `lit/divi-move` | page-read, page-write, catalog, design-read | Ziel schreibend | wie insert, aber `TreeEditor` ohne Client-Werte: kein Cleaner, kein `dynamic-sources`. | +| `lit/divi-delete` | wie move | Ziel schreibend | `TreeEditor::remove`. | +| `lit/divi-duplicate-node` | wie move | Ziel schreibend | `TreeEditor::duplicate` kopiert gespeicherte Bytes. | +| `lit/divi-replace-text` | page-read, catalog, design-read, dynamic-sources | `dry_run: false`: zusätzlich page-write und je `targets[].post_id` Ziel schreibend | Der Probelauf (Standard) liest nur; `textReplacementFor` ist der Cleaner der Composition (DynamicSources). Der echte Lauf speichert jede Seite einzeln – auch Bibliothekseinträge/TB-Layouts als Ziel. | +| `lit/divi-node-copy` | wie insert | Ziel schreibend (`post_id`), Quelle lesend (`source_post_id`) | `StoredNodeGuard` reinigt den kopierten Knoten (Cleaner, DynamicSources); die Quelle wird nur gelesen (Bibliothek/TB: `roles`). | +| `lit/divi-dynamic-sources` | dynamic-sources, roles | – | `DynamicContentMap::options`; die Feldliste fragt `read_dynamic_content_custom_fields`, ohne Rolle fehlen Felder mit irreführender Begründung – die Rolle gehört zum Ergebnis. | +| `lit/divi-preset-get` | design-read, catalog | – | `DesignSystemWriter::presets` (DesignRead) und `groupsOf` (Catalog, siehe Task-4-Zuordnung) lesen; Verwendungen kommen aus der Datenbank. | +| `lit/divi-presets-set` | design-read, preset-write, catalog | `items[].from_node`: zusätzlich page-read, Ziel lesend | `PresetArea::write` → `savePresets` (PresetWrite) nach dem Lesen (DesignRead); `PresetComposer`/Cleaner brauchen den Katalog. Ungebundene Composition (postId null): keine Registry-Abfrage, kein `dynamic-sources`. `from_node` lädt einen Eintrag. | +| `lit/divi-presets-delete` | design-read, preset-write | – | kein Composer. | +| `lit/divi-design-snapshots` | – | – | `SnapshotStore` ist eine WordPress-Option; kein Divi-Aufruf (E8-7). | +| `lit/divi-design-restore` | design-read, preset-write | – | schreibt den Snapshot über `PresetArea` zurück. | +| `lit/divi-presets-assign` | page-read, page-write, catalog, design-read, preset-write, library-read, library-write, tb-write, roles | – (E8-5) | `PageRewriter` speichert jede Art Beitrag: Seiten (PageWrite), Bibliothekseinträge (`isGlobal` → LibraryRead, Speichern → LibraryWrite), TB-Layouts (TbWrite); die Rolle entscheidet über den Zugriff auf diese Einträge; `PresetAssignment` ist ein Cleaner (Catalog); Warnungen lesen das Designsystem. | +| `lit/divi-design-detach` | wie assign + preset-write | – (E8-5) | zusätzlich der Preset-Schritt (`PresetArea::write`), der verschachtelte Verweise entfernt. | +| `lit/library-list` | library-read, roles | `id`: zusätzlich page-read, design-read | `describe()` fragt `isGlobal` (LibraryRead); `canList` ist eine Rolle; ein einzelner Eintrag wird geladen und als Outline gezeigt. | +| `lit/library-find-similar` | page-read, catalog, roles | – | scannt Seiten (PageRead) der Typen mit Builder (`diviEnabled` → Catalog) nach dem Bibliotheksrecht (Roles); `Fingerprint` ist reines PHP, keine Preset-Namen: kein `design-read`. | +| `lit/library-insert` | page-read, page-write, catalog, design-read, dynamic-sources, library-read, roles | Ziel schreibend (`post_id`) | wie insert; die Quelle: `isGlobal` (LibraryRead) und Recht (Roles). Schreibt nicht in die Bibliothek: kein `library-write`. | +| `lit/library-make-global` | page-read, page-write, catalog, design-read, dynamic-sources, library-read, library-write, roles | – | Quellen und Ziele nur `page`/`post` (kein Ziel-Zusatz). Modus 1 legt den Eintrag an (`DiviLibraryStore::create`, Bibliotheksbereich: LibraryWrite); Modus 2 (`library_id`) erreicht LibraryWrite nie, bleibt aber in einer Menge (ein Tool, eine Menge). `isGlobal` (LibraryRead). | +| `lit/library-save` | page-read, page-write, catalog, design-read, dynamic-sources, library-write, roles | – | liest einen Knoten beliebiger Herkunft (Bibliothek/TB-Quelle fordert nur `roles`, schon enthalten), reinigt ihn (StoredNodeGuard) und speichert einen neuen Eintrag mit Bibliotheksbereich (PageWrite + LibraryWrite, D5); kein `isGlobal`-Aufruf: kein `library-read`. | +| `lit/tb-list` | tb-read, roles | – | Vorlagen/Layouts/Bedingungen aus den TB-Funktionen; Recht ist die Rolle `theme_builder`. | +| `lit/tb-create-template` | tb-read, tb-write, roles | `header`/`body`/`footer` mit nichtleeren `nodes`: zusätzlich page-read, page-write, catalog, design-read, dynamic-sources | legt Layouts und Vorlage an (`TemplateBuild`); Layouts mit Knoten werden wie Seiten komponiert und mit `saveNew` gespeichert (PageWrite + TbWrite, D5). | +| `lit/tb-assign` | tb-read, tb-write, roles | – | Bedingungen und Anhängen (`setConditions`, `attach`, `clearCaches`). | +| `lit/tb-activate` | tb-read, tb-write, roles | – | schaltet `_et_enabled` einer gelisteten Vorlage. | +| `lit/content-create` | – | `divi` im Input: page-read, page-write, catalog, design-read, dynamic-sources (vom `ContentComposer`) | ohne `divi` bleibt der Aufruf in WordPress. Mit `divi`: `diviEnabled`/Composition (Catalog, DesignRead, DynamicSources, gebunden an Eintrag 0 bzw. den neuen), Link-Ziele (PageRead), `saveNew` (PageWrite). Ein normaler Entwurf: kein Bibliotheks-/TB-Bereich. | + +**Abgleich mit den `assertFeature`-Stellen (Task 7, D5):** `DiviSaver` (PageWrite + Bereich) wird von allen Tools mit `page-write` erreicht; der Bibliotheksbereich nur von den Tools mit Ziel „Bibliothekseintrag“, `library-save`, `library-make-global`, `presets-assign`/`design-detach`; der TB-Bereich von Tools mit Ziel „TB-Layout“, `tb-create-template`, `presets-assign`/`design-detach`. `DiviLibraryStore::create` → nur `library-save`, `library-make-global` (beide `library-write`). `DiviThemeBuilder`-Schreibmethoden → nur `tb-assign`, `tb-activate`, `tb-create-template` (alle `tb-write`). `DesignSystemWriter::savePresets` und `DesignChange` → nur `presets-set`, `presets-delete`, `design-restore`, `design-detach` (alle `preset-write`). `DiviPages::setFlags` → über `DiviSaver` (Scope Page/TB) und `DiviContentComposer` (page-write). + +- [ ] **Step 1: Ausgangslage prüfen** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git status --short +grep -rn "implements ContentComposer" src tests +grep -rn "enum DiviFeature" src +grep -rn "PostGuard::id" src/Integrations/WordPress/PostGuard.php | head -2 +``` + +Erwartet: sauberer Baum (Tasks 1–7 committed); `ContentComposer` wird von `DiviContentComposer` und der anonymen Klasse in `tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php` implementiert (Mockery-Mocks sind nicht betroffen); `enum DiviFeature` aus Task 4 ist vorhanden; `PostGuard::id()` ist public static. + +- [ ] **Step 2: Tests schreiben** + +Datei `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php` (vollständig). Sie ist die Tabelle als Konstante `EXPECTED` (Fähigkeiten, die ein Tool bei einer Eingabe ohne Ziel braucht) plus `inputCases()` (jede eingabeabhängige Zeile) und prüft: (a) jede Ability der Gruppen `divi`, `divi-library`, `divi-theme-builder` implementiert `RequiresDiviFeatures`, `lit/content-create` nur `DeclaresRequirements` (kein Divi-Import im Kern); (b) die Tabelle exakt; (c) keine Zeile ohne Ability und keine Ability ohne Zeile; dazu Schreib-/Lese-Plausibilität. Die Abilities kommen aus den echten Integrationen (`DiviIntegration::abilities()`, `->library()`, `->themeBuilder()`, `WordPressIntegration`), nicht aus einer Liste im Test: ein neues Tool (4c bis 4e) ohne Zeile lässt Test (c) scheitern. + +```php + post type for get_post_type(); every other ID names no entry. */ + private const TYPES = [ + self::PAGE => 'page', + self::LIBRARY_ENTRY => 'et_pb_layout', + self::LAYOUT => 'et_body_layout', + ]; + + /** What a tool needs whatever its input names: tool => capabilities (DiviFeature values). */ + private const EXPECTED = [ + // Pages (divi) + 'lit/divi-outline' => ['page-read', 'design-read'], + 'lit/divi-node-get' => ['page-read', 'design-read'], + 'lit/divi-catalog' => ['catalog'], + 'lit/divi-design-system' => ['design-read'], + 'lit/divi-insert' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'], + 'lit/divi-update' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'], + 'lit/divi-move' => ['page-read', 'page-write', 'catalog', 'design-read'], + 'lit/divi-delete' => ['page-read', 'page-write', 'catalog', 'design-read'], + 'lit/divi-duplicate-node' => ['page-read', 'page-write', 'catalog', 'design-read'], + 'lit/divi-replace-text' => ['page-read', 'catalog', 'design-read', 'dynamic-sources'], + 'lit/divi-node-copy' => ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources'], + 'lit/divi-dynamic-sources' => ['dynamic-sources', 'roles'], + // Design system (divi) + 'lit/divi-preset-get' => ['design-read', 'catalog'], + 'lit/divi-presets-set' => ['design-read', 'preset-write', 'catalog'], + 'lit/divi-presets-delete' => ['design-read', 'preset-write'], + 'lit/divi-design-snapshots' => [], + 'lit/divi-design-restore' => ['design-read', 'preset-write'], + 'lit/divi-presets-assign' => [ + 'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', 'library-write', + 'tb-write', 'roles', + ], + 'lit/divi-design-detach' => [ + 'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', 'library-write', + 'tb-write', 'roles', + ], + // Library (divi-library) + 'lit/library-list' => ['library-read', 'roles'], + 'lit/library-find-similar' => ['page-read', 'catalog', 'roles'], + 'lit/library-insert' => [ + 'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read', 'roles', + ], + 'lit/library-make-global' => [ + 'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read', 'library-write', + 'roles', + ], + 'lit/library-save' => [ + 'page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-write', 'roles', + ], + // Theme Builder (divi-theme-builder) + 'lit/tb-list' => ['tb-read', 'roles'], + 'lit/tb-create-template' => ['tb-read', 'tb-write', 'roles'], + 'lit/tb-assign' => ['tb-read', 'tb-write', 'roles'], + 'lit/tb-activate' => ['tb-read', 'tb-write', 'roles'], + // WordPress: without the divi key it stays in the core + 'lit/content-create' => [], + ]; + + /** The compose set of a new or changed page. */ + private const COMPOSE = ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources']; + + /** + * Input-dependent additions: label => [tool, input, capabilities for that input]. + * + * @return array, 2: list}> + */ + public static function inputCases(): array + { + $page = self::PAGE; + $library = self::LIBRARY_ENTRY; + $layout = self::LAYOUT; + $libraryWrite = ['roles', 'library-read', 'library-write']; + $layoutWrite = ['roles', 'tb-read', 'tb-write']; + + return [ + 'outline of a page' => ['lit/divi-outline', ['post_id' => $page], ['page-read', 'design-read']], + 'outline of a library entry' => [ + 'lit/divi-outline', ['post_id' => $library], ['page-read', 'design-read', 'roles'], + ], + 'outline of a layout' => [ + 'lit/divi-outline', ['post_id' => $layout], ['page-read', 'design-read', 'roles'], + ], + 'node-get of a library entry' => [ + 'lit/divi-node-get', ['post_id' => $library, 'id' => 's0'], ['page-read', 'design-read', 'roles'], + ], + 'outline of an unknown entry' => [ + 'lit/divi-outline', ['post_id' => 999], ['page-read', 'design-read'], + ], + 'outline without post_id' => ['lit/divi-outline', [], ['page-read', 'design-read']], + 'insert into a page' => ['lit/divi-insert', ['post_id' => $page], self::COMPOSE], + 'insert into a library entry' => [ + 'lit/divi-insert', ['post_id' => $library], array_merge(self::COMPOSE, $libraryWrite), + ], + 'update of a layout' => [ + 'lit/divi-update', ['post_id' => $layout], array_merge(self::COMPOSE, $layoutWrite), + ], + 'move in a layout' => [ + 'lit/divi-move', ['post_id' => $layout], + array_merge(['page-read', 'page-write', 'catalog', 'design-read'], $layoutWrite), + ], + 'delete with a numeric string ID' => [ + 'lit/divi-delete', ['post_id' => (string) $library], + array_merge(['page-read', 'page-write', 'catalog', 'design-read'], $libraryWrite), + ], + 'node-copy of a page into a page' => [ + 'lit/divi-node-copy', ['post_id' => $page, 'source_post_id' => $page], self::COMPOSE, + ], + 'node-copy out of a library entry' => [ + 'lit/divi-node-copy', ['post_id' => $page, 'source_post_id' => $library], + array_merge(self::COMPOSE, ['roles']), + ], + 'node-copy into a library entry' => [ + 'lit/divi-node-copy', ['post_id' => $library, 'source_post_id' => $page], + array_merge(self::COMPOSE, $libraryWrite), + ], + 'replace-text dry run by default' => [ + 'lit/divi-replace-text', ['search' => 'a', 'replace' => 'b'], + ['page-read', 'catalog', 'design-read', 'dynamic-sources'], + ], + 'replace-text dry run explicit' => [ + 'lit/divi-replace-text', ['dry_run' => true, 'targets' => [['post_id' => $library]]], + ['page-read', 'catalog', 'design-read', 'dynamic-sources'], + ], + 'replace-text on pages' => [ + 'lit/divi-replace-text', ['dry_run' => false, 'targets' => [['post_id' => $page, 'tree_rev' => 'x']]], + array_merge(['page-read', 'catalog', 'design-read', 'dynamic-sources'], ['page-write']), + ], + 'replace-text on a library entry' => [ + 'lit/divi-replace-text', + ['dry_run' => false, 'targets' => [['post_id' => $page], ['post_id' => $library], 'bad']], + array_merge(['page-read', 'catalog', 'design-read', 'dynamic-sources', 'page-write'], $libraryWrite), + ], + 'presets-set without from_node' => [ + 'lit/divi-presets-set', ['items' => [['module' => 'button', 'name' => 'x']]], + ['design-read', 'preset-write', 'catalog'], + ], + 'presets-set from a page node' => [ + 'lit/divi-presets-set', ['items' => [['from_node' => ['post_id' => $page, 'id' => 's0']]]], + ['design-read', 'preset-write', 'catalog', 'page-read'], + ], + 'presets-set from a library node' => [ + 'lit/divi-presets-set', ['items' => [1, ['from_node' => ['post_id' => $library, 'id' => 's0']]]], + ['design-read', 'preset-write', 'catalog', 'page-read', 'roles'], + ], + 'library-list' => ['lit/library-list', [], ['library-read', 'roles']], + 'library-list of one entry' => [ + 'lit/library-list', ['id' => $library], ['library-read', 'roles', 'page-read', 'design-read'], + ], + 'library-insert into a layout' => [ + 'lit/library-insert', ['post_id' => $layout, 'library_id' => $library], + array_merge( + ['page-read', 'page-write', 'catalog', 'design-read', 'dynamic-sources', 'library-read'], + $layoutWrite + ), + ], + 'tb-create-template without nodes' => [ + 'lit/tb-create-template', ['title' => 'T', 'header' => ['source' => 'new']], + ['tb-read', 'tb-write', 'roles'], + ], + 'tb-create-template with nodes' => [ + 'lit/tb-create-template', ['title' => 'T', 'body' => ['source' => 'new', 'nodes' => [[]]]], + array_merge(['tb-read', 'tb-write', 'roles'], self::COMPOSE), + ], + 'tb-create-template with empty nodes' => [ + 'lit/tb-create-template', ['title' => 'T', 'footer' => ['source' => 'new', 'nodes' => []]], + ['tb-read', 'tb-write', 'roles'], + ], + 'content-create without divi' => ['lit/content-create', ['post_type' => 'page'], []], + 'content-create with divi' => [ + 'lit/content-create', ['post_type' => 'page', 'divi' => [[]]], self::COMPOSE, + ], + ]; + } + + protected function setUp(): void + { + parent::setUp(); + Functions\when('__')->returnArg(); + Functions\when('get_post_type')->alias(static fn (int $id): string|false => self::TYPES[$id] ?? false); + Functions\when('current_user_can')->justReturn(true); + } + + /** @return array name => ability of the three Divi groups and lit/content-create */ + private function abilities(): array + { + $divi = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()); + $composer = Mockery::mock(ContentComposer::class); + $composer->allows('requirements')->andReturn(self::COMPOSE); + $wordPress = new WordPressIntegration( + Mockery::mock(ContentSchema::class), + FakeChangeLog::forbidden(), + $composer + ); + + $found = []; + foreach ([ + ...$divi->abilities(), + ...$divi->library()->abilities(), + ...$divi->themeBuilder()->abilities(), + ] as $ability) { + $found[$ability->name()] = $ability; + } + foreach ($wordPress->abilities() as $ability) { + if ($ability->name() === 'lit/content-create') { + $found[$ability->name()] = $ability; + } + } + + return $found; + } + + /** + * @param list $features + * @return list The values, sorted. + */ + private static function values(array $features): array + { + $values = array_map(static fn (DiviFeature $feature): string => $feature->value, $features); + sort($values); + + return $values; + } + + /** @param list $values */ + private static function sorted(array $values): array + { + sort($values); + + return array_values(array_unique($values)); + } + + public function testEveryToolOfTheDiviGroupsDeclaresItsFeatures(): void + { + $abilities = $this->abilities(); + + foreach ($abilities as $name => $ability) { + if ($name === 'lit/content-create') { + self::assertInstanceOf(DeclaresRequirements::class, $ability, $name); + self::assertNotInstanceOf(RequiresDiviFeatures::class, $ability, 'the core imports no Divi class'); + continue; + } + self::assertInstanceOf(RequiresDiviFeatures::class, $ability, $name); + } + } + + public function testEveryRowOfTheTableHasAnAbilityAndTheOtherWayRound(): void + { + $abilities = array_keys($this->abilities()); + + self::assertSame([], array_values(array_diff(array_keys(self::EXPECTED), $abilities)), 'row without ability'); + self::assertSame([], array_values(array_diff($abilities, array_keys(self::EXPECTED))), 'ability without row'); + } + + public function testEveryToolDeclaresExactlyTheTableForAnInputThatNamesNothing(): void + { + foreach ($this->abilities() as $name => $ability) { + self::assertSame( + self::sorted(self::EXPECTED[$name] ?? ['']), + self::values(FeatureSets::declaredBy($ability, [])), + $name + ); + } + } + + /** + * @param array $input + * @param list $expected + * @dataProvider inputCases + */ + public function testInputDependentFeatures(string $tool, array $input, array $expected): void + { + $ability = $this->abilities()[$tool]; + + self::assertSame(self::sorted($expected), self::values(FeatureSets::declaredBy($ability, $input))); + } + + public function testNoToolDeclaresAFeatureTwiceAndEveryIdIsAFeature(): void + { + foreach ($this->abilities() as $name => $ability) { + $declared = $ability instanceof RequiresDiviFeatures + ? array_map(static fn (DiviFeature $f): string => $f->value, $ability->diviFeatures([])) + : []; + self::assertSame($declared, array_values(array_unique($declared)), $name); + } + foreach (self::EXPECTED as $name => $ids) { + foreach ($ids as $id) { + self::assertNotNull(DiviFeature::tryFrom($id), $name . ': ' . $id); + } + } + } + + public function testEveryWritingToolNeedsTheWriteFeatureOfEverythingItSaves(): void + { + $writes = [ + 'lit/divi-insert' => 'page-write', 'lit/divi-update' => 'page-write', 'lit/divi-move' => 'page-write', + 'lit/divi-delete' => 'page-write', 'lit/divi-duplicate-node' => 'page-write', + 'lit/divi-node-copy' => 'page-write', 'lit/library-insert' => 'page-write', + 'lit/library-make-global' => 'library-write', 'lit/library-save' => 'library-write', + 'lit/divi-presets-set' => 'preset-write', 'lit/divi-presets-delete' => 'preset-write', + 'lit/divi-design-restore' => 'preset-write', 'lit/divi-design-detach' => 'preset-write', + 'lit/divi-presets-assign' => 'page-write', 'lit/tb-assign' => 'tb-write', 'lit/tb-activate' => 'tb-write', + 'lit/tb-create-template' => 'tb-write', + ]; + foreach ($writes as $name => $feature) { + self::assertContains($feature, self::EXPECTED[$name], $name); + } + // Read-only tools never need a write capability. + foreach (['lit/divi-outline', 'lit/divi-node-get', 'lit/divi-catalog', 'lit/divi-design-system', + 'lit/divi-dynamic-sources', 'lit/divi-preset-get', 'lit/library-list', 'lit/library-find-similar', + 'lit/tb-list', 'lit/divi-design-snapshots'] as $name) { + foreach (self::EXPECTED[$name] as $id) { + self::assertFalse(DiviFeature::from($id)->isWrite(), $name . ': ' . $id); + } + } + } +} +``` + +Datei `tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php` (vollständig): + +```php + */ + private array $types = []; + + private bool $mayEdit = true; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('get_post_type')->alias(fn (int $id): string|false => $this->types[$id] ?? false); + Functions\when('current_user_can')->alias(fn (string $capability): bool => $this->mayEdit); + } + + public function testMergeKeepsEveryFeatureOnceInTheOrderOfItsFirstAppearance(): void + { + $merged = FeatureSets::merge( + [DiviFeature::Catalog, DiviFeature::Roles], + [], + [DiviFeature::Roles, DiviFeature::PageRead, DiviFeature::Catalog] + ); + + self::assertSame([DiviFeature::Catalog, DiviFeature::Roles, DiviFeature::PageRead], $merged); + self::assertSame([], FeatureSets::merge()); + } + + public function testAPageOrPostAddsNothing(): void + { + $this->types = [5 => 'page', 6 => 'post', 7 => 'project']; + + foreach ([5, 6, 7, '5'] as $id) { + self::assertSame([], FeatureSets::ofEntry($id, true), (string) $id); + self::assertSame([], FeatureSets::ofEntry($id, false), (string) $id); + } + } + + public function testAnIdThatNamesNoEntryAddsNothing(): void + { + foreach ([null, 0, -3, 'abc', '1.5', [], 404] as $id) { + self::assertSame([], FeatureSets::ofEntry($id, true), var_export($id, true)); + } + } + + public function testALibraryEntryNeedsTheRolesAndWritingAlsoTheLibrary(): void + { + $this->types = [7 => 'et_pb_layout']; + + self::assertSame([DiviFeature::Roles], FeatureSets::ofEntry(7, false)); + self::assertSame( + [DiviFeature::Roles, DiviFeature::LibraryRead, DiviFeature::LibraryWrite], + FeatureSets::ofEntry('7', true) + ); + } + + public function testEveryThemeBuilderLayoutNeedsTheRolesAndWritingAlsoTheThemeBuilder(): void + { + $this->types = [1 => 'et_header_layout', 2 => 'et_body_layout', 3 => 'et_footer_layout']; + + foreach ([1, 2, 3] as $id) { + self::assertSame([DiviFeature::Roles], FeatureSets::ofEntry($id, false), (string) $id); + self::assertSame( + [DiviFeature::Roles, DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite], + FeatureSets::ofEntry($id, true), + (string) $id + ); + } + } + + public function testAnEntryTheCallerMayNotEditAddsNothingSoALockNeverNamesItsKind(): void + { + $this->types = [7 => 'et_pb_layout', 9 => 'et_body_layout']; + $this->mayEdit = false; + + foreach ([7, 9] as $id) { + self::assertSame([], FeatureSets::ofEntry($id, true), (string) $id); + self::assertSame([], FeatureSets::ofEntry($id, false), (string) $id); + } + } + + public function testTemplatesAndThemeBuilderPostsAreNoLayouts(): void + { + $this->types = [1 => 'et_template', 2 => 'et_theme_builder']; + + self::assertSame([], FeatureSets::ofEntry(1, true)); + self::assertSame([], FeatureSets::ofEntry(2, true)); + } + + public function testDeclaredByReadsTheDiviToolsOwnList(): void + { + $tool = Mockery::mock(Ability::class, RequiresDiviFeatures::class); + $tool->expects('diviFeatures')->with(['a' => 1])->andReturn([DiviFeature::Roles, DiviFeature::Roles]); + + self::assertSame([DiviFeature::Roles], FeatureSets::declaredBy($tool, ['a' => 1])); + } + + public function testDeclaredByTranslatesTheIdsOfACoreToolAndIgnoresUnknownOnes(): void + { + $tool = Mockery::mock(Ability::class, DeclaresRequirements::class); + $tool->expects('requirements')->with(['divi' => []])->andReturn(['page-write', 'other-integration', 'catalog']); + + self::assertSame( + [DiviFeature::PageWrite, DiviFeature::Catalog], + FeatureSets::declaredBy($tool, ['divi' => []]) + ); + } + + public function testDeclaredByIsEmptyForAnyOtherTool(): void + { + self::assertSame([], FeatureSets::declaredBy(Mockery::mock(Ability::class), ['x' => 1])); + } +} +``` + +Änderungen an `tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php` (neuer Test) und `tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php` (zwei neue Tests; die anonyme Klasse `ContentComposer` bekommt die neue Port-Methode): + +```diff +diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +index ea7475b..8020c92 100644 +--- a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php ++++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +@@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\Compose\VariableRef; + use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; + use LIT\MCP\Integrations\Divi\Pages\DiviSaver; + use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; ++use LIT\MCP\Integrations\Divi\Release\DiviFeature; + use LIT\MCP\Tests\Support\DiviFixtures; + use LIT\MCP\Tests\Support\DiviWriterFixture; + use LIT\MCP\Tests\Unit\TestCase; +@@ -68,6 +69,17 @@ final class DiviContentComposerTest extends TestCase + ]; + } + ++ public function testRequirementsNameTheCapabilitiesANewEntryNeeds(): void ++ { ++ $requirements = $this->composer()->requirements(); ++ sort($requirements); ++ ++ self::assertSame(['catalog', 'design-read', 'dynamic-sources', 'page-read', 'page-write'], $requirements); ++ foreach ($requirements as $id) { ++ self::assertNotNull(DiviFeature::tryFrom($id), $id); ++ } ++ } ++ + public function testChecksBeforeTheEntryExistsWithDivisMapForNoEntry(): void + { + self::assertNull($this->composer()->check('page', self::sections())); +diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php +index 691e2a6..a75539c 100644 +--- a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php ++++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php +@@ -94,6 +94,23 @@ final class ContentCreateDiviTest extends TestCase + ); + } + ++ public function testRequirementsAreEmptyWithoutDiviWhateverTheComposerNeeds(): void ++ { ++ $composer = $this->composer(); ++ $composer->shouldNotReceive('requirements'); ++ ++ self::assertSame([], $this->ability($composer)->requirements(['post_type' => 'page', 'title' => 'Start'])); ++ self::assertSame([], $this->ability(null)->requirements(self::INPUT), 'no composer: the call is refused anyway'); ++ } ++ ++ public function testRequirementsWithDiviAreThoseOfTheComposer(): void ++ { ++ $composer = $this->composer(); ++ $composer->expects('requirements')->andReturn(['page-write', 'catalog']); ++ ++ self::assertSame(['page-write', 'catalog'], $this->ability($composer)->requirements(self::INPUT)); ++ } ++ + public function testCreatesADiviDraftWithTreeRevOutlineAndWarnings(): void + { + $composer = $this->composer(); +@@ -342,6 +359,11 @@ final class ContentCreateDiviTest extends TestCase + return null; + } + ++ public function requirements(): array ++ { ++ return $this->real->requirements(); ++ } ++ + public function check(string $postType, array $sections): ?WP_Error + { + return $this->real->check($postType, $sections); +``` + +- [ ] **Step 3: RED** + +```bash +vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php +vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php +vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +vendor/bin/phpunit tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php +``` + +Erwartet (in der Kopie so gesehen): `DiviToolFeaturesTest` 31 Fehler, zuerst `PHPUnit\Framework\InvalidArgumentException: Argument #1 of PHPUnit\Framework\Assert::assertInstanceOf() must be a class or interface name` (`RequiresDiviFeatures` fehlt), die übrigen `Class "LIT\MCP\Integrations\Divi\Release\FeatureSets" not found`; `FeatureSetsTest` 9 Fehler `Class "LIT\MCP\Integrations\Divi\Release\FeatureSets" not found`; `DiviContentComposerTest` `Call to undefined method LIT\MCP\Integrations\Divi\Pages\DiviContentComposer::requirements()`; `ContentCreateDiviTest` zwei Fehler `Call to undefined method LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility::requirements()`. + +- [ ] **Step 4: Schnittstellen und `FeatureSets` anlegen** + +`src/Integrations/Divi/Release/RequiresDiviFeatures.php`: + +```php + $input + * @return list + */ + public function diviFeatures(array $input): array; +} +``` + +`src/Abilities/DeclaresRequirements.php`: + +```php + $input + * @return list IDs of the capabilities the call needs for $input ([] = it stays in the core). + */ + public function requirements(array $input): array; +} +``` + +`src/Integrations/Divi/Release/FeatureSets.php` (`declaredBy()` ist die eine Stelle, die Gate und Tests benutzen): + +```php + $input + * @return list + */ + public static function declaredBy(Ability $ability, array $input): array + { + if ($ability instanceof RequiresDiviFeatures) { + return self::merge($ability->diviFeatures($input)); + } + + if (! $ability instanceof DeclaresRequirements) { + return []; + } + + $features = []; + foreach ($ability->requirements($input) as $id) { + $feature = DiviFeature::tryFrom($id); + if ($feature !== null) { + $features[] = $feature; + } + } + + return self::merge($features); + } + + /** + * @param list ...$lists + * @return list Every capability once, in the order of first appearance. + */ + public static function merge(array ...$lists): array + { + $merged = []; + foreach ($lists as $list) { + foreach ($list as $feature) { + $merged[$feature->value] ??= $feature; + } + } + + return array_values($merged); + } + + /** + * What the entry $postId adds to a tool that loads it. A page or post adds nothing; a library entry needs the + * Divi roles (LibraryAccess), written also the library check of global entries and the library save scope + * (DiviSaver, D5); a Theme Builder layout needs the roles, written also the template lookup of + * ThemeBuilderEdits and the Theme Builder save scope. An ID that names no entry, or one the caller may not edit, + * adds nothing – the tool answers "not found" or "forbidden" itself, and a lock never tells which kind of entry + * an ID is (ruling P12). + * + * @return list + */ + public static function ofEntry(mixed $postId, bool $write): array + { + $id = PostGuard::id($postId); + $type = $id === null ? false : get_post_type($id); + if ($id === null || ! is_string($type)) { + return []; + } + + $isLibrary = $type === DiviLibraryStore::POST_TYPE; + if (! $isLibrary && DiviThemeBuilder::areaOf($type) === null) { + return []; + } + + if (! current_user_can('edit_post', $id)) { + return []; + } + + if ($isLibrary) { + return $write + ? [DiviFeature::Roles, DiviFeature::LibraryRead, DiviFeature::LibraryWrite] + : [DiviFeature::Roles]; + } + + return $write + ? [DiviFeature::Roles, DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite] + : [DiviFeature::Roles]; + } +} +``` + +- [ ] **Step 5: Port, `ContentCreateAbility` und `DiviContentComposer`** + +Diff (`ContentComposer`, `ContentCreateAbility`, `DiviContentComposer`; der `DiviWriteAbility`-Teil folgt in Step 6): + +```diff +diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php +index acfe0b6..aac0373 100644 +--- a/src/Integrations/Divi/Pages/DiviContentComposer.php ++++ b/src/Integrations/Divi/Pages/DiviContentComposer.php +@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; + use LIT\MCP\Integrations\Divi\BlockTree\Outline; + use LIT\MCP\Integrations\Divi\BlockTree\Serializer; + use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; ++use LIT\MCP\Integrations\Divi\Release\DiviFeature; + use LIT\MCP\Integrations\Divi\Release\ReleaseGate; + use LIT\MCP\Integrations\Divi\Schema\DiviModules; + use LIT\MCP\Integrations\WordPress\Content\ContentComposer; +@@ -36,6 +37,19 @@ defined('ABSPATH') || exit; + */ + final class DiviContentComposer implements ContentComposer + { ++ /** ++ * What a new Divi entry needs: Catalog (diviEnabled(), composing), DesignRead (presets, variables), DynamicSources ++ * (the composition checks references against the new entry's map), PageRead (link targets) and PageWrite (saveNew ++ * with the Divi flags). No library or Theme Builder scope: the draft is an ordinary entry. ++ */ ++ private const FEATURES = [ ++ DiviFeature::PageRead, ++ DiviFeature::PageWrite, ++ DiviFeature::Catalog, ++ DiviFeature::DesignRead, ++ DiviFeature::DynamicSources, ++ ]; ++ + /** @var Closure(): bool */ + private Closure $writable; + +@@ -60,6 +74,11 @@ final class DiviContentComposer implements ContentComposer + return ($this->writable)(); + } + ++ public function requirements(): array ++ { ++ return array_map(static fn (DiviFeature $feature): string => $feature->value, self::FEATURES); ++ } ++ + public function refusal(): ?WP_Error + { + $status = ReleaseGate::status(); +diff --git a/src/Integrations/WordPress/Content/ContentComposer.php b/src/Integrations/WordPress/Content/ContentComposer.php +index eec00f0..f5cb59a 100644 +--- a/src/Integrations/WordPress/Content/ContentComposer.php ++++ b/src/Integrations/WordPress/Content/ContentComposer.php +@@ -35,6 +35,15 @@ interface ContentComposer + */ + public function refusal(): ?WP_Error; + ++ /** ++ * IDs of the capabilities of the composing integration that composing a new entry needs (the Divi integration: ++ * the DiviFeature values). lit/content-create hands them on as DeclaresRequirements; the core never interprets ++ * them. ++ * ++ * @return list ++ */ ++ public function requirements(): array; ++ + /** + * Validates the description for a new entry of $postType. Nothing is written. + * +diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php +index da1477c..f33eeb7 100644 +--- a/src/Integrations/WordPress/Content/ContentCreateAbility.php ++++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php +@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress\Content; + + use LIT\MCP\Abilities\Ability; + use LIT\MCP\Abilities\Annotations; ++use LIT\MCP\Abilities\DeclaresRequirements; + use LIT\MCP\Abilities\Errors; + use LIT\MCP\Abilities\Rev; + use LIT\MCP\Abilities\ToolResult; +@@ -32,7 +33,7 @@ defined('ABSPATH') || exit; + * (title/excerpt keep kses), then the composer stores the layout (DiviSaver's kses window, spec + * D7) – a refusal or failure there deletes the draft again. + */ +-final class ContentCreateAbility implements Ability ++final class ContentCreateAbility implements Ability, DeclaresRequirements + { + /** The extras (written after the layout) re-saved the entry and changed the stored layout (review m-6). */ + public const LAYOUT_CHANGED = 'Der Divi-Inhalt wurde beim Speichern der Felder verändert (z. B. durch ein Plugin); ' +@@ -109,6 +110,15 @@ final class ContentCreateAbility implements Ability + return $type instanceof WP_Error ? $type : true; + } + ++ /** ++ * Without divi the call stays in WordPress and needs nothing; with divi it needs what the ContentComposer says it ++ * needs (the Divi integration names its capabilities), so the guard refuses before a draft is inserted. ++ */ ++ public function requirements(array $input): array ++ { ++ return array_key_exists('divi', $input) && $this->divi !== null ? $this->divi->requirements() : []; ++ } ++ + public function execute(array $input): mixed + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts'); +``` + +- [ ] **Step 6: `DiviWriteAbility` (Basis von 7 Tools)** + +```diff +diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php +index 3f6a965..3da25a4 100644 +--- a/src/Integrations/Divi/Pages/DiviWriteAbility.php ++++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php +@@ -6,6 +6,9 @@ namespace LIT\MCP\Integrations\Divi\Pages; + + use LIT\MCP\Abilities\Ability; + use LIT\MCP\Abilities\ToolResult; ++use LIT\MCP\Integrations\Divi\Release\DiviFeature; ++use LIT\MCP\Integrations\Divi\Release\FeatureSets; ++use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; + use WP_Error; + + defined('ABSPATH') || exit; +@@ -17,7 +20,7 @@ defined('ABSPATH') || exit; + * validated, all or nothing (ruling S39) – and DiviWriter::commit() stores it (library entries: + * GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22). + */ +-abstract class DiviWriteAbility implements Ability ++abstract class DiviWriteAbility implements Ability, RequiresDiviFeatures + { + public function __construct(protected DiviWriter $writer) + { +@@ -31,6 +34,19 @@ abstract class DiviWriteAbility implements Ability + */ + abstract protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error; + ++ /** ++ * What the tool needs whatever entry it changes; diviFeatures() adds what the entry post_id names (a library ++ * entry or Theme Builder layout is saved with its own capability, D5). ++ * ++ * @return list ++ */ ++ abstract protected function baseFeatures(): array; ++ ++ public function diviFeatures(array $input): array ++ { ++ return FeatureSets::merge($this->baseFeatures(), FeatureSets::ofEntry($input['post_id'] ?? null, true)); ++ } ++ + public function outputSchema(): ?array + { + return ToolResult::schema([ +``` + +Ab hier schlägt `composer test` für die 7 Unterklassen (`baseFeatures()` fehlt) bis Step 7 mit einem Fatal Error fehl – das ist erwartet; Step 7 gehört mit Step 6 in einen Commit. + +- [ ] **Step 7: Die 28 Ability-Blöcke** + +Jede Ability bekommt genau einen Block (die sieben `DiviWriteAbility`-Unterklassen `baseFeatures()`, `DiviNodeCopyAbility` zusätzlich ein `diviFeatures()`), dazu die genannten `use`-Zeilen. Ein Block steht jeweils direkt vor `public function permission(array $input): bool|WP_Error` bzw. (Unterklassen von `DiviWriteAbility`, die keine `permission()` haben) direkt vor `protected function change(`; gibt es dort einen Docblock, steht der Block davor. + +#### Seiten (`src/Integrations/Divi/Pages/`) + +##### `lit/divi-outline` – `src/Integrations/Divi/Pages/DiviOutlineAbility.php` + +- Klassenkopf: `class DiviOutlineAbility implements Ability` wird `class DiviOutlineAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\FeatureSets;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * Loads the entry (PageRead) and names preset labels (DesignRead). Global modules are shown by ID only, never + * resolved. A library entry or Theme Builder layout as post_id adds the Divi roles. + */ + public function diviFeatures(array $input): array + { + return FeatureSets::merge( + [DiviFeature::PageRead, DiviFeature::DesignRead], + FeatureSets::ofEntry($input['post_id'] ?? null, false) + ); + } +``` + +##### `lit/divi-node-get` – `src/Integrations/Divi/Pages/DiviNodeGetAbility.php` + +- Klassenkopf: `class DiviNodeGetAbility implements Ability` wird `class DiviNodeGetAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\FeatureSets;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * Loads the entry (PageRead) and names preset labels (DesignRead); ShortformMap is pure. A library entry or + * Theme Builder layout as post_id adds the Divi roles. + */ + public function diviFeatures(array $input): array + { + return FeatureSets::merge( + [DiviFeature::PageRead, DiviFeature::DesignRead], + FeatureSets::ofEntry($input['post_id'] ?? null, false) + ); + } +``` + +##### `lit/divi-insert` – `src/Integrations/Divi/Pages/DiviInsertAbility.php` + +- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` +- Block direkt vor `protected function change(` einfügen: + +```php + /** + * Loads and saves the entry (PageRead, PageWrite) and composes new nodes: Catalog, DesignRead (presets, + * variables) and DynamicSources (references are checked against the dynamic content map of the entry). + */ + protected function baseFeatures(): array + { + return [ + DiviFeature::PageRead, + DiviFeature::PageWrite, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::DynamicSources, + ]; + } +``` + +##### `lit/divi-update` – `src/Integrations/Divi/Pages/DiviUpdateAbility.php` + +- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` +- Block direkt vor `protected function change(` einfügen: + +```php + /** Like lit/divi-insert: the Composer checks every changed value (Catalog, DesignRead, DynamicSources). */ + protected function baseFeatures(): array + { + return [ + DiviFeature::PageRead, + DiviFeature::PageWrite, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::DynamicSources, + ]; + } +``` + +##### `lit/divi-move` – `src/Integrations/Divi/Pages/DiviMoveAbility.php` + +- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` +- Block direkt vor `protected function change(` einfügen: + +```php + /** + * No client value enters the tree (no DynamicSources): load and save (PageRead, PageWrite), StructureRules + * need the catalog, the quality warnings of the result read the design system (DesignRead). + */ + protected function baseFeatures(): array + { + return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead]; + } +``` + +##### `lit/divi-delete` – `src/Integrations/Divi/Pages/DiviDeleteAbility.php` + +- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` +- Block direkt vor `protected function change(` einfügen: + +```php + /** Like lit/divi-move: TreeEditor::remove() only, no composition of client values. */ + protected function baseFeatures(): array + { + return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead]; + } +``` + +##### `lit/divi-duplicate-node` – `src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php` + +- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` +- Block direkt vor `protected function change(` einfügen: + +```php + /** Like lit/divi-move: TreeEditor::duplicate() copies stored bytes, no composition of client values. */ + protected function baseFeatures(): array + { + return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead]; + } +``` + +##### `lit/divi-replace-text` – `src/Integrations/Divi/Pages/DiviReplaceTextAbility.php` + +- Klassenkopf: `class DiviReplaceTextAbility implements Ability` wird `class DiviReplaceTextAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\FeatureSets;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * The dry run (default) reads: PageRead, Catalog and DesignRead; the cleaner builds its reference check from the + * entry's dynamic content map (DynamicSources). A real run (dry_run: false) also saves each target, which may be a + * library entry or Theme Builder layout (PageWrite and what FeatureSets::ofEntry() adds). + */ + public function diviFeatures(array $input): array + { + $features = [ + DiviFeature::PageRead, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::DynamicSources, + ]; + if (($input['dry_run'] ?? true) !== false) { + return $features; + } + + $features[] = DiviFeature::PageWrite; + foreach (is_array($input['targets'] ?? null) ? $input['targets'] : [] as $target) { + $features = FeatureSets::merge( + $features, + FeatureSets::ofEntry(is_array($target) ? ($target['post_id'] ?? null) : null, true) + ); + } + + return $features; + } +``` + +##### `lit/divi-node-copy` – `src/Integrations/Divi/Pages/DiviNodeCopyAbility.php` + +- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\FeatureSets;` +- Block direkt vor `protected function change(` einfügen: + +```php + /** + * Like lit/divi-insert (StoredNodeGuard cleans the copied node: Catalog, DesignRead, DynamicSources); the source + * entry is only read, so a library entry or Theme Builder layout as source adds the Divi roles. + */ + protected function baseFeatures(): array + { + return [ + DiviFeature::PageRead, + DiviFeature::PageWrite, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::DynamicSources, + ]; + } + + public function diviFeatures(array $input): array + { + return FeatureSets::merge( + parent::diviFeatures($input), + FeatureSets::ofEntry($input['source_post_id'] ?? null, false) + ); + } +``` + +##### `lit/divi-dynamic-sources` – `src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php` + +- Klassenkopf: `class DiviDynamicSourcesAbility implements Ability` wird `class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * Divi's dynamic content registry (DynamicSources); listing custom fields asks the role option + * read_dynamic_content_custom_fields (Roles) and omits them without it, so a broken roles check must not pass. + */ + public function diviFeatures(array $input): array + { + return [DiviFeature::DynamicSources, DiviFeature::Roles]; + } +``` + +#### Katalog und Designsystem + +##### `lit/divi-catalog` – `src/Integrations/Divi/Schema/DiviCatalogAbility.php` + +- Klassenkopf: `class DiviCatalogAbility implements Ability` wird `class DiviCatalogAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** Only the module catalog (DiviModules::catalog()). */ + public function diviFeatures(array $input): array + { + return [DiviFeature::Catalog]; + } +``` + +##### `lit/divi-design-system` – `src/Integrations/Divi/Design/DiviDesignSystemAbility.php` + +- Klassenkopf: `class DiviDesignSystemAbility implements Ability` wird `class DiviDesignSystemAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** Only the design system reader. */ + public function diviFeatures(array $input): array + { + return [DiviFeature::DesignRead]; + } +``` + +##### `lit/divi-preset-get` – `src/Integrations/Divi/Design/DiviPresetGetAbility.php` + +- Klassenkopf: `class DiviPresetGetAbility implements Ability` wird `class DiviPresetGetAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** Divi's preset inventory (DesignRead) and the option groups of modules (Catalog); usages come from the database. */ + public function diviFeatures(array $input): array + { + return [DiviFeature::DesignRead, DiviFeature::Catalog]; + } +``` + +##### `lit/divi-presets-set` – `src/Integrations/Divi/Design/DiviPresetsSetAbility.php` + +- Klassenkopf: `class DiviPresetsSetAbility implements Ability` wird `class DiviPresetsSetAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\FeatureSets;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * Writes the preset inventory (PresetWrite) after reading it (DesignRead); the preset composer cleans the + * attributes (Catalog). from_node reads one node of an entry (PageRead, and the roles for a library entry). + */ + public function diviFeatures(array $input): array + { + $features = [DiviFeature::DesignRead, DiviFeature::PresetWrite, DiviFeature::Catalog]; + foreach (is_array($input['items'] ?? null) ? $input['items'] : [] as $item) { + $source = is_array($item) ? ($item['from_node'] ?? null) : null; + if (is_array($source)) { + $features = FeatureSets::merge( + $features, + [DiviFeature::PageRead], + FeatureSets::ofEntry($source['post_id'] ?? null, false) + ); + } + } + + return $features; + } +``` + +##### `lit/divi-presets-delete` – `src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php` + +- Klassenkopf: `class DiviPresetsDeleteAbility implements Ability` wird `class DiviPresetsDeleteAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** Reads and writes the preset inventory only; no composition. */ + public function diviFeatures(array $input): array + { + return [DiviFeature::DesignRead, DiviFeature::PresetWrite]; + } +``` + +##### `lit/divi-design-snapshots` – `src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php` + +- Klassenkopf: `class DiviDesignSnapshotsAbility implements Ability` wird `class DiviDesignSnapshotsAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** Reads LIT MCP's own snapshot store (WordPress option); no Divi call. */ + public function diviFeatures(array $input): array + { + return []; + } +``` + +##### `lit/divi-design-restore` – `src/Integrations/Divi/Design/DiviDesignRestoreAbility.php` + +- Klassenkopf: `class DiviDesignRestoreAbility implements Ability` wird `class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** Writes a stored snapshot back through the preset area (PresetWrite, reading it first: DesignRead). */ + public function diviFeatures(array $input): array + { + return [DiviFeature::DesignRead, DiviFeature::PresetWrite]; + } +``` + +##### `lit/divi-presets-assign` – `src/Integrations/Divi/Design/DiviPresetsAssignAbility.php` + +- Klassenkopf: `class DiviPresetsAssignAbility implements Ability` wird `class DiviPresetsAssignAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * Rewrites references in every post that uses the module, so the upper bound covers every kind of post the + * rewriter may save: pages (PageRead, PageWrite, Catalog, DesignRead), library entries (LibraryRead for + * isGlobal(), LibraryWrite) and Theme Builder layouts (ThemeBuilderWrite), with their roles. Stopping in the + * middle of a run would leave a partial write, so the lock comes before. DesignRights::presets() checks the + * Visual Builder role through the preset writer (PresetWrite). + */ + public function diviFeatures(array $input): array + { + return [ + DiviFeature::PageRead, + DiviFeature::PageWrite, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::PresetWrite, + DiviFeature::LibraryRead, + DiviFeature::LibraryWrite, + DiviFeature::ThemeBuilderWrite, + DiviFeature::Roles, + ]; + } +``` + +##### `lit/divi-design-detach` – `src/Integrations/Divi/Design/DiviDesignDetachAbility.php` + +- Klassenkopf: `class DiviDesignDetachAbility implements Ability` wird `class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * Like lit/divi-presets-assign (it rewrites every kind of post), plus the preset step that removes references + * nested in other presets (PresetWrite). + */ + public function diviFeatures(array $input): array + { + return [ + DiviFeature::PageRead, + DiviFeature::PageWrite, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::PresetWrite, + DiviFeature::LibraryRead, + DiviFeature::LibraryWrite, + DiviFeature::ThemeBuilderWrite, + DiviFeature::Roles, + ]; + } +``` + +#### Bibliothek (`src/Integrations/Divi/Library/`) + +##### `lit/library-list` – `src/Integrations/Divi/Library/LibraryListAbility.php` + +- Klassenkopf: `class LibraryListAbility implements Ability` wird `class LibraryListAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * describe() asks Divi whether an entry is global (LibraryRead); listing needs the library role (Roles). One + * entry (id) is loaded and outlined (PageRead, DesignRead). + */ + public function diviFeatures(array $input): array + { + $features = [DiviFeature::LibraryRead, DiviFeature::Roles]; + + return isset($input['id']) + ? array_merge($features, [DiviFeature::PageRead, DiviFeature::DesignRead]) + : $features; + } +``` + +##### `lit/library-find-similar` – `src/Integrations/Divi/Library/LibraryFindSimilarAbility.php` + +- Klassenkopf: `class LibraryFindSimilarAbility implements Ability` wird `class LibraryFindSimilarAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * Scans Divi pages (PageRead) of the types that use the builder (Catalog), after the library right (Roles). A + * library entry as reference is read through LibraryAccess (Roles), no Divi call beyond that. + */ + public function diviFeatures(array $input): array + { + return [DiviFeature::PageRead, DiviFeature::Catalog, DiviFeature::Roles]; + } +``` + +##### `lit/library-insert` – `src/Integrations/Divi/Library/LibraryInsertAbility.php` + +- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` +- Block direkt vor `protected function change(` einfügen: + +```php + /** + * Like lit/divi-insert (StoredNodeGuard cleans the entry's nodes: Catalog, DesignRead, DynamicSources), plus the + * library entry as source: isGlobal() is a Divi call (LibraryRead), its rights are a Divi role (Roles). + */ + protected function baseFeatures(): array + { + return [ + DiviFeature::PageRead, + DiviFeature::PageWrite, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::DynamicSources, + DiviFeature::LibraryRead, + DiviFeature::Roles, + ]; + } +``` + +##### `lit/library-make-global` – `src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php` + +- Klassenkopf: `class LibraryMakeGlobalAbility implements Ability` wird `class LibraryMakeGlobalAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * Both modes write pages and posts (PageRead, PageWrite, Catalog, DesignRead, DynamicSources) and ask Divi + * whether an entry is global (LibraryRead); the first mode also creates the global entry (LibraryWrite). + * One upper bound for both modes (the second mode would not reach LibraryWrite): the roles of the library. + */ + public function diviFeatures(array $input): array + { + return [ + DiviFeature::PageRead, + DiviFeature::PageWrite, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::DynamicSources, + DiviFeature::LibraryRead, + DiviFeature::LibraryWrite, + DiviFeature::Roles, + ]; + } +``` + +##### `lit/library-save` – `src/Integrations/Divi/Library/LibrarySaveAbility.php` + +- Klassenkopf: `class LibrarySaveAbility implements Ability` wird `class LibrarySaveAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * Reads a node of any entry (PageRead), cleans it with StoredNodeGuard (Catalog, DesignRead, DynamicSources) and + * saves a new library entry (PageWrite and LibraryWrite: DiviSaver::saveNew with the library scope, D5), after + * the library right (Roles). The source is only read: a library entry or Theme Builder layout adds Roles only. + */ + public function diviFeatures(array $input): array + { + return [ + DiviFeature::PageRead, + DiviFeature::PageWrite, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::DynamicSources, + DiviFeature::LibraryWrite, + DiviFeature::Roles, + ]; + } +``` + +#### Theme Builder (`src/Integrations/Divi/ThemeBuilder/`) + +`TbCreateTemplateAbility` braucht kein `use` für `Template` (selber Namespace). + +##### `lit/tb-list` – `src/Integrations/Divi/ThemeBuilder/TbListAbility.php` + +- Klassenkopf: `class TbListAbility implements Ability` wird `class TbListAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** Templates, layouts and conditions come from Divi's Theme Builder functions; the right is a Divi role. */ + public function diviFeatures(array $input): array + { + return [DiviFeature::ThemeBuilderRead, DiviFeature::Roles]; + } +``` + +##### `lit/tb-create-template` – `src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php` + +- Klassenkopf: `class TbCreateTemplateAbility implements Ability` wird `class TbCreateTemplateAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\FeatureSets;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** + * Creates layouts and the template (Theme Builder functions); layouts with nodes are composed and saved like a + * page (PageRead, PageWrite, Catalog, DesignRead, DynamicSources) – only when an area names nodes. + */ + public function diviFeatures(array $input): array + { + $features = [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite, DiviFeature::Roles]; + foreach (Template::AREAS as $area) { + $spec = $input[$area] ?? null; + if (is_array($spec) && is_array($spec['nodes'] ?? null) && $spec['nodes'] !== []) { + return FeatureSets::merge($features, [ + DiviFeature::PageRead, + DiviFeature::PageWrite, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::DynamicSources, + ]); + } + } + + return $features; + } +``` + +##### `lit/tb-assign` – `src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php` + +- Klassenkopf: `class TbAssignAbility implements Ability` wird `class TbAssignAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** Reads and writes templates and their conditions (Theme Builder functions); the right is a Divi role. */ + public function diviFeatures(array $input): array + { + return [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite, DiviFeature::Roles]; + } +``` + +##### `lit/tb-activate` – `src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php` + +- Klassenkopf: `class TbActivateAbility implements Ability` wird `class TbActivateAbility implements Ability, RequiresDiviFeatures`. +- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;` +- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen: + +```php + /** Switches _et_enabled of a listed template (Theme Builder functions); the right is a Divi role. */ + public function diviFeatures(array $input): array + { + return [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite, DiviFeature::Roles]; + } +``` + +- [ ] **Step 8: GREEN, Lint, Gesamtlauf** + +```bash +vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php +vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php +composer test +composer lint +``` + +Erwartet: `DiviToolFeaturesTest` OK (34 Tests), `FeatureSetsTest` OK (10 Tests), `composer test` grün (Ausgangszahl + 47 neue Tests; in der Kopie 2624 → 2671 vor Task 9), `composer lint` ohne Meldung. Prüfen, dass der Test die Tabelle wirklich erzwingt: in `DiviMoveAbility::baseFeatures()` kurz `DiviFeature::PageWrite` entfernen – `DiviToolFeaturesTest` muss scheitern (`'page-write'` fehlt in der Differenz); Änderung zurücknehmen. + +- [ ] **Step 9: Commit** + +```bash +git add src/Abilities/DeclaresRequirements.php \ + src/Integrations/Divi/Release/RequiresDiviFeatures.php src/Integrations/Divi/Release/FeatureSets.php \ + src/Integrations/WordPress/Content/ContentComposer.php src/Integrations/WordPress/Content/ContentCreateAbility.php \ + src/Integrations/Divi/Pages src/Integrations/Divi/Schema/DiviCatalogAbility.php \ + src/Integrations/Divi/Design src/Integrations/Divi/Library src/Integrations/Divi/ThemeBuilder \ + tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php \ + tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php \ + tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php \ + tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php +git commit -m "feat: jedes Divi-Tool und lit/content-create erklärt seine Divi-Fähigkeiten (RequiresDiviFeatures, DeclaresRequirements, FeatureSets); Tabelle Tool → Fähigkeiten als Test" -m "Co-Authored-By: Claude " +``` + +(`git status` vorher prüfen: außer den genannten Dateien darf nichts geändert sein; `git add` mit Verzeichnissen nimmt nur die Abilities und `DiviWriteAbility` mit – sonst liegt nichts in diesen Ordnern.) + +### Task 9: `DiviFeatureGate` ersetzt `DiviToolGate` + +**Files:** +- Neu: `src/Integrations/Divi/Release/DiviFeatureGate.php`, `tests/Support/DiviFeatureFixtures.php`, `tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php` +- Gelöscht: `src/Integrations/Divi/Release/DiviToolGate.php`, `tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php` +- Ändern: `src/Plugin.php` (bzw. dort, wo `DiviToolGate` instanziiert wird – Step 1 klärt das), `src/Integrations/Divi/Pages/DiviContentComposer.php`, `src/Abilities/AbilityRegistrar.php` (nur Docblock), `tests/Unit/Abilities/AbilityRegistrarTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php` + +**Interfaces:** + +```php +final class DiviFeatureGate implements AbilityGuard +{ + public function __construct(?ChangeLog $log = null); + public function refusal(Ability $ability, array $input): ?WP_Error; // lockFor(FeatureSets::declaredBy($ability, $input)) + public function annotate(Ability $ability, mixed $result): mixed; // unverändert (E2) + public function outputSchema(Ability $ability, ?array $schema): ?array; // + warnings für RequiresDiviFeatures + /** @param list $features */ + public static function lockFor(array $features): ?DiviLocked; // null = alle nutzbar +} +``` + +**Verhalten (E2, D6):** +- Ein Aufruf wird **vor** `execute()` abgelehnt, wenn eine Fähigkeit aus `FeatureSets::declaredBy($ability, $input)` nicht nutzbar ist (`ReleaseGate::feature($f)->usable()` ist falsch) – für Lese- und Schreib-Tools. Fehler: `(new DiviLocked(ReleaseGate::status(), $unusable))->toError()` (Task 7): Code `divi_incompatible`, wenn mindestens eine betroffene Fähigkeit inkompatibel ist, sonst `divi_untested`; HTTP 423; Details `features` (nur die **betroffenen** Fähigkeiten dieses Aufrufs, je einmal, mit `id`, `label`, `state`, `problems`) und ein Hinweis mit den Namen der Fähigkeiten und der Aussage, dass andere Divi-Werkzeuge weiter funktionieren. +- Protokoll nur für schreibende Tools: `record($ability->name(), 'divi', $status->version, 'Abgelehnt: Divi 5.3.0 – Seiten schreiben nicht freigegeben.', result: 'refused')`; je Fähigkeit „inkompatibel“ bzw. „nicht freigegeben“, mehrere mit Komma (E9-1). +- Tools ohne `RequiresDiviFeatures`/`DeclaresRequirements` (und `lit/content-create` ohne `divi`) bleiben unberührt, auch wenn alles gesperrt ist. +- Ein Schreib-Tool mit gesperrter, aber nicht benötigter Fähigkeit läuft (z. B. `lit/divi-insert` bei gesperrtem `preset-write`). + +- [ ] **Step 1: Ausgangslage und Verdrahtungsstelle finden** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git status --short +grep -rn "DiviToolGate" src tests docs/guides +grep -rn "new AbilityRegistrar" src tests +``` + +Erwartet: `DiviToolGate` kommt in `src/Plugin.php` (oder, falls Task 3 es in `DiviIntegration` verlagert hat, dort), in `DiviToolGateTest`, in `AbilityRegistrarTest` und in einem Docblock von `AbilityRegistrar` vor. **Jede** Stelle, an der heute `new DiviToolGate(...)` steht, wird `new DiviFeatureGate($changeLog)` (ohne Gruppen-Closure) – die Gate steht weiter in der `$guards`-Liste des `AbilityRegistrar`. Historische Notizen unter `docs/superpowers/notes/` nennen `DiviToolGate`; sie bleiben, Task 11 schreibt die Entscheidungsnotiz neu. + +- [ ] **Step 2: Tests schreiben** + +`tests/Support/DiviFeatureFixtures.php` (vollständig; braucht den benannten Parameter `matrix:` an `ReleaseStatus::__construct`, Task 6): + +```php + $states DiviFeature value => state + */ + public static function useStates(array $states, string $version = '5.3.0'): void + { + $features = []; + $overall = ReleaseState::Released; + foreach (DiviFeature::cases() as $feature) { + $state = $states[$feature->value] ?? ReleaseState::Released; + $features[$feature->value] = new FeatureStatus( + $feature, + $state, + $state === ReleaseState::Released ? [] : ['Abweichung bei ' . $feature->label()], + null + ); + if ($state === ReleaseState::Incompatible || ($state === ReleaseState::Untested + && $overall === ReleaseState::Released)) { + $overall = $state; + } + } + + $status = new ReleaseStatus($overall, $version, null, [], null, matrix: new CompatibilityMatrix($features)); + ReleaseGate::useResolver(static fn (): ReleaseStatus => $status); + } + + public static function lock(DiviFeature $feature, ReleaseState $state = ReleaseState::Untested): void + { + self::useStates([$feature->value => $state]); + } +} +``` + +`tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php` (vollständig). Er ersetzt `DiviToolGateTest`; Zuordnung der alten Fälle: „freigegeben → nichts“ → `testReleasedDiviRefusesNothingAndAddsNothing`; „Schreib-Tool jeder Gruppe abgelehnt“ → `testAWritingToolIsRefusedWhenItsWriteFeatureIsLockedAsIncompatible`, `testLockingOneFeatureRefusesExactlyTheRealToolsThatDeclareIt` (alle echten Tools, Gruppen gibt es nicht mehr); „Lese-Tool läuft und bekommt die Notiz“ → **umgekehrt** (E2): `testAReadingToolIsRefusedWhenOneOfItsFeaturesIsLocked`, `testAnnotateNeverChangesAResult`; „Ausgabeschema ergänzt `warnings`“ → `testDiviToolsKeepTheOptionalWarningsPropertyInTheirOutputSchema`, `testToolsOfTheCoreKeepTheirOutputSchema`. + +```php + $features What diviFeatures() returns for any input. + * @return Ability&RequiresDiviFeatures + */ + private function tool(string $name, bool $readonly, array $features): Ability + { + $ability = Mockery::mock(Ability::class, RequiresDiviFeatures::class); + $ability->allows('name')->andReturn($name); + $ability->allows('annotations')->andReturn(['readonly' => $readonly]); + $ability->allows('diviFeatures')->andReturn($features); + + return $ability; + } + + private function gate(?FakeChangeLog $log = null): DiviFeatureGate + { + return new DiviFeatureGate($log); + } + + public function testReleasedDiviRefusesNothingAndAddsNothing(): void + { + $gate = $this->gate(FakeChangeLog::forbidden()); + $write = $this->tool('lit/divi-insert', false, [DiviFeature::PageWrite, DiviFeature::Catalog]); + $read = $this->tool('lit/divi-outline', true, [DiviFeature::PageRead]); + + self::assertNull($gate->refusal($write, [])); + self::assertNull($gate->refusal($read, [])); + self::assertSame(['ok' => true], $gate->annotate($read, ['ok' => true])); + } + + public function testAReadingToolIsRefusedWhenOneOfItsFeaturesIsLocked(): void + { + DiviFeatureFixtures::lock(DiviFeature::DesignRead); + $read = $this->tool('lit/divi-outline', true, [DiviFeature::PageRead, DiviFeature::DesignRead]); + + // A refused read changes nothing: no change log entry (the fake allows none). + $error = $this->gate(FakeChangeLog::forbidden())->refusal($read, ['post_id' => 7]); + $payload = self::assertToolError($error, 'divi_untested'); + + self::assertSame(423, $error->get_error_data()['status']); + self::assertSame(['design-read'], array_column($payload['features'], 'id')); + } + + public function testAWritingToolIsRefusedWhenItsWriteFeatureIsLockedAsIncompatible(): void + { + DiviFeatureFixtures::lock(DiviFeature::PageWrite, ReleaseState::Incompatible); + $log = new FakeChangeLog(); + $write = $this->tool('lit/divi-insert', false, [DiviFeature::PageRead, DiviFeature::PageWrite]); + + $payload = self::assertToolError($this->gate($log)->refusal($write, []), 'divi_incompatible'); + + self::assertSame('incompatible', $payload['features'][0]['state']); + self::assertSame( + 'Abgelehnt: Divi 5.3.0 – Seiten schreiben inkompatibel.', + $log->records[0]['summary'] + ); + } + + public function testAToolThatDoesNotNeedTheLockedFeatureRuns(): void + { + DiviFeatureFixtures::useStates([ + 'preset-write' => ReleaseState::Incompatible, + 'library-write' => ReleaseState::Untested, + 'tb-write' => ReleaseState::Incompatible, + ]); + $gate = $this->gate(FakeChangeLog::forbidden()); + $insert = $this->tool('lit/divi-insert', false, [ + DiviFeature::PageRead, + DiviFeature::PageWrite, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::DynamicSources, + ]); + + self::assertNull($gate->refusal($insert, []), 'a write tool runs while only other write features are locked'); + self::assertNull($gate->refusal($this->tool('lit/divi-catalog', true, [DiviFeature::Catalog]), [])); + self::assertNull($gate->refusal($this->tool('lit/divi-design-snapshots', true, []), []), 'declares nothing'); + } + + public function testTheErrorNamesOnlyTheUnusableFeaturesOfTheCallEachOnce(): void + { + DiviFeatureFixtures::useStates([ + 'page-write' => ReleaseState::Untested, + 'roles' => ReleaseState::Untested, + 'tb-write' => ReleaseState::Incompatible, + ]); + $tool = $this->tool('lit/divi-insert', false, [ + DiviFeature::PageWrite, + DiviFeature::Roles, + DiviFeature::PageWrite, + DiviFeature::Catalog, + ]); + + $payload = self::assertToolError($this->gate(new FakeChangeLog())->refusal($tool, []), 'divi_untested'); + + self::assertSame(['page-write', 'roles'], array_column($payload['features'], 'id')); + self::assertSame( + ['Seiten schreiben', 'Divi-Rollen'], + array_column($payload['features'], 'label') + ); + self::assertSame('untested', $payload['features'][0]['state']); + self::assertIsArray($payload['features'][0]['problems']); + self::assertStringContainsString('Seiten schreiben', $payload['hint']); + self::assertStringContainsString('Divi-Rollen', $payload['hint']); + self::assertStringContainsString('weiter', $payload['hint'], 'other Divi tools keep working'); + } + + public function testAnIncompatibleFeatureAmongUntestedOnesMakesTheCodeIncompatible(): void + { + DiviFeatureFixtures::useStates([ + 'page-write' => ReleaseState::Untested, + 'catalog' => ReleaseState::Incompatible, + ]); + $tool = $this->tool('lit/divi-update', false, [DiviFeature::PageWrite, DiviFeature::Catalog]); + + self::assertToolError($this->gate(new FakeChangeLog())->refusal($tool, []), 'divi_incompatible'); + } + + public function testTheToolIsAskedForTheInputOfTheCall(): void + { + DiviFeatureFixtures::lock(DiviFeature::LibraryWrite); + $tool = Mockery::mock(Ability::class, RequiresDiviFeatures::class); + $tool->allows('name')->andReturn('lit/divi-insert'); + $tool->allows('annotations')->andReturn(['readonly' => false]); + $tool->allows('diviFeatures')->with(['post_id' => 1])->andReturn([DiviFeature::PageWrite]); + $tool->allows('diviFeatures')->with(['post_id' => 2]) + ->andReturn([DiviFeature::PageWrite, DiviFeature::LibraryWrite]); + $gate = $this->gate(new FakeChangeLog()); + + self::assertNull($gate->refusal($tool, ['post_id' => 1])); + self::assertToolError($gate->refusal($tool, ['post_id' => 2]), 'divi_untested'); + } + + public function testAToolWithoutDeclarationIsNeverTouchedEvenWithEverythingLocked(): void + { + DiviFeatureFixtures::useStates(array_fill_keys( + array_map(static fn (DiviFeature $f): string => $f->value, DiviFeature::cases()), + ReleaseState::Incompatible + )); + $plain = Mockery::mock(Ability::class); + $plain->allows('name')->andReturn('lit/content-update'); + + self::assertNull($this->gate(FakeChangeLog::forbidden())->refusal($plain, ['post_id' => 1])); + } + + private function contentCreate(?ContentComposer $composer): ContentCreateAbility + { + return new ContentCreateAbility( + Mockery::mock(ContentSchema::class), + Mockery::mock(PostWriter::class), + FakeChangeLog::forbidden(), + $composer + ); + } + + private function composerNeeding(string ...$ids): ContentComposer + { + $composer = Mockery::mock(ContentComposer::class); + $composer->allows('requirements')->andReturn(array_values($ids)); + + return $composer; + } + + public function testContentCreateWithoutDiviIsUntouchedWhateverIsLocked(): void + { + DiviFeatureFixtures::lock(DiviFeature::PageWrite, ReleaseState::Incompatible); + $tool = $this->contentCreate($this->composerNeeding('page-write')); + + self::assertInstanceOf(DeclaresRequirements::class, $tool); + self::assertNull( + $this->gate(FakeChangeLog::forbidden())->refusal($tool, ['post_type' => 'page', 'title' => 'Start']) + ); + } + + public function testContentCreateWithDiviIsRefusedWhenAFeatureOfTheComposerIsLocked(): void + { + DiviFeatureFixtures::lock(DiviFeature::PageWrite); + $log = new FakeChangeLog(); + $tool = $this->contentCreate($this->composerNeeding('page-read', 'page-write', 'catalog')); + $input = ['post_type' => 'page', 'title' => 'Start', 'divi' => [['type' => 'section']]]; + + $payload = self::assertToolError($this->gate($log)->refusal($tool, $input), 'divi_untested'); + + self::assertSame(['page-write'], array_column($payload['features'], 'id')); + self::assertSame('lit/content-create', $log->records[0]['ability']); + } + + public function testContentCreateWithDiviRunsWhenOnlyOtherFeaturesAreLocked(): void + { + DiviFeatureFixtures::useStates([ + 'preset-write' => ReleaseState::Untested, + 'tb-write' => ReleaseState::Untested, + ]); + $tool = $this->contentCreate($this->composerNeeding('page-read', 'page-write', 'catalog')); + + self::assertNull( + $this->gate(FakeChangeLog::forbidden())->refusal($tool, ['post_type' => 'page', 'divi' => [[]]]) + ); + } + + public function testAnUnknownRequirementOfTheCoreIsIgnored(): void + { + DiviFeatureFixtures::lock(DiviFeature::PageWrite); + $tool = $this->contentCreate($this->composerNeeding('something-else')); + + self::assertNull( + $this->gate(FakeChangeLog::forbidden())->refusal($tool, ['post_type' => 'page', 'divi' => [[]]]) + ); + } + + public function testAnnotateNeverChangesAResult(): void + { + DiviFeatureFixtures::lock(DiviFeature::PageWrite); + $gate = $this->gate(); + $read = $this->tool('lit/divi-outline', true, [DiviFeature::PageRead]); + $error = new WP_Error('lit_not_found', 'x'); + + self::assertSame(['post_id' => 7], $gate->annotate($read, ['post_id' => 7]), 'E2: no notice any more'); + self::assertSame(['warnings' => ['alt']], $gate->annotate($read, ['warnings' => ['alt']])); + self::assertSame([1, 2], $gate->annotate($read, [1, 2])); + self::assertSame($error, $gate->annotate($read, $error)); + } + + public function testDiviToolsKeepTheOptionalWarningsPropertyInTheirOutputSchema(): void + { + $gate = $this->gate(); + $read = $this->tool('lit/divi-outline', true, []); + + $properties = ['post_id' => ['type' => 'integer']]; + $schema = $gate->outputSchema($read, ['type' => 'object', 'properties' => $properties]); + $warnings = ['type' => 'array', 'items' => ['type' => 'string']]; + self::assertSame($warnings, $schema['properties']['warnings'] ?? null); + self::assertSame($properties['post_id'], $schema['properties']['post_id']); + + $own = ['type' => 'object', 'properties' => ['warnings' => ['type' => 'array']]]; + self::assertSame($own, $gate->outputSchema($read, $own), 'an existing warnings property stays'); + self::assertNull($gate->outputSchema($read, null)); + self::assertSame(['type' => 'array'], $gate->outputSchema($read, ['type' => 'array'])); + } + + public function testToolsOfTheCoreKeepTheirOutputSchema(): void + { + $gate = $this->gate(); + $create = $this->contentCreate(null); + + self::assertSame(['type' => 'object'], $gate->outputSchema($create, ['type' => 'object'])); + self::assertSame( + ['type' => 'object'], + $gate->outputSchema(Mockery::mock(Ability::class), ['type' => 'object']) + ); + } + + public function testLockForIsNullForUsableFeaturesAndNamesTheUnusableOnesOtherwise(): void + { + self::assertNull(DiviFeatureGate::lockFor([])); + self::assertNull(DiviFeatureGate::lockFor([DiviFeature::PageWrite])); + + DiviFeatureFixtures::lock(DiviFeature::Catalog); + $lock = DiviFeatureGate::lockFor([DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::Catalog]); + + self::assertNotNull($lock); + self::assertSame([DiviFeature::Catalog], $lock->features); + self::assertSame('5.3.0', $lock->status->version); + } + + /** + * The real tools of the three Divi groups: locking one capability refuses exactly the tools that declare it + * (reading and writing alike) and no other. + */ + public function testLockingOneFeatureRefusesExactlyTheRealToolsThatDeclareIt(): void + { + Functions\when('__')->returnArg(); + Functions\when('get_post_type')->justReturn(false); + Functions\when('current_user_can')->justReturn(true); + $divi = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()); + $tools = [...$divi->abilities(), ...$divi->library()->abilities(), ...$divi->themeBuilder()->abilities()]; + self::assertNotEmpty($tools); + + foreach (DiviFeature::cases() as $feature) { + DiviFeatureFixtures::lock($feature); + $gate = $this->gate(FakeChangeLog::any()); + foreach ($tools as $tool) { + $declares = in_array($feature, FeatureSets::declaredBy($tool, []), true); + self::assertSame( + $declares, + $gate->refusal($tool, []) instanceof WP_Error, + $tool->name() . ' / ' . $feature->value + ); + } + } + } +} +``` + +`tests/Unit/Abilities/AbilityRegistrarTest.php` – die Fälle aus Task 1 auf `DiviFeatureGate` umstellen (Diff gegen den Stand nach Task 1; die Ankerzeilen mit `grep -n DiviToolGate tests/Unit/Abilities/AbilityRegistrarTest.php` prüfen). Der Helfer `ability()` bekommt den Parameter `$features` (Mock mit `RequiresDiviFeatures`), `diviRegistrar()` übergibt `[new DiviFeatureGate()]`, die Divi-Fälle deklarieren ihre Fähigkeiten und sperren sie über `DiviFeatureFixtures`: + +```diff +diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php +index ec7ca94..79dfd38 100644 +--- a/tests/Unit/Abilities/AbilityRegistrarTest.php ++++ b/tests/Unit/Abilities/AbilityRegistrarTest.php +@@ -9,14 +9,17 @@ use LIT\MCP\Abilities\Ability; + use LIT\MCP\Abilities\AbilityRegistrar; + use LIT\MCP\Abilities\ExposurePolicy; + use LIT\MCP\Abilities\WriteFailed; ++use LIT\MCP\Integrations\Divi\Release\DiviFeature; ++use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate; + use LIT\MCP\Integrations\Divi\Release\DiviLocked; +-use LIT\MCP\Integrations\Divi\Release\DiviToolGate; + use LIT\MCP\Integrations\Divi\Release\ReleaseGate; + use LIT\MCP\Integrations\Divi\Release\ReleaseState; + use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; ++use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; + use LIT\MCP\Integrations\Integration; + use LIT\MCP\Integrations\IntegrationRegistry; + use LIT\MCP\Server\RequestContext; ++use LIT\MCP\Tests\Support\DiviFeatureFixtures; + use LIT\MCP\Tests\Unit\TestCase; + use Mockery; + use RuntimeException; +@@ -56,12 +59,18 @@ final class AbilityRegistrarTest extends TestCase + $integration->allows('abilities')->andReturn([$ability]); + $registry = new IntegrationRegistry($integration); + +- return new AbilityRegistrar($registry, null, [new DiviToolGate($registry->groupMap(...))]); ++ return new AbilityRegistrar($registry, null, [new DiviFeatureGate()]); + } + +- private function ability(bool $readonly = true): Mockery\MockInterface ++ /** @param list|null $features null = a tool that declares no Divi features */ ++ private function ability(bool $readonly = true, ?array $features = null): Mockery\MockInterface + { +- $ability = Mockery::mock(Ability::class); ++ $ability = $features === null ++ ? Mockery::mock(Ability::class) ++ : Mockery::mock(Ability::class, RequiresDiviFeatures::class); ++ if ($features !== null) { ++ $ability->allows('diviFeatures')->andReturn($features); ++ } + $ability->allows('name')->andReturn('lit/test'); + $ability->allows('label')->andReturn('Test'); + $ability->allows('description')->andReturn('Test ability'); +@@ -218,15 +227,52 @@ final class AbilityRegistrarTest extends TestCase + unlink($log); + } + +- public function testUntestedDiviRefusesAWritingDiviToolBeforeItRuns(): void ++ public function testALockedFeatureRefusesAWritingDiviToolBeforeItRuns(): void + { +- ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0')); +- $ability = $this->ability(false); ++ DiviFeatureFixtures::lock(DiviFeature::PageWrite); ++ $ability = $this->ability(false, [DiviFeature::PageRead, DiviFeature::PageWrite]); + $ability->expects('execute')->never(); + + $args = $this->diviRegistrar($ability)->argsFor($ability); + +- self::assertToolError(($args['execute_callback'])([]), 'divi_untested'); ++ $payload = self::assertToolError(($args['execute_callback'])([]), 'divi_untested'); ++ self::assertSame(['page-write'], array_column($payload['features'], 'id')); ++ } ++ ++ public function testALockedFeatureRefusesAReadingDiviToolToo(): void ++ { ++ DiviFeatureFixtures::lock(DiviFeature::DesignRead, ReleaseState::Incompatible); ++ $ability = $this->ability(true, [DiviFeature::PageRead, DiviFeature::DesignRead]); ++ $ability->expects('execute')->never(); ++ ++ $args = $this->diviRegistrar($ability)->argsFor($ability); ++ ++ self::assertToolError(($args['execute_callback'])([]), 'divi_incompatible'); ++ } ++ ++ public function testAToolRunsAndKeepsItsResultWhileOnlyOtherFeaturesAreLocked(): void ++ { ++ DiviFeatureFixtures::lock(DiviFeature::PresetWrite); ++ $ability = $this->ability(false, [DiviFeature::PageRead, DiviFeature::PageWrite]); ++ $ability->expects('execute')->once()->with(['post_id' => 3])->andReturn(['ok' => true]); ++ ++ $args = $this->diviRegistrar($ability)->argsFor($ability); ++ ++ self::assertSame(['ok' => true], ($args['execute_callback'])(['post_id' => 3]), 'no notice is added (E2)'); ++ } ++ ++ public function testAToolThatDeclaresNoFeaturesIsNotGated(): void ++ { ++ DiviFeatureFixtures::useStates(array_fill_keys( ++ array_map(static fn (DiviFeature $feature): string => $feature->value, DiviFeature::cases()), ++ ReleaseState::Incompatible ++ )); ++ $ability = $this->ability(false); ++ $ability->expects('execute')->once()->andReturn(['ok' => true]); ++ ++ $args = $this->diviRegistrar($ability)->argsFor($ability); ++ ++ self::assertSame(['ok' => true], ($args['execute_callback'])([])); + } + + public function testALockThrownInsideAWriteBecomesTheLockError(): void +@@ -238,6 +284,18 @@ final class AbilityRegistrarTest extends TestCase + self::assertToolError($this->executeLogged($ability), 'divi_incompatible'); + } + ++ public function testAFeatureLockThrownInsideAWriteBecomesTheFeatureError(): void ++ { ++ DiviFeatureFixtures::lock(DiviFeature::LibraryWrite); ++ $locked = new DiviLocked(ReleaseGate::status(), [DiviFeature::LibraryWrite]); ++ $ability = $this->ability(); ++ $ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $locked)); ++ ++ $payload = self::assertToolError($this->executeLogged($ability), 'divi_untested'); ++ ++ self::assertSame(['library-write'], array_column($payload['features'], 'id')); ++ } ++ + public function testAPartialWriteStaysPartialEvenWithALockInside(): void + { + $locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0')); +@@ -263,13 +321,16 @@ final class AbilityRegistrarTest extends TestCase + } + } + +- public function testDiviReadToolsGetWarningsInTheirSchema(): void ++ public function testDiviToolsGetWarningsInTheirSchemaOthersDoNot(): void + { +- $ability = $this->ability(); +- +- $args = $this->diviRegistrar($ability)->argsFor($ability); ++ $divi = $this->ability(true, [DiviFeature::PageRead]); ++ $core = $this->ability(); + ++ $args = $this->diviRegistrar($divi)->argsFor($divi); + self::assertArrayHasKey('warnings', $args['output_schema']['properties']); ++ ++ $coreArgs = $this->diviRegistrar($core)->argsFor($core); ++ self::assertSame(['type' => 'object'], $coreArgs['output_schema']); + } + + public function testRegisterAbilitiesCallsWordPressForEachAbility(): void +``` + +`tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php` – `refusal()` je Fähigkeit: + +```diff +diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +index 8020c92..32d7b7d 100644 +--- a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php ++++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +@@ -10,6 +10,8 @@ use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; + use LIT\MCP\Integrations\Divi\Pages\DiviSaver; + use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; + use LIT\MCP\Integrations\Divi\Release\DiviFeature; ++use LIT\MCP\Integrations\Divi\Release\ReleaseState; ++use LIT\MCP\Tests\Support\DiviFeatureFixtures; + use LIT\MCP\Tests\Support\DiviFixtures; + use LIT\MCP\Tests\Support\DiviWriterFixture; + use LIT\MCP\Tests\Unit\TestCase; +@@ -80,6 +82,30 @@ final class DiviContentComposerTest extends TestCase + } + } + ++ public function testRefusesNothingWhileItsCapabilitiesAreUsable(): void ++ { ++ DiviFeatureFixtures::useStates([ ++ 'preset-write' => ReleaseState::Incompatible, ++ 'library-write' => ReleaseState::Untested, ++ 'tb-write' => ReleaseState::Incompatible, ++ ]); ++ ++ self::assertNull($this->composer()->refusal(), 'capabilities of other tools do not matter'); ++ } ++ ++ public function testRefusesWithTheLockedCapabilitiesOfANewEntry(): void ++ { ++ DiviFeatureFixtures::useStates([ ++ 'page-write' => ReleaseState::Untested, ++ 'dynamic-sources' => ReleaseState::Incompatible, ++ 'preset-write' => ReleaseState::Untested, ++ ]); ++ ++ $payload = self::assertToolError($this->composer()->refusal(), 'divi_incompatible'); ++ ++ self::assertSame(['page-write', 'dynamic-sources'], array_column($payload['features'], 'id')); ++ } ++ + public function testChecksBeforeTheEntryExistsWithDivisMapForNoEntry(): void + { + self::assertNull($this->composer()->check('page', self::sections())); +``` + +Den alten `tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php` löschen: + +```bash +git rm tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php +``` + +- [ ] **Step 3: RED** + +```bash +vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php +vendor/bin/phpunit tests/Unit/Abilities/AbilityRegistrarTest.php +vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +``` + +Erwartet (in der Kopie so gesehen): `DiviFeatureGateTest` 17 Fehler `Class "LIT\MCP\Integrations\Divi\Release\DiviFeatureGate" not found`; `AbilityRegistrarTest` 5 Fehler mit derselben Meldung (die vier Divi-Fälle plus `testDiviToolsGetWarningsInTheirSchemaOthersDoNot`); `DiviContentComposerTest` ein Fehler `Undefined array key "features"` (`testRefusesWithTheLockedCapabilitiesOfANewEntry`) und ein Fehlschlag in `testRefusesNothingWhileItsCapabilitiesAreUsable` (`Failed asserting that WP_Error Object … is null` – die alte Prüfung „global schreibbar“ lehnt bei gesperrtem `preset-write` ab). + +- [ ] **Step 4: Die Gate, Verdrahtung, `DiviContentComposer::refusal()`** + +`src/Integrations/Divi/Release/DiviFeatureGate.php` (vollständig): + +```php + $features + */ + public static function lockFor(array $features): ?DiviLocked + { + $unusable = []; + foreach ($features as $feature) { + if (! ReleaseGate::feature($feature)->usable()) { + $unusable[$feature->value] = $feature; + } + } + + return $unusable === [] ? null : new DiviLocked(ReleaseGate::status(), array_values($unusable)); + } + + public function refusal(Ability $ability, array $input): ?WP_Error + { + $lock = self::lockFor(FeatureSets::declaredBy($ability, $input)); + if ($lock === null) { + return null; + } + + if (! self::readsOnly($ability)) { + $this->log?->record( + $ability->name(), + 'divi', + $lock->status->version, + self::summary($lock), + result: 'refused' + ); + } + + return $lock->toError(); + } + + /** A refusal is the whole answer: there is no result to annotate (the lock of reading tools is gone, E2). */ + public function annotate(Ability $ability, mixed $result): mixed + { + return $result; + } + + /** + * Divi tools keep the optional warnings property in their output schema (as before the per-capability lock); + * lit/content-create has its own. + * + * @param array|null $schema + * @return array|null + */ + public function outputSchema(Ability $ability, ?array $schema): ?array + { + if ($schema === null || ($schema['type'] ?? null) !== 'object' || ! $ability instanceof RequiresDiviFeatures) { + return $schema; + } + + $properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : []; + $properties['warnings'] ??= ['type' => 'array', 'items' => ['type' => 'string']]; + $schema['properties'] = $properties; + + return $schema; + } + + private static function readsOnly(Ability $ability): bool + { + return ($ability->annotations()['readonly'] ?? false) === true; + } + + private static function summary(DiviLocked $lock): string + { + $parts = array_map( + static fn (DiviFeature $feature): string => sprintf( + '%s %s', + $feature->label(), + $lock->status->matrix->of($feature)->state === ReleaseState::Incompatible + ? 'inkompatibel' + : 'nicht freigegeben' + ), + $lock->features + ); + + return sprintf('Abgelehnt: %s – %s.', $lock->status->versionLabel(), implode(', ', $parts)); + } +} +``` + +`DiviToolGate` löschen und überall ersetzen (Diff gegen Task-1-Stand; `Plugin.php` und der Docblock von `AbilityRegistrar`, `DiviContentComposer::refusal()`): + +```bash +git rm src/Integrations/Divi/Release/DiviToolGate.php +``` + +```diff +diff --git a/src/Abilities/AbilityRegistrar.php b/src/Abilities/AbilityRegistrar.php +index e94b014..e867337 100644 +--- a/src/Abilities/AbilityRegistrar.php ++++ b/src/Abilities/AbilityRegistrar.php +@@ -15,7 +15,7 @@ defined('ABSPATH') || exit; + + /** + * Registers all abilities of available integrations with the Abilities API and wraps them +- * with the role gate, the Divi release lock (DiviToolGate) and neutral error handling. ++ * with the role gate, the guards it is given (the Divi release lock DiviFeatureGate) and neutral error handling. + */ + final class AbilityRegistrar + { +diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php +index aac0373..13b7d67 100644 +--- a/src/Integrations/Divi/Pages/DiviContentComposer.php ++++ b/src/Integrations/Divi/Pages/DiviContentComposer.php +@@ -11,7 +11,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Outline; + use LIT\MCP\Integrations\Divi\BlockTree\Serializer; + use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; + use LIT\MCP\Integrations\Divi\Release\DiviFeature; +-use LIT\MCP\Integrations\Divi\Release\ReleaseGate; ++use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate; + use LIT\MCP\Integrations\Divi\Schema\DiviModules; + use LIT\MCP\Integrations\WordPress\Content\ContentComposer; + use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +@@ -81,9 +81,7 @@ final class DiviContentComposer implements ContentComposer + + public function refusal(): ?WP_Error + { +- $status = ReleaseGate::status(); +- +- return $status->writable() ? null : $status->refusal(); ++ return DiviFeatureGate::lockFor(self::FEATURES)?->toError(); + } + + public function check(string $postType, array $sections): ?WP_Error +diff --git a/src/Plugin.php b/src/Plugin.php +index 7df70a5..ea8e69a 100644 +--- a/src/Plugin.php ++++ b/src/Plugin.php +@@ -28,7 +28,7 @@ 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\DiviFeatureGate; + use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; + use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess; + use LIT\MCP\Integrations\IntegrationRegistry; +@@ -144,7 +144,7 @@ final class Plugin + DiviCheckCommand::register($diviCheck); + + $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); +- $guards = [new DiviToolGate($registry->groupMap(...), $changeLog)]; ++ $guards = [new DiviFeatureGate($changeLog)]; + (new AbilityRegistrar($registry, $policy, $guards))->register(); + (new ServerRegistrar( + $registry, +``` + +- [ ] **Step 5: GREEN, Lint, Reste suchen** + +```bash +vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php +vendor/bin/phpunit tests/Unit/Abilities/AbilityRegistrarTest.php +vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +composer test +composer lint +grep -rn "DiviToolGate" src tests +``` + +Erwartet: alle drei Dateien OK (17 / 21 / 9 Tests), `composer test` grün (in der Kopie 2690 Tests), `composer lint` ohne Meldung, das `grep` findet nichts mehr unter `src` und `tests`. + +- [ ] **Step 6: Commit** + +```bash +git add src/Integrations/Divi/Release/DiviFeatureGate.php src/Integrations/Divi/Release/DiviToolGate.php \ + src/Plugin.php src/Integrations/Divi/Pages/DiviContentComposer.php src/Abilities/AbilityRegistrar.php \ + tests/Support/DiviFeatureFixtures.php \ + tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php \ + tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php \ + tests/Unit/Abilities/AbilityRegistrarTest.php tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +git commit -m "feat: DiviFeatureGate ersetzt DiviToolGate – Ablehnung je Fähigkeit für Lese- und Schreib-Tools, Details nach D6, kein Hinweis mehr in warnings; DiviContentComposer::refusal prüft seine Fähigkeiten" -m "Co-Authored-By: Claude " +``` + +(Falls Task 3 die Gate-Verdrahtung nach `DiviIntegration` verlagert hat, diese Datei zusätzlich stagen.) + +## Offene Punkte + +1. **Task 4 muss die Adapter so tagen, wie die Tabelle es voraussetzt** (sonst sperrt die Gate andere Tools als die Adapter): `DiviPages`: `setFlags`, `clearPageCss`/`remove_static_resources`, Mirror `vb-sync-flags` → `page-write` (`remove_static_resources` zusätzlich `tb-write`, D2); `et_builder_get_public_post_types` → `page-read`. `DiviLibraryStore`: `is_global_layout_template` → `library-read`; `create` und Mirror `library-submit-layout` → `library-write`. `DiviRoles`: `et_pb_is_allowed` → `roles`. `DiviThemeBuilder`: die Lese-Calls (`get_theme_builder_post_id` ohne `$create`, `template_ids`, `get_template`, `flat_template_settings`) → `tb-read`; `insert_layout`, `store_template`, `clear_wp_cache`, `et_`, die drei Mirrors und `liveId(create: true)` → `tb-write`. `DesignSystemReader` → `design-read`; `DesignSystemWriter`: `get_data`, `get_preset_attrs_mapping`, `get_preset_attrs`, `get_group_preset_default_attr`, `get_module_settings`, `can_current_user_use_visual_builder` → `design-read`; `prepare_data`, `validate_preset_count`, `get_deleted_preset_ids`, `remove_preset_from_legacy_option`, `save_data` und Mirror `preset-sync` → `preset-write`. `DiviModules` → `catalog`; `DynamicContentMap` → `dynamic-sources`. Task 7 muss die `assertFeature`-Stellen so setzen (Abgleich siehe Task 8). +2. **Task 6/7:** `ReleaseStatus::__construct` muss den Parameter unter dem Namen `matrix` annehmen (Named Argument in `DiviFeatureFixtures`; die Kopie benutzt `?CompatibilityMatrix $matrix = null` als letzten Parameter). Weicht Task 6 ab, nur `DiviFeatureFixtures::useStates()` anpassen. `DiviLocked::toError()` muss bei nichtleerer Fähigkeitenliste `features` (`id`, `label`, `state`, `problems`) liefern, `divi_incompatible` bei mindestens einer inkompatiblen Fähigkeit, und einen Hinweis, der die Labels nennt und „weiter“ enthält (D6; `DiviFeatureGateTest` prüft genau das). `ReleaseGate::feature()` darf für unbekannte Fähigkeiten nie „nutzbar“ liefern. +3. **Task 10 (Anzeige „betroffene Tools“):** `FeatureSets::declaredBy($ability, [])` liefert die Grundmenge je Tool (für `lit/content-create` zusätzlich mit `['divi' => []]`). Eingabeabhängige Zusätze (Bibliotheks-/TB-Ziele, `dry_run: false`, `nodes`, `from_node`, `id`) erscheinen darin nicht – die Anzeige sollte „je nach Eingabe weitere“ sagen oder die Grundmenge als Mindestmenge kennzeichnen. +4. **Restlicher Typ-Hinweis (P12):** `FeatureSets::ofEntry()` nennt Bibliotheks-/TB-Zusätze nur für Einträge, die der Aufrufer bearbeiten darf. Ist die Gruppe „Divi-Bibliothek“ abgeschaltet (Ruling P9), verrät eine laufende Sperre von `library-write` einem bearbeitungsberechtigten Aufrufer, dass die ID ein Bibliothekseintrag ist (statt „nicht gefunden“). Das gilt nur während einer solchen Sperre und nur für Divi-Werkzeuge mit `post_id`; wer es schließen will, braucht die Tools-Tab-Zustände in `FeatureSets` (eigener Schritt). +5. **`dynamic-sources` ist eine Obermenge** (E8-4): Ein Bruch von `DynamicContentOptions::get_options` sperrt alle Tools mit Composer. Eine Verfeinerung nach dem Inhalt der Eingabe (enthält sie `{{post:…}}`/`$variable({"type":"content"…`?) ist möglich, wurde aber nicht gebaut. +6. **`lit/library-make-global`** meldet für beide Modi dieselbe Menge (Modus 2 erreicht `library-write` nie); eine Aufteilung nach `library_id` wäre eine Zeile Code, wurde bewusst gespart. +7. **Task 11:** Guides/Entscheidungsnotiz sollten das Verhalten beschreiben: Lese-Tools werden bei gesperrter Fähigkeit abgelehnt (E2), das Protokoll enthält nur Ablehnungen schreibender Tools (E9-1), `warnings` im Schema bleibt (E9-2). + +## Verifiziert + +- Aufgebaut in einer Kopie des Repos (`rsync` inkl. `vendor`, Stand e7793fd) mit minimal vorweggenommenen Teilen aus Task 1 (`AbilityGuard`, `ToolRefusal`, `DiviToolGate implements AbilityGuard`, Registrar mit `$guards`), Task 4 (`DiviFeature`), Task 6/7 (`FeatureStatus`, `CompatibilityMatrix`, `ReleaseStatus::$matrix`, `ReleaseGate::feature()`, `DiviLocked` mit Fähigkeiten und D6-Fehler). Task 8 und 9 dort ausgeführt: `composer test` **2690 Tests OK** (Ausgangsstand 2624), `composer lint` sauber; keine Zeile über 120 Zeichen in den neuen Dateien. +- RED für beide Tasks gegen den Stand vor der Umsetzung gelaufen; die Meldungen oben sind die wirklich ausgegebenen. +- Die Tabelle erzwingt sich selbst: eine entfernte Fähigkeit in `DiviMoveAbility` ließ `DiviToolFeaturesTest` mit der erwarteten Differenz scheitern (zurückgenommen). +- Alle 29 Tools stammen aus den echten Integrationen (`DiviIntegration`, `->library()`, `->themeBuilder()`, `WordPressIntegration`); `DiviBoundaryTest` und die übrigen Architekturtests blieben grün (der Kern importiert kein Divi: `lit/content-create` kennt nur `DeclaresRequirements` und `ContentComposer`). +- Die Kopie wurde nach dem Schreiben dieses Teils gelöscht; im Repo wurde nichts geändert. + +--- + +# Plan-Teil: Tasks 10 und 11 (Anzeige je Fähigkeit; Guides, Notiz, Smoke) + +> Teil von `docs/superpowers/plans/2026-10-18-divi-faehigkeiten.md` (Gerüst: Entscheidungen E1–E4, D1–D7, verbindliche Signaturen). Einzufügen an der Stelle `` nach Task 9. Stilvorlage: `2026-10-18-etappe-4b-zuweisen.md`, Task 11. + +## Signatur-Abweichungen und Annahmen (zuerst lesen) + +**Neue Signaturen dieser Tasks (nichts Verbindliches aus dem Gerüst geändert):** + +- `final class FeatureOverview` (`src/Integrations/Divi/Release/FeatureOverview.php`): `__construct(Closure $abilities)` (`Closure(): list`), `static withoutTools(): self`, `toolsByFeature(): array>`, `rows(CompatibilityMatrix): list`. Eine Quelle für `lit-site-status` und Admin-Tab. +- `DiviEnvironmentComponent::__construct(?DiviCompatibility $divi = null, ?FeatureOverview $overview = null)`; `describe()` liefert zusätzlich `features` (für ein nicht aktives Divi `[]`). `SiteStatusAbility::outputSchema()` beschreibt `components[].features`. +- `DiviIntegration::__construct(…, ?Closure $allAbilities = null)` als **letzter** Parameter (`Closure(): list`, Plugin übergibt `IntegrationRegistry::abilities()`) und `DiviIntegration::featureOverview(): FeatureOverview`. +- `DiviTab::__construct(…, ?FeatureOverview $overview = null)` als letzter Parameter; neu `registerNotice(): void` und `renderNotice(): void` (Admin-Hinweis liegt in `DiviTab`, weil nur `Admin/DiviTab.php` Divi importieren darf, `CoreIndependenceTest`). +- `DiviCheckCommand::__construct(…, ?Closure $matrix = null)` als letzter Parameter (`Closure(): CompatibilityMatrix`; `null` = frisch über `ReleaseGate`). **Exit-Code 2** neu (siehe Task 10, Schritt 5). +- `ReleaseStatus::notice()` und `NOTICE` entfallen (nach Task 9 ohne Aufrufer); die Texte von `refusal()` werden je Fähigkeit formuliert. +- Test-Hilfen: `tests/Support/FeatureFakeTool.php` (Ability + `RequiresDiviFeatures`) und `tests/Support/MatrixFixtures.php` (`released(source, replace)`, `with(feature, state, source, ...problems)`). + +**Annahmen über andere Tasks (vor Beginn prüfen, Schritt 0):** + +1. **Task 6:** `ReleaseStatus` hat `public readonly CompatibilityMatrix $matrix` und der Konstruktor einen benannten Parameter `matrix:`; `ReleaseStatus::released()` liefert `allReleased('code')`. `CompatibilityMatrix::toArray()` gibt `list}>` zurück (so verlangt es das Gerüst für `lit-site-status` und den Tab). Der State-Cache hat die Form `['key' => stateKey, 'features' => [ => list]]` (D7) – **nur das Smoke hängt daran** (`simulate_problem`, bricht mit klarer Meldung ab, wenn die Form anders ist). +2. **Task 2/3:** `DiviEnvironmentComponent` (Task 2) hat die Form, die Schritt 2 unten komplett ersetzt (`describe()` mit `id`, `active`, `version`, `problems`, `release`); wo Task 2/3 es und `DiviTab` erzeugen, wird `$divi->featureOverview()` durchgereicht (Schritt 7). +3. **Task 8:** `diviFeatures([])` ist für jedes Tool ohne Seiteneffekte aufrufbar (reine Tabelle); `lit/divi-presets-assign` enthält `PresetWrite` (Smoke erwartet `lit/divi-presets-…` unter `preset-write`). +4. **Task 9:** `DiviToolGate` samt Test ist gelöscht, `ReleaseStatus::notice()` hat keinen Aufrufer; die Ablehnung nennt die Fähigkeit in `message` oder `hint` (Smoke: „Seiten schreiben“) und trägt `features` (D6). +5. **Task 4:** `DiviFeature::isWrite()`/`label()` benutzen `match ($this)`/`$this` in einem Enum. `composer lint` (PHPCompatibility) meldet das als `ForbiddenThisUseContexts.OutsideObjectContext`; Task 4 muss `// phpcs:ignore PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext -- enum method` setzen (im Probe-Aufbau dieses Teils trat der Fehler bei einem Enum-Stub auf; die bestehenden Enums des Repos benutzen `$this` nicht). + +--- + +### Task 10: Anzeige je Fähigkeit (lit-site-status, Admin-Tab, Admin-Hinweis, divi-check, Texte) + +**Files:** +- Create: `src/Integrations/Divi/Release/FeatureOverview.php` +- Create: `tests/Support/FeatureFakeTool.php`, `tests/Support/MatrixFixtures.php` +- Create: `tests/Unit/Integrations/Divi/Release/FeatureOverviewTest.php` +- Modify (ersetzen): `src/Integrations/Divi/DiviEnvironmentComponent.php` (Task 2) +- Create/Modify: `tests/Unit/Integrations/Divi/DiviEnvironmentComponentTest.php` (Task 2 hat ihn evtl. angelegt – dann die drei Tests ergänzen) +- Modify: `src/Integrations/Core/SiteStatusAbility.php`, `tests/Unit/Integrations/Core/SiteStatusAbilityTest.php` +- Modify: `src/Integrations/Divi/DiviIntegration.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` +- Modify: `src/Admin/DiviTab.php`, `tests/Unit/Admin/DiviTabTest.php` +- Modify: `src/Cli/DiviCheckCommand.php`, `tests/Unit/Cli/DiviCheckCommandTest.php` +- Modify: `src/Integrations/Divi/Release/ReleaseStatus.php`, `tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php` +- Modify: `src/Plugin.php` (Verdrahtung) + +**Interfaces:** +- Consumes: `CompatibilityMatrix` (`toArray()`, `unusable()`), `FeatureStatus` (`feature`, `usable()`), `DiviFeature::label()`, `ReleaseGate::status()->matrix`, `ReleaseGate::forget()`, `ReleaseResolver::CACHE`, `RequiresDiviFeatures::diviFeatures()` (Task 8), `IntegrationRegistry::abilities()`, `SettingsPage::url()`/`SCREEN_HOOK`, `EnvironmentComponent` (Task 2). +- Produces: siehe „Neue Signaturen“ oben; Ausgabeform `components[divi].features: list<{id, label, state, source, problems, tools}>`. +- D7: nichts in diesem Task berechnet Mirrors neu, außer `wp lit-mcp divi-check` (CLI, bewusst frisch). + +- [ ] **Step 0: Vorbedingungen prüfen** + +Run: + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +grep -n "matrix" src/Integrations/Divi/Release/ReleaseStatus.php +grep -rn "class DiviEnvironmentComponent\|new DiviEnvironmentComponent\|new DiviTab" src +grep -rn "notice()" src +ls src/Integrations/Divi/Release/DiviToolGate.php 2>&1 | tail -1 +php -r 'require "vendor/autoload.php"; var_dump(method_exists(LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix::class, "unusable"));' +``` + +Expected: `ReleaseStatus` hat `$matrix`; `DiviEnvironmentComponent` (Task 2) und `new DiviTab` (`Plugin.php` oder Task-3-`boot()`) gefunden; `notice()` nur noch in `ReleaseStatus.php` selbst; `DiviToolGate.php` nicht vorhanden; `bool(true)`. Weicht etwas ab, die Annahmen oben korrigieren, bevor weitergemacht wird. + +- [ ] **Step 1: Hilfsklassen und `FeatureOverview` (RED/GREEN)** + +`tests/Support/FeatureFakeTool.php`: + +```php + $features */ + public function __construct( + private string $name, + private bool $direct, + private array $features + ) { + } + + public function name(): string + { + return $this->name; + } + + public function label(): string + { + return $this->name; + } + + public function description(): string + { + return ''; + } + + public function inputSchema(): array + { + return ['type' => 'object']; + } + + public function outputSchema(): ?array + { + return null; + } + + public function annotations(): array + { + return []; + } + + public function isDirect(): bool + { + return $this->direct; + } + + public function permission(array $input): bool|WP_Error + { + return true; + } + + public function execute(array $input): mixed + { + return []; + } + + public function diviFeatures(array $input): array + { + return $this->features; + } +} +``` + +`tests/Support/MatrixFixtures.php`: + +```php + $replace Statuses that replace the released one of their feature. + */ + public static function released(string $source = 'code', array $replace = []): CompatibilityMatrix + { + $features = []; + foreach (DiviFeature::cases() as $feature) { + $features[$feature->value] = new FeatureStatus($feature, ReleaseState::Released, [], $source); + } + foreach ($replace as $status) { + $features[$status->feature->value] = $status; + } + + return new CompatibilityMatrix($features); + } + + /** @param string ...$problems */ + public static function with( + DiviFeature $feature, + ReleaseState $state, + string $source = 'code', + string ...$problems + ): CompatibilityMatrix { + return self::released($source, [new FeatureStatus($feature, $state, $problems, null)]); + } +} +``` + +`tests/Unit/Integrations/Divi/Release/FeatureOverviewTest.php`: + +```php + [ + new FeatureFakeTool('lit/divi-outline', true, [DiviFeature::PageRead]), + new FeatureFakeTool('lit/divi-insert', true, [DiviFeature::PageRead, DiviFeature::PageWrite]), + new FeatureFakeTool('lit/divi-presets-assign', false, [DiviFeature::PresetWrite]), + new FeatureFakeTool('lit/content-create', true, []), + ]); + } + + public function testToolsAreNamedAsTheClientSeesThem(): void + { + $tools = self::overview()->toolsByFeature(); + + self::assertSame(['lit-divi-outline', 'lit-divi-insert'], $tools['page-read']); + self::assertSame(['lit-divi-insert'], $tools['page-write']); + self::assertSame(['lit/divi-presets-assign'], $tools['preset-write']); + } + + public function testAToolWithoutFeaturesIsLeftOut(): void + { + $overview = new FeatureOverview(static fn (): array => [new FeatureFakeTool('lit/content-create', true, [])]); + + self::assertSame([], $overview->toolsByFeature()); + } + + public function testRowsCarryTheMatrixAndTheToolsOfEachFeature(): void + { + $matrix = MatrixFixtures::with( + DiviFeature::PageWrite, + ReleaseState::Incompatible, + 'code', + 'Seiten: et_pb_x() fehlt' + ); + + $rows = array_column(self::overview()->rows($matrix), null, 'id'); + + self::assertCount(count(DiviFeature::cases()), $rows); + self::assertSame( + [ + 'id' => 'page-write', + 'label' => 'Seiten schreiben', + 'state' => 'incompatible', + 'source' => null, + 'problems' => ['Seiten: et_pb_x() fehlt'], + 'tools' => ['lit-divi-insert'], + ], + $rows['page-write'] + ); + self::assertSame('released', $rows['page-read']['state']); + self::assertSame('code', $rows['page-read']['source']); + self::assertSame([], $rows['catalog']['tools']); + } + + public function testWithoutToolsEveryRowHasAnEmptyToolList(): void + { + foreach (FeatureOverview::withoutTools()->rows(MatrixFixtures::released()) as $row) { + self::assertSame([], $row['tools']); + } + } +} +``` + +Run: `vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/FeatureOverviewTest.php` +Expected (RED): `Error: Class "LIT\MCP\Integrations\Divi\Release\FeatureOverview" not found`. + +`src/Integrations/Divi/Release/FeatureOverview.php`: + +```php + $abilities All registered abilities (IntegrationRegistry::abilities()). */ + public function __construct(private Closure $abilities) + { + } + + /** An overview that knows no tools (tests, and a Divi tab built without a registry). */ + public static function withoutTools(): self + { + return new self(static fn (): array => []); + } + + /** + * Tools whose diviFeatures([]) contain the feature, named as the MCP client sees them: direct tools + * lit-, indirect ones lit/. A tool that needs a feature only for certain input (content-create + * with divi) is not listed – its refusal names the feature. + * + * @return array> Feature ID => tool names. + */ + public function toolsByFeature(): array + { + $map = []; + foreach (($this->abilities)() as $ability) { + if (! $ability instanceof RequiresDiviFeatures) { + continue; + } + foreach ($ability->diviFeatures([]) as $feature) { + $map[$feature->value][] = $ability->isDirect() + ? str_replace('/', '-', $ability->name()) + : $ability->name(); + } + } + + return $map; + } + + /** + * @return list, + * tools: list}> + */ + public function rows(CompatibilityMatrix $matrix): array + { + $tools = $this->toolsByFeature(); + + return array_map( + static fn (array $row): array => $row + ['tools' => $tools[$row['id']] ?? []], + $matrix->toArray() + ); + } +} +``` + +Run: `vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/FeatureOverviewTest.php` +Expected (GREEN): `OK (4 tests, 20 assertions)`. + +- [ ] **Step 2: `lit-site-status` (E3, D7) – Komponente und Ausgabeschema** + +`tests/Unit/Integrations/Divi/DiviEnvironmentComponentTest.php` (vollständig; existiert die Datei schon aus Task 2, die drei Tests und `diviInstalled()` übernehmen): + +```php +allows('get_template')->andReturn($version === null ? 'twentytwentyfive' : 'Divi'); + $theme->allows('parent')->andReturn(false); + $theme->allows('get')->with('Version')->andReturn($version ?? '1.0'); + Functions\when('wp_get_theme')->justReturn($theme); + } + + public function testListsEveryFeatureWithStateSourceProblemsAndTools(): void + { + $this->diviInstalled('5.3.0'); + $matrix = MatrixFixtures::with( + DiviFeature::PageWrite, + ReleaseState::Incompatible, + 'auto', + 'Seiten: Signatur von et_pb_x() geändert' + ); + ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus( + ReleaseState::Incompatible, + '5.3.0', + null, + [], + null, + matrix: $matrix + )); + $overview = new FeatureOverview(static fn (): array => [ + new FeatureFakeTool('lit/divi-outline', true, [DiviFeature::PageRead]), + new FeatureFakeTool('lit/divi-insert', true, [DiviFeature::PageRead, DiviFeature::PageWrite]), + ]); + + $component = (new DiviEnvironmentComponent(new DiviCompatibility([]), $overview))->describe(); + $features = array_column($component['features'], null, 'id'); + + self::assertSame('divi', $component['id']); + self::assertCount(count(DiviFeature::cases()), $features); + self::assertSame('incompatible', $features['page-write']['state']); + self::assertNull($features['page-write']['source']); + self::assertSame(['Seiten: Signatur von et_pb_x() geändert'], $features['page-write']['problems']); + self::assertSame(['lit-divi-insert'], $features['page-write']['tools']); + self::assertSame('Seiten schreiben', $features['page-write']['label']); + self::assertSame('released', $features['page-read']['state']); + self::assertSame('auto', $features['page-read']['source']); + self::assertSame(['lit-divi-outline', 'lit-divi-insert'], $features['page-read']['tools']); + self::assertSame('incompatible', $component['release']['state']); + } + + public function testTheMatrixComesFromTheGateAndIsNotComputedAgain(): void + { + $this->diviInstalled('5.3.0'); + $calls = 0; + ReleaseGate::useResolver(static function () use (&$calls): ReleaseStatus { + ++$calls; + + return ReleaseStatus::released('5.3.0'); + }); + $component = new DiviEnvironmentComponent(new DiviCompatibility([])); + + $component->describe(); + $component->describe(); + + self::assertSame(1, $calls); + } + + public function testWithoutDiviThereAreNoFeaturesAndNoGateCall(): void + { + $this->diviInstalled(null); + ReleaseGate::useResolver(static function (): ReleaseStatus { + self::fail('the gate must not be asked without Divi'); + }); + + $component = (new DiviEnvironmentComponent(new DiviCompatibility([])))->describe(); + + self::assertFalse($component['active']); + self::assertSame([], $component['features']); + self::assertNull($component['release']); + } +} +``` + +Run: `vendor/bin/phpunit tests/Unit/Integrations/Divi/DiviEnvironmentComponentTest.php` +Expected (RED): `Undefined array key "features"` (in `testListsEveryFeatureWithStateSourceProblemsAndTools`; die beiden anderen Tests scheitern ebenfalls an der fehlenden Matrix bzw. am fehlenden zweiten Konstruktorargument). + +`src/Integrations/Divi/DiviEnvironmentComponent.php` komplett ersetzen: + +```php +overview = $overview ?? FeatureOverview::withoutTools(); + } + + /** + * @return array{id: string, active: bool, version: ?string, problems: list, release: ?array, + * features: list, + * tools: list}>} + */ + public function describe(): array + { + $version = DiviEnvironment::version(); + if ($version === null) { + return ['id' => 'divi', 'active' => false, 'version' => null, 'problems' => [], 'release' => null, + 'features' => []]; + } + + $status = ReleaseGate::status(); + + return [ + 'id' => 'divi', + 'active' => true, + 'version' => $version, + 'problems' => ($this->divi ?? new DiviCompatibility())->problems(), + 'release' => $status->toArray(), + 'features' => $this->overview->rows($status->matrix), + ]; + } +} +``` + +`SiteStatusAbility`: Tests (am Ende der Klasse ergänzen) und Code. Änderungen am Test: + +```diff +diff --git a/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php b/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php +--- a/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php ++++ b/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php +@@ -35,2 +35,26 @@ final class SiteStatusAbilityTest extends TestCase + } ++ ++ public function testTheOutputSchemaDescribesTheDiviFeatures(): void ++ { ++ $schema = (new SiteStatusAbility(Mockery::mock(EnvironmentProbe::class), new Access()))->outputSchema(); ++ $component = $schema['properties']['components']['items']; ++ $feature = $component['properties']['features']['items']; ++ ++ self::assertSame(['id', 'active', 'version', 'problems', 'release'], $component['required']); ++ self::assertSame( ++ ['id', 'label', 'state', 'source', 'problems', 'tools'], ++ $feature['required'] ++ ); ++ self::assertSame(['released', 'untested', 'incompatible'], $feature['properties']['state']['enum']); ++ self::assertSame(['string', 'null'], $feature['properties']['source']['type']); ++ } ++ ++ public function testTheDescriptionNamesTheFeaturesAndSaysOtherToolsKeepWorking(): void ++ { ++ $description = (new SiteStatusAbility(Mockery::mock(EnvironmentProbe::class), new Access()))->description(); ++ ++ self::assertStringContainsString('features je Fähigkeit', $description); ++ self::assertStringContainsString('(code, database, auto)', $description); ++ self::assertStringContainsString('alle anderen Divi-Werkzeuge laufen weiter', $description); ++ } + } +``` + +Run: `vendor/bin/phpunit tests/Unit/Integrations/Core/SiteStatusAbilityTest.php` +Expected (RED): `Undefined array key "items"` bzw. `'features je Fähigkeit' … does not contain`. + +Änderungen an `src/Integrations/Core/SiteStatusAbility.php`: + +```diff +diff --git a/src/Integrations/Core/SiteStatusAbility.php b/src/Integrations/Core/SiteStatusAbility.php +--- a/src/Integrations/Core/SiteStatusAbility.php ++++ b/src/Integrations/Core/SiteStatusAbility.php +@@ -32,6 +32,8 @@ final class SiteStatusAbility implements Ability + return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, ' + . 'erkannte Integrationen (Divi, Pods, The Events Calendar). ' +- . '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).'; ++ . 'Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, ' ++ . 'release den Gesamtzustand und features je Fähigkeit (z. B. Seiten schreiben) Zustand ' ++ . '(released, untested, incompatible), Quelle (code, database, auto), Probleme und betroffene Werkzeuge. ' ++ . 'Werkzeuge, deren Fähigkeit nicht released ist, lehnen ab; alle anderen Divi-Werkzeuge laufen weiter.'; + } + +@@ -50,5 +52,5 @@ final class SiteStatusAbility implements Ability + 'user' => ['type' => 'object'], + 'allowed_roles' => ['type' => 'array', 'items' => ['type' => 'string']], +- 'components' => ['type' => 'array'], ++ 'components' => ['type' => 'array', 'items' => self::componentSchema()], + ], + 'required' => ['site', 'wordpress', 'user', 'allowed_roles', 'components'], +@@ -56,4 +58,41 @@ final class SiteStatusAbility implements Ability + } + ++ /** @return array */ ++ private static function componentSchema(): array ++ { ++ $text = ['type' => 'string']; ++ ++ return [ ++ 'type' => 'object', ++ 'properties' => [ ++ 'id' => $text, ++ 'active' => ['type' => 'boolean'], ++ 'version' => ['type' => ['string', 'null']], ++ 'problems' => ['type' => 'array', 'items' => $text], ++ 'release' => ['type' => ['object', 'null']], ++ 'features' => [ ++ 'type' => 'array', ++ 'description' => 'Nur bei Divi: Zustand je Fähigkeit.', ++ 'items' => [ ++ 'type' => 'object', ++ 'properties' => [ ++ 'id' => $text, ++ 'label' => $text, ++ 'state' => ['type' => 'string', 'enum' => ['released', 'untested', 'incompatible']], ++ 'source' => [ ++ 'type' => ['string', 'null'], ++ 'description' => 'code, database, auto oder null (nicht nutzbar).', ++ ], ++ 'problems' => ['type' => 'array', 'items' => $text], ++ 'tools' => ['type' => 'array', 'items' => $text], ++ ], ++ 'required' => ['id', 'label', 'state', 'source', 'problems', 'tools'], ++ ], ++ ], ++ ], ++ 'required' => ['id', 'active', 'version', 'problems', 'release'], ++ ]; ++ } ++ + public function annotations(): array + { +``` + +Run: `vendor/bin/phpunit tests/Unit/Integrations/Divi tests/Unit/Integrations/Core` +Expected (GREEN): alle grün. + +- [ ] **Step 3: `DiviIntegration` kennt die Tools aller Integrationen** + +Änderungen an `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`: + +```diff +diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +--- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php ++++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +@@ -9,2 +9,3 @@ use LIT\MCP\Abilities\Ability; + use LIT\MCP\Integrations\Divi\DiviIntegration; ++use LIT\MCP\Integrations\Divi\Release\DiviFeature; + use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; +@@ -12,2 +13,3 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; + use LIT\MCP\Tests\Support\FakeChangeLog; ++use LIT\MCP\Tests\Support\FeatureFakeTool; + use LIT\MCP\Tests\Support\LibraryFixtures; +@@ -210,2 +212,26 @@ final class DiviIntegrationTest extends TestCase + } ++ ++ public function testTheFeatureOverviewFindsTheToolsOfAllIntegrations(): void ++ { ++ $integration = new DiviIntegration( ++ Mockery::mock(ContentSchema::class), ++ FakeChangeLog::forbidden(), ++ allAbilities: static fn (): array => [ ++ new FeatureFakeTool('lit/divi-insert', true, [DiviFeature::PageWrite]), ++ new FeatureFakeTool('lit/library-insert', true, [DiviFeature::PageWrite, DiviFeature::LibraryRead]), ++ ] ++ ); ++ ++ self::assertSame( ++ ['page-write' => ['lit-divi-insert', 'lit-library-insert'], 'library-read' => ['lit-library-insert']], ++ $integration->featureOverview()->toolsByFeature() ++ ); ++ } ++ ++ public function testWithoutAnAbilityListTheOverviewKnowsNoTools(): void ++ { ++ $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()); ++ ++ self::assertSame([], $integration->featureOverview()->toolsByFeature()); ++ } + } +``` + +Run: `vendor/bin/phpunit tests/Unit/Integrations/Divi/DiviIntegrationTest.php` +Expected (RED): `Unknown named parameter $allAbilities`. + +Änderungen an `src/Integrations/Divi/DiviIntegration.php`: + +```diff +diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php +--- a/src/Integrations/Divi/DiviIntegration.php ++++ b/src/Integrations/Divi/DiviIntegration.php +@@ -48,4 +48,5 @@ use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; + use LIT\MCP\Integrations\Divi\Pages\DiviWriter; + use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; ++use LIT\MCP\Integrations\Divi\Release\FeatureOverview; + use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; + use LIT\MCP\Integrations\Divi\Schema\DiviModules; +@@ -87,4 +88,6 @@ final class DiviIntegration implements Integration + * @param (Closure(): bool)|null $themeBuilderVisible Tools-tab gate for Theme Builder layouts in the Divi + * tools at all (group "Divi Theme Builder" not disabled); null = always. ++ * @param (Closure(): list)|null $allAbilities The abilities of every integration (Plugin passes ++ * IntegrationRegistry::abilities()); the tools a Divi feature affects are found among them (E3). + */ + public function __construct( +@@ -96,5 +99,6 @@ final class DiviIntegration implements Integration + private ?Closure $libraryInsertable = null, + ?Closure $themeBuilderWritable = null, +- ?Closure $themeBuilderVisible = null ++ ?Closure $themeBuilderVisible = null, ++ private ?Closure $allAbilities = null + ) { + $this->modules = new DiviModules(); +@@ -132,4 +136,10 @@ final class DiviIntegration implements Integration + } + ++ /** Which tools each Divi feature affects – for lit-site-status and the Divi tab (E3). */ ++ public function featureOverview(): FeatureOverview ++ { ++ return new FeatureOverview($this->allAbilities ?? static fn (): array => []); ++ } ++ + /** + * Divi short form for lit/content-create; available() reports whether Divi 5 is loaded, +``` + +Run: `vendor/bin/phpunit tests/Unit/Integrations/Divi/DiviIntegrationTest.php` +Expected (GREEN): `OK`. + +- [ ] **Step 4: Admin-Tab „Divi“ und Admin-Hinweis (E1, E3)** + +Die Tests ändern `tab()`/`rendered()` (neuer optionaler Parameter) und ergänzen zwölf Tests (Tabelle, Escaping, Quelle in Worten, Automatik-Text mit Filter, keine pauschale „gesperrt“-Aussage, sieben Hinweis-Fälle). Änderungen an `tests/Unit/Admin/DiviTabTest.php`: + +```diff +diff --git a/tests/Unit/Admin/DiviTabTest.php b/tests/Unit/Admin/DiviTabTest.php +--- a/tests/Unit/Admin/DiviTabTest.php ++++ b/tests/Unit/Admin/DiviTabTest.php +@@ -12,2 +12,5 @@ use LIT\MCP\Integrations\Divi\Release\CheckReportStore; + use LIT\MCP\Integrations\Divi\Release\CheckStep; ++use LIT\MCP\Integrations\Divi\Release\DiviFeature; ++use LIT\MCP\Integrations\Divi\Release\FeatureOverview; ++use LIT\MCP\Integrations\Divi\Release\FeatureStatus; + use LIT\MCP\Integrations\Divi\Release\ReleaseGate; +@@ -18,2 +21,4 @@ use LIT\MCP\Plugin; + use LIT\MCP\Tests\Support\FakeChangeLog; ++use LIT\MCP\Tests\Support\FeatureFakeTool; ++use LIT\MCP\Tests\Support\MatrixFixtures; + use LIT\MCP\Tests\Unit\TestCase; +@@ -55,4 +60,8 @@ final class DiviTabTest extends TestCase + +- private function tab(?CheckReport $report, ReleaseStore $releases, ?FakeChangeLog $log = null): DiviTab +- { ++ private function tab( ++ ?CheckReport $report, ++ ReleaseStore $releases, ++ ?FakeChangeLog $log = null, ++ ?FeatureOverview $overview = null ++ ): DiviTab { + $reports = Mockery::mock(CheckReportStore::class); +@@ -67,3 +76,4 @@ final class DiviTabTest extends TestCase + static fn (): string => '5.3.0', +- static fn (string $version): string => 'key' ++ static fn (string $version): string => 'key', ++ $overview + ); +@@ -145,3 +155,3 @@ final class DiviTabTest extends TestCase + /** @return string The rendered tab. */ +- private function rendered(ReleaseStatus $status, ?CheckReport $report): string ++ private function rendered(ReleaseStatus $status, ?CheckReport $report, ?FeatureOverview $overview = null): string + { +@@ -160,3 +170,3 @@ final class DiviTabTest extends TestCase + ob_start(); +- $this->tab($report, Mockery::mock(ReleaseStore::class))->render(); ++ $this->tab($report, Mockery::mock(ReleaseStore::class), null, $overview)->render(); + +@@ -256,2 +266,166 @@ final class DiviTabTest extends TestCase + } ++ ++ private static function mixedStatus(): ReleaseStatus ++ { ++ $matrix = MatrixFixtures::released('auto', [ ++ new FeatureStatus( ++ DiviFeature::PageWrite, ++ ReleaseState::Incompatible, ++ ['Seiten: Signatur von et_pb_x() '], ++ null ++ ), ++ new FeatureStatus(DiviFeature::Catalog, ReleaseState::Untested, [], null), ++ ]); ++ ++ return new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, [], null, matrix: $matrix); ++ } ++ ++ public function testTheFeatureTableShowsStateSourceProblemsAndTools(): void ++ { ++ $overview = new FeatureOverview(static fn (): array => [ ++ new FeatureFakeTool('lit/divi-insert', true, [DiviFeature::PageRead, DiviFeature::PageWrite]), ++ new FeatureFakeTool('lit/divi-presets-assign', false, [DiviFeature::PresetWrite]), ++ ]); ++ ++ $html = $this->rendered(self::mixedStatus(), null, $overview); ++ ++ self::assertStringContainsString('Betroffene Werkzeuge', $html); ++ self::assertStringContainsString('Seiten schreibeninkompatibel–', $html); ++ self::assertStringContainsString('Modulkatalog lesenungetestet', $html); ++ self::assertStringContainsString('Seiten lesenfreigegebenAutomatik (auto)', $html); ++ self::assertStringContainsString('
  • lit-divi-insert
  • ', $html); ++ self::assertStringContainsString('
  • lit/divi-presets-assign
  • ', $html); ++ } ++ ++ public function testFeatureProblemsAreEscaped(): void ++ { ++ $html = $this->rendered(self::mixedStatus(), null); ++ ++ self::assertStringContainsString('Seiten: Signatur von et_pb_x() <geändert>', $html); ++ self::assertStringNotContainsString('', $html); ++ } ++ ++ public function testTheTableNamesEverySourceInWords(): void ++ { ++ $code = $this->rendered(ReleaseStatus::released('5.3.0'), null); ++ $database = $this->rendered( ++ new ReleaseStatus(ReleaseState::Released, '5.3.0', ReleaseStatus::SOURCE_DATABASE, [], [ ++ 'released_at' => '2026-10-01T08:00:00+00:00', 'user_id' => 1, ++ ], matrix: MatrixFixtures::released('database')), ++ self::report() ++ ); ++ ++ self::assertStringContainsString('Plugin (code)', $code); ++ self::assertStringContainsString('Freigabe auf dieser Website (database)', $database); ++ } ++ ++ public function testTheTabExplainsTheAutomationAndTheFilter(): void ++ { ++ $html = $this->rendered(ReleaseStatus::released('5.3.0'), null); ++ ++ self::assertStringContainsString('gibt LIT MCP jede Fähigkeit einzeln frei', $html); ++ self::assertStringContainsString('lit_mcp_divi_auto_release', $html); ++ self::assertStringContainsString('als ungetestet', $html); ++ } ++ ++ public function testTheTabNoLongerSaysWritingIsLockedAsAWhole(): void ++ { ++ $untested = $this->rendered( ++ new ReleaseStatus( ++ ReleaseState::Untested, ++ '5.3.0', ++ matrix: MatrixFixtures::with(DiviFeature::Catalog, ReleaseState::Untested) ++ ), ++ null ++ ); ++ $incompatible = $this->rendered(self::mixedStatus(), null); ++ ++ foreach ([$untested, $incompatible] as $html) { ++ self::assertStringNotContainsString('Schreiben bleibt gesperrt', $html); ++ self::assertStringNotContainsString('lesen nur', $html); ++ self::assertStringContainsString('alle anderen laufen weiter', $html); ++ } ++ } ++ ++ /** @return string The admin notice of this request. */ ++ private function notice(ReleaseStatus $status, string $version = '5.3.0'): string ++ { ++ ReleaseGate::useResolver(static fn (): ReleaseStatus => $status); ++ $tab = new DiviTab( ++ Mockery::mock(CheckReportStore::class), ++ Mockery::mock(ReleaseStore::class), ++ FakeChangeLog::forbidden(), ++ new AdminPostAction(static function (): void { ++ }), ++ static fn (): string => $version, ++ static fn (string $v): string => 'key' ++ ); ++ ++ ob_start(); ++ $tab->renderNotice(); ++ ++ return (string) ob_get_clean(); ++ } ++ ++ public function testTheNoticeNamesTheUnusableFeaturesAndLinksToTheTab(): void ++ { ++ $html = $this->notice(self::mixedStatus()); ++ ++ self::assertStringContainsString('class="notice notice-warning"', $html); ++ self::assertStringContainsString('Mit Divi 5.3.0 sind diese Fähigkeiten nicht nutzbar', $html); ++ self::assertStringContainsString('Seiten schreiben, Modulkatalog lesen', $html); ++ self::assertStringContainsString('alle anderen laufen weiter', $html); ++ self::assertStringContainsString('page=lit-mcp&tab=divi', $html); ++ } ++ ++ public function testNoNoticeWhileEveryFeatureIsUsable(): void ++ { ++ self::assertSame('', $this->notice(ReleaseStatus::released('5.3.0'))); ++ } ++ ++ public function testNoNoticeForUsersWithoutManageOptions(): void ++ { ++ Functions\when('current_user_can')->justReturn(false); ++ ++ self::assertSame('', $this->notice(self::mixedStatus())); ++ } ++ ++ public function testNoNoticeWithoutDivi(): void ++ { ++ self::assertSame('', $this->notice(self::mixedStatus(), '')); ++ } ++ ++ public function testNoNoticeOnTheDiviTabItself(): void ++ { ++ Functions\when('get_current_screen')->justReturn((object) ['id' => 'settings_page_lit-mcp']); ++ Functions\when('sanitize_key')->returnArg(); ++ $_GET['tab'] = 'divi'; ++ ++ try { ++ self::assertSame('', $this->notice(self::mixedStatus())); ++ } finally { ++ unset($_GET['tab']); ++ } ++ } ++ ++ public function testTheNoticeStillShowsOnOtherTabsOfThePluginPage(): void ++ { ++ Functions\when('get_current_screen')->justReturn((object) ['id' => 'settings_page_lit-mcp']); ++ Functions\when('sanitize_key')->returnArg(); ++ $_GET['tab'] = 'tools'; ++ ++ try { ++ self::assertStringContainsString('nicht nutzbar', $this->notice(self::mixedStatus())); ++ } finally { ++ unset($_GET['tab']); ++ } ++ } ++ ++ public function testTheNoticeIsRegisteredOnAdminNotices(): void ++ { ++ $tab = $this->tab(null, Mockery::mock(ReleaseStore::class)); ++ Functions\expect('add_action')->once()->with('admin_notices', [$tab, 'renderNotice']); ++ ++ $tab->registerNotice(); ++ } + } +``` + +Run: `vendor/bin/phpunit tests/Unit/Admin/DiviTabTest.php` +Expected (RED): `Call to undefined method LIT\MCP\Admin\DiviTab::renderNotice()` bzw. `Failed asserting that '…' contains 'Betroffene Werkzeuge'`. + +Änderungen an `src/Admin/DiviTab.php` (Freigabe-/Zurückziehen-Formulare, `release()`, `withdraw()` bleiben unverändert; Escaping: jede Ausgabe läuft über `esc_html`/`esc_url`, Tool- und Problemtexte über `renderList()`; der Filtername steht als Konstante im ``-Element, nicht aus Eingaben): + +```diff +diff --git a/src/Admin/DiviTab.php b/src/Admin/DiviTab.php +--- a/src/Admin/DiviTab.php ++++ b/src/Admin/DiviTab.php +@@ -11,4 +11,6 @@ use LIT\MCP\Integrations\Divi\Release\CheckReportStore; + use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; + use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; ++use LIT\MCP\Integrations\Divi\Release\FeatureOverview; ++use LIT\MCP\Integrations\Divi\Release\FeatureStatus; + use LIT\MCP\Integrations\Divi\Release\ReleaseGate; + use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; +@@ -22,7 +24,9 @@ defined('ABSPATH') || exit; + + /** +- * Tab "Divi" (spec §6): release status of the installed Divi, the latest check report, "Prüfung starten" (REST, +- * DiviCheckRoute), "Divi 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. ++ * Tab "Divi" (spec §6): release status of the installed Divi, the table of its features (state, source, problems, ++ * affected tools – E3), the latest check report, "Prüfung starten" (REST, DiviCheckRoute), "Divi ++ * 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. renderNotice() is the admin notice on every admin page ++ * while a feature is unusable. + */ + final class DiviTab implements AdminTab +@@ -35,4 +39,5 @@ final class DiviTab implements AdminTab + * @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment. + * @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey(). ++ * @param FeatureOverview|null $overview null = a table without the affected tools. + */ + public function __construct( +@@ -42,5 +47,6 @@ final class DiviTab implements AdminTab + private ?AdminPostAction $action = null, + private ?Closure $version = null, +- private ?Closure $stateKey = null ++ private ?Closure $stateKey = null, ++ private ?FeatureOverview $overview = null + ) { + } +@@ -66,4 +72,53 @@ final class DiviTab implements AdminTab + } + ++ /** The admin notice for administrators while a Divi feature is unusable; runs when the page is registered. */ ++ public function registerNotice(): void ++ { ++ add_action('admin_notices', [$this, 'renderNotice']); ++ } ++ ++ /** ++ * Only for administrators, only with Divi active, only while a feature is not usable – and not on the Divi tab, ++ * which shows the same table. Reads the cached matrix (D7), so no check runs on an ordinary admin page. ++ */ ++ public function renderNotice(): void ++ { ++ if (! current_user_can('manage_options') || $this->version() === '' || $this->onDiviTab()) { ++ return; ++ } ++ ++ $unusable = ReleaseGate::status()->matrix->unusable(); ++ if ($unusable === []) { ++ return; ++ } ++ ++ printf( ++ '

    %s %s

    ', ++ esc_html(sprintf( ++ /* translators: 1: Divi version, 2: comma separated feature names */ ++ __( ++ 'LIT MCP: Mit Divi %1$s sind diese Fähigkeiten nicht nutzbar: %2$s. Die betroffenen Werkzeuge lehnen ab, alle anderen laufen weiter.', // phpcs:ignore Generic.Files.LineLength.TooLong ++ 'wp-lit-mcp' ++ ), ++ $this->version(), ++ implode(', ', array_map( ++ static fn (FeatureStatus $status): string => $status->feature->label(), ++ $unusable ++ )) ++ )), ++ esc_url(SettingsPage::url(self::SLUG)), ++ esc_html__('Details im Reiter „Divi“', 'wp-lit-mcp') ++ ); ++ } ++ ++ private function onDiviTab(): bool ++ { ++ $screen = function_exists('get_current_screen') ? get_current_screen() : null; ++ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch. ++ $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : ''; ++ ++ return is_object($screen) && $screen->id === SettingsPage::SCREEN_HOOK && $tab === self::SLUG; ++ } ++ + public function release(): void + { +@@ -135,5 +190,5 @@ final class DiviTab implements AdminTab + 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.', ++ 'LIT MCP prüft nach einem Divi-Update jede Fähigkeit einzeln (z. B. „Seiten schreiben“). Ist sie nicht nutzbar, lehnen nur die Werkzeuge ab, die sie brauchen; alle anderen laufen weiter.', + 'wp-lit-mcp' + ) . '

    '; +@@ -150,4 +205,5 @@ final class DiviTab implements AdminTab + + $this->renderStatus($status); ++ $this->renderFeatures($status); + $this->renderForms($status, $report); + if ($report !== null) { +@@ -166,5 +222,5 @@ final class DiviTab implements AdminTab + printf( + '%s%s – %s', +- esc_html__('Zustand', 'wp-lit-mcp'), ++ esc_html__('Gesamtzustand', 'wp-lit-mcp'), + esc_html($this->stateLabel($status->state)), + esc_html($this->explanation($status)) +@@ -180,4 +236,78 @@ final class DiviTab implements AdminTab + } + ++ private function renderFeatures(ReleaseStatus $status): void ++ { ++ echo '

    ' . esc_html__('Fähigkeiten', 'wp-lit-mcp') . '

    '; ++ echo ''; ++ $headings = [ ++ __('Fähigkeit', 'wp-lit-mcp'), ++ __('Zustand', 'wp-lit-mcp'), ++ __('Quelle', 'wp-lit-mcp'), ++ __('Probleme', 'wp-lit-mcp'), ++ __('Betroffene Werkzeuge', 'wp-lit-mcp'), ++ ]; ++ foreach ($headings as $heading) { ++ echo ''; ++ } ++ echo ''; ++ foreach (($this->overview ?? FeatureOverview::withoutTools())->rows($status->matrix) as $row) { ++ echo ''; ++ echo ''; ++ echo ''; ++ echo ''; ++ } ++ echo '
    ' . esc_html($heading) . '
    ' . esc_html($row['label']) . '' . esc_html($this->stateLabel(ReleaseState::from($row['state']))) . '' . esc_html($this->sourceLabel($row['source'])) . ''; ++ $this->renderList($row['problems'], false); ++ echo ''; ++ $this->renderList($row['tools'], true); ++ echo '
    '; ++ $this->renderAutomationNote(); ++ } ++ ++ /** @param list $items */ ++ private function renderList(array $items, bool $code): void ++ { ++ if ($items === []) { ++ echo '–'; ++ ++ return; ++ } ++ ++ echo '
      '; ++ foreach ($items as $item) { ++ echo $code ? '
    • ' . esc_html($item) . '
    • ' : '
    • ' . esc_html($item) . '
    • '; ++ } ++ echo '
    '; ++ } ++ ++ private function sourceLabel(?string $source): string ++ { ++ return match ($source) { ++ 'code' => __('Plugin (code)', 'wp-lit-mcp'), ++ 'database' => __('Freigabe auf dieser Website (database)', 'wp-lit-mcp'), ++ 'auto' => __('Automatik (auto)', 'wp-lit-mcp'), ++ default => '–', ++ }; ++ } ++ ++ /** E1: what the automatic release does and how to switch it off. */ ++ private function renderAutomationNote(): void ++ { ++ echo '

    ' . esc_html__( ++ // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. ++ 'Automatik: Ist die installierte Divi-Version im Plugin nicht als getestet hinterlegt, gibt LIT MCP jede Fähigkeit einzeln frei, wenn alle benutzten Divi-Funktionen vorhanden sind, ihre Signaturen und die Fingerabdrücke nachgebauter Divi-Abläufe unverändert sind und alle Konstanten definiert sind. Fehlt für eine Stelle die Referenz, ist die Fähigkeit ungetestet; weicht etwas ab, ist sie inkompatibel.', ++ 'wp-lit-mcp' ++ ) . '

    '; ++ echo '

    ' . sprintf( ++ /* translators: %s: filter name in a code element */ ++ esc_html__( ++ // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. ++ 'Die Automatik abschalten: Filter %s auf false setzen (z. B. in einem mu-Plugin). Dann gelten nicht hinterlegte Divi-Versionen bis zur Freigabe unten als ungetestet.', ++ 'wp-lit-mcp' ++ ), ++ 'lit_mcp_divi_auto_release' ++ ) . '

    '; ++ } ++ + private function stateLabel(ReleaseState $state): string + { +@@ -194,5 +324,5 @@ final class DiviTab implements AdminTab + 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.', ++ 'Divi hat Stellen geändert, die LIT MCP nutzt. Die betroffenen Fähigkeiten (Tabelle unten) bleiben gesperrt, bis ein Plugin-Update von LIT MCP eingespielt ist; alle anderen laufen weiter.', + 'wp-lit-mcp' + ); +@@ -200,5 +330,6 @@ final class DiviTab implements AdminTab + if ($status->state === ReleaseState::Untested) { + return __( +- 'Diese Version ist nicht geprüft. Die Divi-Werkzeuge lesen nur, bis sie freigegeben ist.', ++ // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. ++ 'Diese Version ist nicht geprüft. Die Werkzeuge der ungetesteten Fähigkeiten (Tabelle unten) lehnen ab, bis die Version freigegeben ist; alle anderen laufen weiter.', + 'wp-lit-mcp' + ); +``` + +Hinweis: Hat Task 3 den `removeOrphans()`-Aufruf aus `render()` in den `load`-Hook verlegt, fehlt der Kontext `$removed` im Diff-Ausschnitt um `$this->renderStatus($status);` – an dieser Stelle nur die eine Zeile `$this->renderFeatures($status);` zwischen `renderStatus` und `renderForms` einfügen und den Einleitungstext ersetzen. + +Run: `vendor/bin/phpunit tests/Unit/Admin/DiviTabTest.php` +Expected (GREEN): `OK (24 tests, 100 assertions)` (zwölf bestehende plus zwölf neue; bei Abweichung durch Task 3 entsprechend). + +- [ ] **Step 5: `wp lit-mcp divi-check` – Matrix und Exit-Code (E3)** + +Begründung der Kombination (steht auch im Klassen-Docblock): Heute endet der Befehl nach dem Bericht grün/rot (`0`/`1`). Mit der Matrix gibt es einen dritten Fall: Prüfung grün, aber eine Fähigkeit nicht nutzbar – typisch eine Version, die noch nicht in `DiviReleases::TESTED` steht, während die Automatik (E1) abgeschaltet ist oder eine Referenz fehlt. E3 verlangt Exit ≠ 0, sobald eine Fähigkeit nicht nutzbar ist; ein reines `1` würde diesen Fall mit „Prüfung rot“ vermischen und den Entwickler-Ablauf „Prüfen, dann in `TESTED` eintragen“ unlesbar machen. Deshalb: `0` = grün und alles nutzbar, `1` = Prüfung rot, `2` = Prüfung grün, aber Fähigkeit nicht nutzbar. `--update-reference` behält seinen Code (läuft bewusst vor der Aufnahme der Referenz in eine Freigabe) und gibt die Matrix zur Information aus. Die Matrix wird nach dem Lauf frisch berechnet (`delete_transient` + `ReleaseGate::forget()`), damit sie nie älter ist als der Bericht davor. + +Änderungen an `tests/Unit/Cli/DiviCheckCommandTest.php`: + +```diff +diff --git a/tests/Unit/Cli/DiviCheckCommandTest.php b/tests/Unit/Cli/DiviCheckCommandTest.php +--- a/tests/Unit/Cli/DiviCheckCommandTest.php ++++ b/tests/Unit/Cli/DiviCheckCommandTest.php +@@ -12,3 +12,10 @@ use LIT\MCP\Integrations\Divi\Release\CheckStep; + use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; ++use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix; ++use LIT\MCP\Integrations\Divi\Release\DiviFeature; + use LIT\MCP\Integrations\Divi\Release\DiviReference; ++use LIT\MCP\Integrations\Divi\Release\ReleaseGate; ++use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; ++use LIT\MCP\Integrations\Divi\Release\ReleaseState; ++use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; ++use LIT\MCP\Tests\Support\MatrixFixtures; + use LIT\MCP\Tests\Unit\TestCase; +@@ -54,3 +61,4 @@ final class DiviCheckCommandTest extends TestCase + bool $expectSave = false, +- bool $noSourceDir = false ++ bool $noSourceDir = false, ++ ?CompatibilityMatrix $matrix = null + ): DiviCheckCommand { +@@ -77,3 +85,4 @@ final class DiviCheckCommandTest extends TestCase + $this->expectedFile, +- $noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir ++ $noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir, ++ $matrix === null ? null : static fn (): CompatibilityMatrix => $matrix + ); +@@ -81,2 +90,3 @@ final class DiviCheckCommandTest extends TestCase + ++ + public function testNeedsAnAdministrator(): void +@@ -220,2 +230,88 @@ final class DiviCheckCommandTest extends TestCase + } ++ ++ public function testPrintsTheMatrixAfterTheReport(): void ++ { ++ self::assertSame(0, $this->command()->run([])); ++ ++ $schreibtest = array_search('✓ Schreibtest auf einer Wegwerfseite', $this->lines, true); ++ $header = array_search('Fähigkeiten:', $this->lines, true); ++ self::assertIsInt($schreibtest); ++ self::assertIsInt($header); ++ self::assertGreaterThan($schreibtest, $header); ++ self::assertContains('✓ Seiten schreiben – freigegeben (code)', $this->lines); ++ self::assertContains('✓ Theme Builder lesen – freigegeben (code)', $this->lines); ++ } ++ ++ public function testEveryFeatureGetsOneLine(): void ++ { ++ $this->command()->run([]); ++ ++ $matrixLines = array_filter($this->lines, static fn (string $line): bool => str_contains($line, ' – freigegeben')); ++ self::assertCount(count(DiviFeature::cases()), $matrixLines); ++ } ++ ++ public function testAnIncompatibleFeatureFailsWithItsProblemsIndented(): void ++ { ++ $matrix = MatrixFixtures::with(DiviFeature::PageWrite, ReleaseState::Incompatible, 'auto', 'Seiten: et_pb_x() fehlt'); ++ ++ $code = $this->command(matrix: $matrix)->run([]); ++ ++ self::assertSame(2, $code); ++ self::assertContains('✗ Seiten schreiben – inkompatibel', $this->lines); ++ self::assertContains(' Seiten: et_pb_x() fehlt', $this->lines); ++ self::assertContains('✓ Seiten lesen – freigegeben (auto)', $this->lines); ++ } ++ ++ public function testAnUntestedFeatureFailsWithExitTwoAlthoughTheReportIsGreen(): void ++ { ++ $matrix = MatrixFixtures::with(DiviFeature::Catalog, ReleaseState::Untested, 'auto'); ++ ++ $code = $this->command(expectSave: true, matrix: $matrix)->run([]); ++ ++ self::assertSame(2, $code); ++ self::assertContains('– Modulkatalog lesen – ungetestet', $this->lines); ++ } ++ ++ public function testARedReportKeepsExitOneWhateverTheMatrixSays(): void ++ { ++ $red = [CheckStep::CATALOG => ['divi/text: unbekanntes Modul']]; ++ ++ self::assertSame(1, $this->command($red)->run([])); ++ self::assertSame( ++ 1, ++ $this->command($red, matrix: MatrixFixtures::with(DiviFeature::Catalog, ReleaseState::Incompatible, 'auto', 'x')) ++ ->run([]) ++ ); ++ } ++ ++ public function testUpdateReferenceKeepsItsOwnExitCodeAndStillPrintsTheMatrix(): void ++ { ++ $this->writeDivi(); ++ $matrix = MatrixFixtures::with(DiviFeature::PageWrite, ReleaseState::Incompatible, 'auto', 'Seiten: Nachbau geändert'); ++ ++ $code = $this->command([CheckStep::MIRRORS => ['geändert']], matrix: $matrix) ++ ->run(['update-reference' => true]); ++ ++ self::assertSame(0, $code); ++ self::assertContains('✗ Seiten schreiben – inkompatibel', $this->lines); ++ } ++ ++ public function testWithoutAnInjectedMatrixTheStateIsComputedAfterTheCheck(): void ++ { ++ $order = []; ++ Functions\when('delete_transient')->alias(static function (string $key) use (&$order): bool { ++ $order[] = 'delete:' . $key; ++ ++ return true; ++ }); ++ ReleaseGate::useResolver(static function () use (&$order): ReleaseStatus { ++ $order[] = 'resolve'; ++ ++ return ReleaseStatus::released('5.3.0'); ++ }); ++ ++ $this->command()->run([]); ++ ++ self::assertSame(['delete:' . ReleaseResolver::CACHE, 'resolve'], $order); ++ } + } +``` + +Run: `vendor/bin/phpunit tests/Unit/Cli` +Expected (RED): `Unknown named parameter $matrix` (in `command()`), die bestehenden Tests ebenfalls rot, solange der Konstruktor fehlt. + +Änderungen an `src/Cli/DiviCheckCommand.php`: + +```diff +diff --git a/src/Cli/DiviCheckCommand.php b/src/Cli/DiviCheckCommand.php +--- a/src/Cli/DiviCheckCommand.php ++++ b/src/Cli/DiviCheckCommand.php +@@ -10,4 +10,5 @@ use LIT\MCP\Integrations\Divi\Release\CheckReportStore; + use LIT\MCP\Integrations\Divi\Release\CheckStep; + use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; ++use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix; + use LIT\MCP\Integrations\Divi\Release\DiviAdapters; + use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; +@@ -23,5 +24,12 @@ defined('ABSPATH') || exit; + /** + * wp lit-mcp divi-check (spec §7): the admin check from the command line, for a developer checking a new Divi +- * version. Never releases anything – a green version goes into DiviReleases::TESTED by hand. ++ * version. Never releases anything – a green version goes into DiviReleases::TESTED by hand. After the report it ++ * prints the compatibility matrix (E3). ++ * ++ * Exit codes: 0 = report green and every feature usable; 1 = report red (or the check could not run, or ++ * --update-reference refused); 2 = report green but a feature is not usable. The 2 is for a version that is ++ * checked but not yet released (not in DiviReleases::TESTED, automatic release off or a reference missing): the ++ * checks passed, yet LIT MCP would still refuse the affected tools. A script that only cares about "usable" tests ++ * for ≠ 0. --update-reference keeps its own code: it runs before the new reference is part of any release. + */ + final class DiviCheckCommand +@@ -36,4 +44,5 @@ final class DiviCheckCommand + * @param string|null $expectedFile null = DiviReference::FILE. + * @param (Closure(): ?string)|null $sourceDir null = DiviReference::sourceDir(). ++ * @param (Closure(): CompatibilityMatrix)|null $matrix null = the release gate's matrix, computed again. + */ + public function __construct( +@@ -43,5 +52,6 @@ final class DiviCheckCommand + private ?Closure $themeDir = null, + private ?string $expectedFile = null, +- private ?Closure $sourceDir = null ++ private ?Closure $sourceDir = null, ++ private ?Closure $matrix = null + ) { + $this->out = $out ?? static function (string $line): void { +@@ -60,5 +70,7 @@ final class DiviCheckCommand + /** + * Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format +- * und Schreibtest auf einer Wegwerfseite. Gibt nichts frei. ++ * und Schreibtest auf einer Wegwerfseite. Gibt danach den Zustand je Fähigkeit aus und gibt nichts frei. ++ * Exit-Code 0: Prüfung grün und alle Fähigkeiten nutzbar; 1: Prüfung rot; 2: Prüfung grün, aber mindestens ++ * eine Fähigkeit nicht nutzbar (z. B. Version noch nicht in DiviReleases::TESTED). + * + * ## OPTIONS +@@ -108,8 +120,67 @@ final class DiviCheckCommand + } + if (isset($assoc['update-reference'])) { +- return $this->updateReference($report); ++ $code = $this->updateReference($report); ++ $this->printMatrix(); ++ ++ return $code; ++ } ++ ++ $unusable = $this->printMatrix(); ++ if (! $report->green()) { ++ return 1; ++ } ++ ++ return $unusable > 0 ? 2 : 0; ++ } ++ ++ /** ++ * Prints one line per feature (✓ released, ✗ incompatible, – untested) with label and state, the problems ++ * indented below. ++ * ++ * @return int The number of features that are not usable. ++ */ ++ private function printMatrix(): int ++ { ++ $matrix = $this->currentMatrix(); ++ ($this->out)('Fähigkeiten:'); ++ foreach ($matrix->toArray() as $row) { ++ ($this->out)(sprintf( ++ '%s %s – %s', ++ match ($row['state']) { ++ 'released' => '✓', ++ 'incompatible' => '✗', ++ default => '–', ++ }, ++ $row['label'], ++ self::stateLabel($row['state'], $row['source']) ++ )); ++ foreach ($row['problems'] as $problem) { ++ ($this->out)(' ' . $problem); ++ } ++ } ++ ++ return count($matrix->unusable()); ++ } ++ ++ /** The check just ran, so the state is computed again instead of read from a cache older than the report. */ ++ private function currentMatrix(): CompatibilityMatrix ++ { ++ if ($this->matrix !== null) { ++ return ($this->matrix)(); + } + +- return $report->green() ? 0 : 1; ++ delete_transient(ReleaseResolver::CACHE); ++ ReleaseGate::forget(); ++ ++ return ReleaseGate::status()->matrix; ++ } ++ ++ private static function stateLabel(string $state, ?string $source): string ++ { ++ return match ($state) { ++ 'released' => 'freigegeben (' . ($source ?? '?') . ')', ++ 'incompatible' => 'inkompatibel', ++ default => 'ungetestet', ++ }; + } + +``` + +Zusätzlich im Docblock von `__invoke()` (Hilfetext von WP-CLI) – Vorher: + +```php + * Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format + * und Schreibtest auf einer Wegwerfseite. Gibt nichts frei. +``` + +Nachher: + +```php + * Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format + * und Schreibtest auf einer Wegwerfseite. Gibt danach den Zustand je Fähigkeit aus und gibt nichts frei. + * Exit-Code 0: Prüfung grün und alle Fähigkeiten nutzbar; 1: Prüfung rot; 2: Prüfung grün, aber mindestens + * eine Fähigkeit nicht nutzbar (z. B. Version noch nicht in DiviReleases::TESTED). +``` + +Run: `vendor/bin/phpunit tests/Unit/Cli` +Expected (GREEN): `OK (16 tests, 44 assertions)`. + +- [ ] **Step 6: Texte je Fähigkeit statt pauschal „gesperrt“ (Review-Punkt e)** + +Die Texte des Tabs (`explanation()`: „Schreiben bleibt gesperrt“, „lesen nur“) und von `lit-site-status` sind in den Steps 2 und 4 schon umgestellt; die Guides folgen in Task 11 (dort auch der Guard-Test `FeatureTextsTest`, der `src/` und `docs/guides/` auf die alten Pauschalsätze prüft). Hier bleibt `ReleaseStatus`. + +Hat Task 7/9 die Konstanten und `refusal()` schon je Fähigkeit formuliert (`grep -rn "Schreibende Divi-Werkzeuge sind gesperrt\|lesende Divi-Werkzeuge funktionieren weiter" src` ist leer), diesen Schritt überspringen und nur die Wortlaute mit den unten stehenden abgleichen. Sonst zuerst den Test anpassen (RED), dann die Konstanten. + +Test: in `tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php` die Erwartungsstrings ersetzen und `testNoticesForReadingTools` löschen (die Methode `notice()` entfällt unten): + +```php +// testUntestedRefusalNamesTheVersionAndTheWayOut +self::assertSame( + 'Achtung: Divi 5.3.0 ist auf dieser Website noch nicht für LIT MCP freigegeben. Dieses Werkzeug braucht ' + . 'Divi-Fähigkeiten, die noch nicht geprüft sind; andere Divi-Werkzeuge laufen weiter. Ein ' + . 'Administrator kann unter Einstellungen → LIT MCP → Divi die Prüfung ausführen und die Version ' + . 'freigeben. Es wurde nichts geändert.', + $payload['message'] +); +self::assertStringContainsString('ein Werkzeug ohne die betroffene Fähigkeit nutzen', $payload['hint']); +self::assertStringContainsString('components → divi → features', $payload['hint']); +``` + +```php +// testIncompatibleRefusalListsTheProblems +self::assertSame( + 'Divi 5.3.0 hat Stellen geändert, die LIT MCP für dieses Werkzeug nutzt; andere Divi-Werkzeuge laufen ' + . 'weiter. Die betroffenen Werkzeuge bleiben gesperrt, bis LIT MCP aktualisiert wurde. Es wurde ' + . 'nichts geändert.', + $payload['message'] +); +``` + +Run: `vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php` +Expected (RED): `Failed asserting that two strings are identical` mit dem alten Text („Schreibende Divi-Werkzeuge sind gesperrt …“) als Ist-Wert. + +Code, Vorher (`src/Integrations/Divi/Release/ReleaseStatus.php`): + +```php + private const UNTESTED = 'Achtung: %s ist auf dieser Website noch nicht für LIT MCP freigegeben. Schreibende ' + . 'Divi-Werkzeuge sind gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung ' + . 'ausführt und die Version freigibt. Es wurde nichts geändert.'; + private const UNTESTED_HINT = 'Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben; ' + . 'lesende Divi-Werkzeuge funktionieren weiter.'; + private const INCOMPATIBLE = '%s hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind ' + . 'gesperrt, bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.'; + private const INCOMPATIBLE_HINT = 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss; lesende ' + . 'Divi-Werkzeuge funktionieren weiter.'; + private const NOTICE = 'Achtung: %s ist für LIT MCP %s; Lesen funktioniert, Schreiben ist gesperrt.'; +``` + +Nachher: + +```php + private const UNTESTED = 'Achtung: %s ist auf dieser Website noch nicht für LIT MCP freigegeben. Dieses ' + . 'Werkzeug braucht Divi-Fähigkeiten, die noch nicht geprüft sind; andere Divi-Werkzeuge laufen weiter. ' + . 'Ein Administrator kann unter Einstellungen → LIT MCP → Divi die Prüfung ausführen und die Version ' + . 'freigeben. Es wurde nichts geändert.'; + private const UNTESTED_HINT = 'Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben; ' + . 'bis dahin ein Werkzeug ohne die betroffene Fähigkeit nutzen. lit-site-status zeigt unter components → ' + . 'divi → features, welche Fähigkeiten und Werkzeuge betroffen sind.'; + private const INCOMPATIBLE = '%s hat Stellen geändert, die LIT MCP für dieses Werkzeug nutzt; andere ' + . 'Divi-Werkzeuge laufen weiter. Die betroffenen Werkzeuge bleiben gesperrt, bis LIT MCP aktualisiert ' + . 'wurde. Es wurde nichts geändert.'; + private const INCOMPATIBLE_HINT = 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss; bis dahin ' + . 'ein Werkzeug ohne die betroffene Fähigkeit nutzen. lit-site-status zeigt unter components → divi → ' + . 'features, welche Fähigkeiten und Werkzeuge betroffen sind.'; +``` + +`ReleaseStatus::notice()` hat nach Task 9 keinen Aufrufer (Step 0) und entfällt samt `NOTICE`. Vorher: + +```php + /** The warning a reading Divi tool carries while writes are locked. */ + public function notice(): string + { + return sprintf( + self::NOTICE, + $this->versionLabel(), + $this->state === ReleaseState::Incompatible ? 'inkompatibel' : 'ungetestet' + ); + } +``` + +Nachher: der Block ist gelöscht. Hat Task 9 `refusal()` bereits auf eine Fähigkeitsliste umgestellt (`refusal(array $features)` o. Ä.), dieselben Wortlaute in der dortigen Textquelle verwenden. + +Run: `vendor/bin/phpunit tests/Unit/Integrations/Divi/Release` +Expected (GREEN): grün. + +- [ ] **Step 7: Verdrahtung in `Plugin.php`** + +Vorher (`src/Plugin.php`): + +```php + $divi = new DiviIntegration( + $schema, + $changeLog, + $libraryWritable, + libraryVisible: $libraryVisible, + diviWritable: $diviWritable, + libraryInsertable: $libraryInsertable, + themeBuilderWritable: $themeBuilderWritable, + themeBuilderVisible: $themeBuilderVisible + ); +``` + +Nachher: + +```php + // E3: lit-site-status and the Divi tab name the tools each feature affects; the registry exists only after + // the Divi integration, so it is asked at call time. + $allAbilities = static function () use (&$registry): array { + return $registry instanceof IntegrationRegistry ? $registry->abilities() : []; + }; + $divi = new DiviIntegration( + $schema, + $changeLog, + $libraryWritable, + libraryVisible: $libraryVisible, + diviWritable: $diviWritable, + libraryInsertable: $libraryInsertable, + themeBuilderWritable: $themeBuilderWritable, + themeBuilderVisible: $themeBuilderVisible, + allAbilities: $allAbilities + ); +``` + +Tab und Hinweis, Vorher: + +```php + $tabs = [ + new ConnectionTab($status), + new AccessTab($access, new ConnectionRepository()), + new ToolsTab($registry, $policy), + new LogTab($changeLog, $limits, $registry), + new LimitsTab(), + new DiviTab(log: $changeLog), + ]; +``` + +```php + if (is_admin()) { + self::$settingsPage->register(); + (new AdminNotices($status))->register(); + } +``` + +Nachher: + +```php + $diviTab = new DiviTab(log: $changeLog, overview: $divi->featureOverview()); + $tabs = [ + new ConnectionTab($status), + new AccessTab($access, new ConnectionRepository()), + new ToolsTab($registry, $policy), + new LogTab($changeLog, $limits, $registry), + new LimitsTab(), + $diviTab, + ]; +``` + +```php + if (is_admin()) { + self::$settingsPage->register(); + (new AdminNotices($status))->register(); + $diviTab->registerNotice(); + } +``` + +Hat Task 3 Tab und Komponente schon nach `DiviIntegration::boot()` verlegt (Step 0 zeigt es), dort statt in `Plugin.php`: `new DiviTab(log: …, overview: $this->featureOverview())`, `registerNotice()` im selben `is_admin()`-Zweig und `new DiviEnvironmentComponent(overview: $this->featureOverview())` an der Stelle, die Task 2 anlegt; in `Plugin.php` bleibt nur der `$allAbilities`-Block und der Parameter `allAbilities:`. + +- [ ] **Step 8: Gesamtlauf und Lint** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: alle Tests grün, phpcs ohne Meldung (jede Zeile ≤ 120 Zeichen; die langen i18n-Texte im Tab tragen das bestehende `phpcs:ignore Generic.Files.LineLength.TooLong`). + +- [ ] **Step 9: Commits** + +Zwei Commits, nur diese Dateien stagen, Commit wie in den Global Constraints: + +```bash +git add src/Integrations/Divi/Release/FeatureOverview.php src/Integrations/Divi/DiviEnvironmentComponent.php \ + src/Integrations/Core/SiteStatusAbility.php src/Integrations/Divi/DiviIntegration.php src/Admin/DiviTab.php src/Plugin.php \ + tests/Support/FeatureFakeTool.php tests/Support/MatrixFixtures.php \ + tests/Unit/Integrations/Divi/Release/FeatureOverviewTest.php tests/Unit/Integrations/Divi/DiviEnvironmentComponentTest.php \ + tests/Unit/Integrations/Core/SiteStatusAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php \ + tests/Unit/Admin/DiviTabTest.php +git commit -m "feat: lit-site-status, Admin-Tab und Admin-Hinweis zeigen Divi-Fähigkeiten mit Zustand, Quelle, Problemen und betroffenen Werkzeugen" -m "Co-Authored-By: Claude " + +git add src/Cli/DiviCheckCommand.php src/Integrations/Divi/Release/ReleaseStatus.php \ + tests/Unit/Cli/DiviCheckCommandTest.php tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php +git commit -m "feat: divi-check gibt die Matrix je Fähigkeit aus (Exit 2 bei nicht nutzbarer Fähigkeit); Texte nennen Fähigkeiten statt pauschal gesperrt" -m "Co-Authored-By: Claude " +``` + +--- + +### Task 11: Guides, Entscheidungsnotiz, Smoke + +**Files:** +- Modify: `docs/guides/overview.md`, `docs/guides/divi-structure.md`, `docs/guides/workflows.md` +- Create: `docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md` +- Create: `tests/Unit/Integrations/Divi/Release/FeatureTextsTest.php` +- Modify: `tests/Integration/divi-release-smoke.sh` + +**Interfaces:** +- Consumes: alles aus Tasks 1–10 (`features` in `lit-site-status`, `divi_untested`/`divi_incompatible` je Fähigkeit mit `features`, Admin-Hinweis und -Tab, `wp lit-mcp divi-check` mit Matrix); `tests/Integration/lib.sh` (`connect`, `tool`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`, `cleanup_app_passwords_for`). +- Produces: drei angepasste Guides, die Notiz, ein erweitertes Smoke (endet mit `ALL CHECKS PASSED`). +- Regeln für die Guide-Texte (`tests/Unit/Guides/ToolNamingTest.php`): indirekte Tools `lit/`, direkte `lit-`; Fehlercodes ohne `lit_`-Präfix (`divi_untested`, `divi_incompatible`). `overview.md` wird hier ausnahmsweise geändert (der Abschnitt „Divi-Version nicht freigegeben“ ist durch E1–E3 überholt); keine neuen Themen, keine neuen direkten Tools. + +- [ ] **Step 1: Guard-Test und Guides** + +Zuerst der Guard-Test `tests/Unit/Integrations/Divi/Release/FeatureTextsTest.php`: Mit einer Sperre je Fähigkeit (E1–E3) darf kein Text mehr sagen, „Schreiben“ oder „Divi“ sei insgesamt gesperrt. Er prüft `src/` und `docs/guides/` (auch Strings, die über Zeilenumbrüche aneinandergehängt sind): + +```php + Path => content of the PHP sources and the guides. */ + private static function texts(): array + { + $root = dirname(__DIR__, 5); + $texts = []; + foreach (glob($root . '/docs/guides/*.md') ?: [] as $file) { + $texts[$file] = (string) file_get_contents($file); + } + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/src')) as $file) { + if (str_ends_with((string) $file, '.php')) { + $texts[(string) $file] = (string) file_get_contents((string) $file); + } + } + + return $texts; + } + + public function testNoTextLocksWritingAsAWhole(): void + { + $texts = self::texts(); + self::assertNotEmpty($texts); + + foreach ($texts as $path => $text) { + // Join string literals split over lines ('a ' . 'b') so a phrase cannot hide in a line break. + $text = (string) preg_replace('/\'\s*\.\s*\'/', '', $text); + foreach (self::OLD_PHRASES as $phrase) { + self::assertFalse( + stripos($text, $phrase) !== false, + sprintf('%s still says "%s" – name the feature instead', basename($path), $phrase) + ); + } + } + } +} +``` + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/FeatureTextsTest.php` +Expected (RED): `overview.md still says "lesende Divi-Werkzeuge funktionieren weiter" – name the feature instead`. + +Die Zeilenumbrüche der Guides sind weich (Markdown), die Zeilenlänge ist nicht begrenzt. Vollständige Änderungen (Vorher/Nachher als Diff): + +```diff +diff --git a/docs/guides/overview.md b/docs/guides/overview.md +--- a/docs/guides/overview.md ++++ b/docs/guides/overview.md +@@ -65,8 +65,22 @@ Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in ein + +-## Divi-Version nicht freigegeben ++## Divi-Fähigkeit 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`). ++LIT MCP gibt jede Divi-Fähigkeit einzeln frei, z. B. „Seiten lesen“, „Seiten schreiben“, „Presets schreiben“ oder ++„Theme Builder lesen“. Nach einem Divi-Update geschieht das automatisch, wenn alle Divi-Stellen, die eine Fähigkeit ++nutzt, unverändert sind; sonst nach einer Prüfung durch einen Administrator. Nur Werkzeuge, die eine nicht nutzbare ++Fähigkeit brauchen – lesende wie schreibende –, lehnen ab; alle anderen Divi-Werkzeuge laufen weiter, ebenso ++`lit-content-create` ohne `divi`. ++ ++Antwortet ein Werkzeug mit `divi_untested`, ist eine Fähigkeit, die es braucht, für die installierte Divi-Version noch ++nicht geprüft; bei `divi_incompatible` hat Divi Stellen geändert, die LIT MCP dafür nutzt. In beiden Fällen wurde ++nichts geändert. Vorgehen: ++ ++1. Den Aufruf nicht wiederholen: er scheitert, bis ein Administrator eingreift. ++2. `lit-site-status` lesen. Unter `components` → `divi` → `features` steht je Fähigkeit der Zustand (`released`, ++ `untested`, `incompatible`), die Quelle (`code`, `database`, `auto`), die Probleme (`problems`) und die betroffenen ++ Werkzeuge (`tools`). ++3. Ein anderes Werkzeug nutzen, das die betroffene Fähigkeit nicht braucht – ist z. B. nur „Seiten schreiben“ ++ betroffen, funktioniert `lit-divi-outline` weiter, und der Nutzer kann die Änderung selbst im Divi-Builder machen. ++4. Den Nutzer informieren: bei `divi_untested` soll ein Administrator unter Einstellungen → LIT MCP → Divi die ++ Prüfung ausführen und die Version freigeben; bei `divi_incompatible` muss LIT MCP aktualisiert werden. Die Sperre ++ nicht über andere Werkzeuge umgehen. +``` + +```diff +diff --git a/docs/guides/divi-structure.md b/docs/guides/divi-structure.md +--- a/docs/guides/divi-structure.md ++++ b/docs/guides/divi-structure.md +@@ -40,2 +40,10 @@ Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert + ++## Wenn ein Divi-Werkzeug ablehnt ++ ++`divi_untested` oder `divi_incompatible` heißt: eine Divi-Fähigkeit, die das Werkzeug braucht (z. B. „Seiten ++schreiben“), ist für die installierte Divi-Version nicht freigegeben. Das gilt je Fähigkeit – `lit-divi-outline` ++kann weiter lesen, auch wenn `lit-divi-insert` ablehnt. `lit-site-status` nennt unter `components` → `divi` → ++`features` die betroffenen Fähigkeiten und Werkzeuge; den Nutzer informieren und nicht wiederholen (Vorgehen: ++`lit-guide` topic `overview`, Abschnitt „Divi-Fähigkeit nicht freigegeben“). ++ + ## Was nicht bearbeitet wird +``` + +```diff +diff --git a/docs/guides/workflows.md b/docs/guides/workflows.md +--- a/docs/guides/workflows.md ++++ b/docs/guides/workflows.md +@@ -24,2 +24,14 @@ + ++## Ein Divi-Werkzeug lehnt ab ++ ++Antwortet ein Divi-Werkzeug mit `divi_untested` oder `divi_incompatible`, ist nur eine Fähigkeit gesperrt, nicht Divi ++insgesamt: ++ ++1. `lit-site-status` lesen: `components` → `divi` → `features` nennt je Fähigkeit Zustand, Quelle, Probleme und die ++ betroffenen Werkzeuge. ++2. Mit Werkzeugen weiterarbeiten, deren Fähigkeit `released` ist (z. B. Seiten lesen mit `lit-divi-outline`), und den ++ gesperrten Teil auslassen. ++3. Dem Nutzer sagen, welche Fähigkeit gesperrt ist: bei `untested` muss ein Administrator unter Einstellungen → LIT ++ MCP → Divi die Prüfung ausführen und freigeben, bei `incompatible` muss LIT MCP aktualisiert werden. ++ + ## Bibliothek und globale Module +``` + +Die übrigen Guides (`divi-library`, `theme-builder`, `divi-design-system`, `divi-shortform`, `divi-attrs`, `divi-placeholders`) enthalten keine Aussage zur Freigabe; `grep -rn "freigegeben\|ungetestet\|divi_untested\|divi_incompatible\|Schreiben ist gesperrt" docs/guides` findet nach der Änderung nur noch `overview.md` (neuer Abschnitt), `divi-structure.md` (neuer Abschnitt), `workflows.md` (neuer Abschnitt) und die „gesperrt“-Sätze in `divi-library.md` (Zeile 124/125), die die Tools-Tab-Sperre meinen und bleiben. + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit tests/Unit/Guides tests/Unit/Integrations/Divi/Release/FeatureTextsTest.php` +Expected (GREEN): grün (`ToolNamingTest`: nur `lit-site-status`, `lit-divi-outline`, `lit-divi-insert`, `lit-content-create` als direkte Namen; `FeatureTextsTest` findet keinen Pauschalsatz mehr in `src/` und `docs/guides/`; setzt voraus, dass Task 10 Step 6 die `ReleaseStatus`-Texte umgestellt hat). + +- [ ] **Step 2: Entscheidungsnotiz anlegen** + +`docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md` (vollständig): + +@@NOTE@@ + +- [ ] **Step 3: `divi-release-smoke.sh` erweitern** + +Prüfung auf einen vorhandenen Testhook: Es gibt keinen Filter und keine Konstante, die den Release-Zustand von außen beeinflusst; das Skript nutzt seit 2026-09-30 den State-Cache (Transient `lit_mcp_divi_state`) als einzigen Hebel. Der bleibt der Weg: Ein zusätzliches Problem im Cache kann die Sperre nur **verschärfen** (nie eine Fähigkeit freigeben), ändert keine Divi-Datei und nicht `fingerprints.json`, gilt nur für diesen Schlüssel (`stateKey()`) und höchstens zehn Minuten, und das Skript löscht den Transient im EXIT-Trap. Ein neuer Filter im Produktionscode (`lit_mcp_divi_reference` o. ä.) wurde verworfen: er wäre ein zusätzlicher Weg, die Referenz und damit die Sperre zu beeinflussen, ohne dass ein Test ihn braucht. + +Neue Prüfungen: (1) alle elf Fähigkeiten `released`, Quelle `code`, ohne Probleme, mit Werkzeugen (`page-write` → `lit-divi-insert`, `page-read` → `lit-divi-outline`, `preset-write` → `lit/divi-presets-…`); (2) simuliertes Problem für `page-write`: Lesen, Katalog und Design-System laufen ohne Hinweis, `lit-divi-insert` und `lit-content-create` mit `divi` werden mit `divi_incompatible` und `features` abgelehnt, `lit-content-create` ohne `divi` läuft; `lit-site-status` zeigt nur `page-write` als `incompatible` (Quelle `null`, Problemtext, Tool); Admin-Hinweis und Tab nennen Fähigkeit und Tools; (3) zurück auf freigegeben; (4) `wp lit-mcp divi-check` gibt elf freigegebene Fähigkeiten aus, rechnet frisch (ein vergifteter Cache wird nicht übernommen) und endet mit Exit 0. + +Vollständiges neues Skript (ersetzt die Datei; Aufbau, `lib.sh`-Nutzung, Cleanup und die Prüfungen 1–2 und 5 des bisherigen Skripts bleiben erhalten, 3–4 sind auf Fähigkeiten umgestellt): + +```bash +#!/usr/bin/env bash +# End-to-end checks of the Divi release gate (spec 2026-09-29-divi-absicherung) and its per-feature matrix +# (plan 2026-10-18-divi-faehigkeiten). Runs INSIDE the DDEV web container (Divi 5.15.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. Divi's files and fingerprints.json are never touched: a deviation is simulated by adding a +# problem to the cached state (transient lit_mcp_divi_state, which only ever locks more, never less). +# Only runs with the plugins postlos and asynclistry deactivated (Monolog 2 / deprecation output of WP-CLI). +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; } + +# The ids of the eleven features (plan decision D1), sorted. +FEATURE_IDS='["catalog","design-read","dynamic-sources","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write"]' + +# Adds a problem to one feature in the cached state, as if its check had failed. Needs the cache entry +# ["key" => stateKey, "features" => [feature id => list]] (ReleaseResolver, decision D7); the key keeps +# the entry valid for this Divi version. Fails loudly when the cache has another format. +simulate_problem() { # $1 = feature id, $2 = problem text + FEATURE="$1" PROBLEM="$2" wp eval ' +$cache = LIT\MCP\Integrations\Divi\Release\ReleaseResolver::CACHE; +$key = LIT\MCP\Integrations\Divi\Release\ReleaseResolver::stateKey( + (string) LIT\MCP\Integrations\Divi\Release\DiviEnvironment::version() +); +$entry = get_transient($cache); +if (! is_array($entry) || ($entry["key"] ?? null) !== $key || ! is_array($entry["features"] ?? null)) { + fwrite(STDERR, "state cache missing or in another format: adapt simulate_problem()\n"); + exit(1); +} +$entry["features"][getenv("FEATURE")][] = getenv("PROBLEM"); +set_transient($cache, $entry, 600);' || fail "could not simulate a problem for $1" +} + +[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme" + +# 1. Code, registrations and fingerprints match Divi 5.15.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.15.1 released (code), no problems in an MCP request" + +# 1b. The matrix: every feature released from code, none with problems, and the tools each feature affects. +echo "$STATUS" | jq -e --argjson ids "$FEATURE_IDS" ' + .components[] | select(.id == "divi") | .features + | ([.[].id] | sort) == $ids + and all(.state == "released" and .source == "code" and .problems == [])' >/dev/null \ + || fail "all eleven features must be released from code without problems: $STATUS" +echo "$STATUS" | jq -e ' + .components[] | select(.id == "divi") | .features + | (.[] | select(.id == "page-write") | .tools | any(. == "lit-divi-insert")) + and (.[] | select(.id == "page-read") | .tools | any(. == "lit-divi-outline")) + and (.[] | select(.id == "preset-write") | .tools | any(startswith("lit/divi-presets-"))) + and (.[] | select(.id == "page-read") | .label == "Seiten lesen")' >/dev/null \ + || fail "features must name their tools (direct lit-, indirect lit/): $STATUS" +pass "lit-site-status: eleven features released (code), each with its tools" + +# 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. One incompatible feature (simulated: a failed check for "page-write" in the state cache) locks exactly the tools +# that need it. First make sure the cache entry exists (a status call fills it), then add the problem. +wp transient delete lit_mcp_divi_state >/dev/null 2>&1 || true +tool lit-site-status '{}' >/dev/null +PROBLEM_TEXT="Smoke: Seiten schreiben geändert" +simulate_problem page-write "$PROBLEM_TEXT" + +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) + +# Reading needs "page-read" only: it runs, without a lock notice (the old blanket warning is gone). +OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}") +echo "$OUTLINE" | jq -e 'has("__error") | not' >/dev/null || fail "reading must work while only page-write is locked: $OUTLINE" +echo "$OUTLINE" | jq -e '(.warnings // []) | all(test("inkompatibel|ungetestet") | not)' >/dev/null \ + || fail "reading carries no lock notice: $OUTLINE" +for read_tool in lit-divi-catalog lit-divi-design-system; do + READ=$(tool "$read_tool" '{}') + echo "$READ" | jq -e 'has("__error") | not' >/dev/null || fail "$read_tool must keep working: $READ" +done +pass "reading Divi tools run while page-write is locked (outline, catalog, design system)" + +INSERT=$(tool lit-divi-insert "{\"post_id\":$PAGE_ID,\"tree_rev\":$(echo "$OUTLINE" | jq '.tree_rev'),\"nodes\":[{\"type\":\"section\",\"modules\":[{\"type\":\"text\",\"content\":\"

    x

    \"}]}]}") +[ "$(error_code "$INSERT")" = "divi_incompatible" ] || fail "write must be refused with divi_incompatible: $INSERT" +echo "$INSERT" | jq -e '.__error | fromjson | ((.features // []) | any(.id == "page-write")) + and (((.message // "") + " " + (.hint // "")) | contains("Seiten schreiben"))' >/dev/null \ + || fail "the refusal must name the feature page-write (D6): $INSERT" +[ "$(wp post get "$PAGE_ID" --field=post_content)" = "$BEFORE" ] || fail "nothing may be written" +pass "lit-divi-insert refused with divi_incompatible naming page-write, page unchanged" + +CREATE=$(tool lit-content-create '{"post_type":"page","title":"LIT Freigabe Smoke Neu","divi":[{"type":"section","modules":[{"type":"text","content":"

    x

    "}]}]}') +[ "$(error_code "$CREATE")" = "divi_incompatible" ] || 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" + +PLAIN=$(tool lit-content-create '{"post_type":"page","title":"LIT Freigabe Smoke Ohne Divi"}') +PLAIN_ID=$(echo "$PLAIN" | jq -r '.changed[0].id // empty') +[ -n "$PLAIN_ID" ] || fail "content-create without divi must keep working: $PLAIN" +CREATED_POSTS+=("$PLAIN_ID") +pass "lit-content-create without divi still works" + +STATUS=$(tool lit-site-status '{}') +echo "$STATUS" | jq -e --arg problem "$PROBLEM_TEXT" ' + .components[] | select(.id == "divi") | .features + | (.[] | select(.id == "page-write") | .state == "incompatible" and .source == null + and (.problems | any(. == $problem)) and (.tools | any(. == "lit-divi-insert"))) + and ([.[] | select(.id != "page-write") | .state] | all(. == "released"))' >/dev/null \ + || fail "site status must show only page-write 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: only page-write incompatible (problem and tools named), refusal logged" + +# The admin sees the same table and a notice with a link to the tab (rendered here without a browser). +NOTICE=$(wp --user="$ADMIN_LOGIN" eval '(new LIT\MCP\Admin\DiviTab())->renderNotice();') +echo "$NOTICE" | grep -q 'Seiten schreiben' || fail "admin notice must name the feature: $NOTICE" +echo "$NOTICE" | grep -q 'tab=divi' || fail "admin notice must link to the Divi tab: $NOTICE" +TAB=$(wp --user="$ADMIN_LOGIN" eval ' +require_once ABSPATH . "wp-admin/includes/template.php"; +(new LIT\MCP\Admin\DiviTab())->render();') +echo "$TAB" | grep -q 'lit-mcp-divi-features' || fail "Divi tab must list the features: $TAB" +echo "$TAB" | grep -q 'lit-divi-insert' || fail "Divi tab must name the affected tools: $TAB" +echo "$TAB" | grep -q 'lit_mcp_divi_auto_release' || fail "Divi tab must explain the filter: $TAB" +pass "admin notice and Divi tab show the incompatible feature with its tools" + +# 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" +STATUS=$(tool lit-site-status '{}') +echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .features | all(.state == "released")' >/dev/null \ + || fail "every feature must be released again: $STATUS" +[ -z "$(wp --user="$ADMIN_LOGIN" eval '(new LIT\MCP\Admin\DiviTab())->renderNotice();')" ] \ + || fail "no admin notice while every feature is usable" +pass "released again: writing works, matrix green, no admin notice" + +# 5. WP-CLI: prints the matrix, checks fresh (a poisoned cache is not trusted) and exits 0 when all is usable +simulate_problem page-write "Smoke: Cache vergiftet" +CLI=$(wp --user="$ADMIN_LOGIN" lit-mcp divi-check 2>&1) || fail "wp lit-mcp divi-check must succeed (exit 0): $CLI" +echo "$CLI" | grep -q '^Fähigkeiten:' || fail "divi-check must print the matrix: $CLI" +echo "$CLI" | grep -q '✓ Seiten schreiben – freigegeben (code)' || fail "divi-check must show page-write released: $CLI" +[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "11" ] || fail "divi-check must show eleven released features: $CLI" +[ "$(check_pages)" = "0" ] || fail "the CLI check must delete its page" +pass "wp lit-mcp divi-check prints eleven released features, recomputes the state and leaves no page" + +finish +``` + +- [ ] **Step 4: Syntax prüfen** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && bash -n tests/Integration/divi-release-smoke.sh && (command -v shellcheck >/dev/null && shellcheck -x -e SC1091 tests/Integration/divi-release-smoke.sh || true)` +Expected: keine Ausgabe von `bash -n`; shellcheck (falls vorhanden) ohne Fehler. Die `jq`-Ausdrücke wurden gegen erfundene Antworten der erwarteten Form geprüft (alle liefern `true`/Exit 0). + +- [ ] **Step 5: Smoke `divi-release-smoke.sh` laufen lassen** + +Das Skript läuft nur mit deaktivierten Plugins `postlos` und `asynclistry`. Nur deaktivieren, wenn sie aktiv waren, und danach wieder aktivieren (auch nach einem Fehlschlag). Der Lauf dauert bis zu 15 Minuten, deshalb mit `run_in_background` starten: + +```bash +bash -c ' +cd /home/janniclas/Projekte/WordPress +was="" +for p in postlos asynclistry; do + if ddev wp plugin is-active "$p" >/dev/null 2>&1; then was="$was $p"; ddev wp plugin deactivate "$p"; fi +done +timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh; rc=$? +for p in $was; do ddev wp plugin activate "$p"; done +exit $rc +' +``` + +Expected: `ok - …`-Zeilen für Manifeste, Status (elf Fähigkeiten), Admin-Prüfung (REST), anonyme Ablehnung, Lesen trotz gesperrtem `page-write`, abgelehnten Insert, abgelehntes `content-create` mit `divi`, `content-create` ohne `divi`, Status mit einer inkompatiblen Fähigkeit, Admin-Hinweis und Tab, Wiederfreigabe, CLI mit elf freigegebenen Fähigkeiten; `ALL CHECKS PASSED`; Exit-Code 0; `ddev wp plugin list --status=active --field=name` zeigt danach `postlos` und `asynclistry` wieder, wenn sie vorher aktiv waren. + +Danach prüfen, dass nichts übrig blieb: `ddev wp post list --post_type=page --post_status=any --s="LIT Freigabe Smoke" --field=ID` (leer), `ddev wp transient get lit_mcp_divi_state` (kein vergifteter Eintrag: ohne Problemtext „Smoke“), `ddev wp option get lit_mcp_divi_check_lock` (leer). + +Scheitert ein Schritt: erst klären, ob Skript oder Produktionscode falsch liegt. Mögliche Abweichungen: Form des State-Caches (`simulate_problem` meldet „state cache missing or in another format“ – dann die Cache-Form aus Task 6 übernehmen), Name der Fehlerdetails (`features`), Ort der Fähigkeitsnennung in `message`/`hint`, Ausgabe von `DiviTab::render()` unter WP-CLI (braucht `wp-admin/includes/template.php`). Nicht die Prüfung lockern, sondern die Ursache beheben. + +- [ ] **Step 6: Gesamtlauf, Lint, weitere Smokes** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: alle Tests grün (Stand vor diesem Zwischenschritt 2624 plus die neuen Tests aus Tasks 1–10; Task 10 allein bringt 30, Task 11 einen), phpcs ohne Meldung. + +Dann nacheinander mit denselben Plugin-Schritten wie in Step 5 (jeweils mit `run_in_background`): `divi-write-smoke.sh`, `divi-presets-smoke.sh`, `divi-presets-assign-smoke.sh` (Regression der Schreib-Tools nach Task 7/9), außerdem `divi-library-smoke.sh` und `tb-smoke.sh` (Fähigkeiten `library-*`, `tb-*`); alle enden mit `ALL CHECKS PASSED`. Das Plugin-Skript je Smoke durch den Dateinamen ersetzen: + +```bash +bash -c ' +cd /home/janniclas/Projekte/WordPress +was="" +for p in postlos asynclistry; do + if ddev wp plugin is-active "$p" >/dev/null 2>&1; then was="$was $p"; ddev wp plugin deactivate "$p"; fi +done +rc=0 +for s in divi-write-smoke divi-presets-smoke divi-presets-assign-smoke divi-library-smoke tb-smoke; do + timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/$s.sh || { rc=$?; echo "FAILED: $s"; break; } +done +for p in $was; do ddev wp plugin activate "$p"; done +exit $rc +' +``` + +- [ ] **Step 7: Commits** + +Task 10 ist bereits committet (Step 9 dort). Hier zwei Commits, nur die genannten Dateien stagen, Commit wie in den Global Constraints: + +```bash +git add docs/guides/overview.md docs/guides/divi-structure.md docs/guides/workflows.md \ + docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md tests/Unit/Integrations/Divi/Release/FeatureTextsTest.php +git commit -m "docs: Guides und Entscheidungsnotiz zur Divi-Freigabe je Fähigkeit" -m "Co-Authored-By: Claude " + +git add tests/Integration/divi-release-smoke.sh +git commit -m "test: Smoke prüft die Fähigkeiten-Matrix in lit-site-status, Ablehnung nur der betroffenen Werkzeuge, Admin-Hinweis und divi-check" -m "Co-Authored-By: Claude " +``` + +Memory- und Stand-Notizen (`MEMORY.md`, `etappe-4-stand.md`) fasst dieser Task nicht an; das macht der Koordinator. + +--- + +## Offene Punkte (Tasks 10 und 11) + +- **Cache-Form (Task 6):** Das Smoke setzt `['key' => …, 'features' => [ => list]]` voraus. Weicht Task 6 ab, ist `simulate_problem()` die einzige Stelle, die angepasst werden muss. +- **Smoke und Plugin-Verdrahtung wurden nicht ausgeführt:** Das Smoke ist per `bash -n` und gegen erfundene JSON-Antworten geprüft, nicht gegen die Dev-Datenbank. Die Verdrahtung in `Plugin.php` und der Aufruf von `DiviTab::render()`/`renderNotice()` unter WP-CLI sind ungeprüft, bis die Tasks 2/3/6 stehen. +- **Freigabe-Knopf bei gemischter Matrix:** Die Formulare im Tab richten sich weiter nach dem Gesamtzustand (`ReleaseStatus::state`, Semantik aus Task 6). Wie der Gesamtzustand bei gemischter Matrix aussieht (z. B. `incompatible` vor `untested`), entscheidet, ob der Freigabe-Knopf erscheint; die Freigabe selbst verlangt ohnehin einen vollständig grünen Bericht. +- **`lit-content-create` in der Tool-Liste:** Nicht aufgeführt, weil `diviFeatures([])` leer ist (nur mit `divi` braucht es `PageWrite`). Die Ablehnung nennt die Fähigkeit. +- **Exit-Code 2** ist neu und wird von Skripten, die nur 0/1 kennen, als Fehler gelesen – gewollt (E3), aber in der Notiz begründet. +- **Der Admin-Hinweis** erscheint nicht auf dem Reiter „Divi“, aber auf allen übrigen Admin-Seiten; er ist nicht wegklickbar (verschwindet mit der Ursache). +- **Plan-Rest:** Task 3 sollte die Wiring-Tests um `has_action('admin_notices', [DiviTab, 'renderNotice'])` ergänzen, falls Tab und Hinweis dort registriert werden. + +## Verifiziert + +- Arbeitskopie von HEAD e7793fd (`cp -a` samt `vendor`, nach dem Lauf gelöscht) mit minimalen Vorwegnahmen der Gerüst-Signaturen (`DiviFeature`, `FeatureStatus`, `CompatibilityMatrix`, `RequiresDiviFeatures`, `ReleaseStatus::$matrix`, `EnvironmentComponent`, `DiviEnvironmentComponent` in der Task-2-Form): `composer test` grün – 2655 Tests, 15820 Assertions (Ausgangslage 2624, +31, davon 30 aus Task 10: `FeatureOverviewTest` 4, `DiviEnvironmentComponentTest` 3, `SiteStatusAbilityTest` +2, `DiviIntegrationTest` +2, `DiviTabTest` +12, `DiviCheckCommandTest` +7, dazu `FeatureTextsTest` 1 aus Task 11). +- `composer lint`: sauber bis auf den `$this`-in-Enum-Befund im Enum-Stub (siehe Annahme 5); `FeatureTextsTest` wurde einmal rot gesehen (`overview.md still says "lesende Divi-Werkzeuge funktionieren weiter"`) und nach Guide- und `ReleaseStatus`-Änderung grün; die übrigen RED-Meldungen oben sind aus dem Aufbau abgeleitet, nicht einzeln ausgeführt. +- `bash -n tests/Integration/divi-release-smoke.sh` ohne Meldung; alle neuen `jq`-Ausdrücke gegen erfundene Antworten (Soll- und Abweichungsfall, Fehlerdetails mit `features`) mit Exit 0. +- Nicht ausgeführt: Integrationsskripte (Auftrag), `Plugin.php`-Verdrahtung, Task-6-abhängige Teile gegen echten Code. + diff --git a/docs/superpowers/plans/2026-10-18-plaene-uebersicht.html b/docs/superpowers/plans/2026-10-18-plaene-uebersicht.html new file mode 100644 index 0000000..075a094 --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-plaene-uebersicht.html @@ -0,0 +1,14795 @@ + + + + + + + Etappe 4 – Pläne im Überblick Diagram + + + + + + + + + + + +
    + +
    +
    +
    +

    Etappe 4 – Pläne im Überblick

    +
    +
    + + + + + + + +
    + + Etappe 4 – Pläne im Überblick + A workflow diagram generated by Archify. + + + + + + + + + + + + + + + + + + + + + + + + + 01 / Fertig + + + 02 / Zwischenschritt (geplant) + + + 03 / Danach + + + + + Umgesetzt + + + Kern entkoppeln + Freigabe je Fähigkeit + + + + + + + + + + + + + + + 4a Presets · Pipeline + Snapshots · Fertig › Umgesetzt + + + + 4a Presets + Pipeline + Snapshots + + + + 4b Zuweisen · assign + detach · Fertig › Umgesetzt · 2624 Tests + + + + 4b Zuweisen + assign + detach + 2624 Tests + + + + Kern entkoppeln · Guard + Ports · Zwischenschritt (geplant) › Kern entkoppeln + Freigabe je Fähigkeit · Tasks 1–3 + + + + Kern entkoppeln + Guard + Ports + Tasks 1–3 + + + + Fähigkeiten · Manifest + Signaturen · Zwischenschritt (geplant) › Kern entkoppeln + Freigabe je Fähigkeit · Tasks 4–5 + + + + Fähigkeiten + Manifest + Signaturen + Tasks 4–5 + + + + Matrix + Sperre · je Fähigkeit · Zwischenschritt (geplant) › Kern entkoppeln + Freigabe je Fähigkeit · Tasks 6–9 + + + + Matrix + Sperre + je Fähigkeit + Tasks 6–9 + + + + Anzeige · Status, Admin, CLI · Zwischenschritt (geplant) › Kern entkoppeln + Freigabe je Fähigkeit · Tasks 10–11 + + + + Anzeige + Status, Admin, CLI + Tasks 10–11 + + + + 4c – 4e · Farben bis Version · Danach › Kern entkoppeln + Freigabe je Fähigkeit + + + + 4c – 4e + Farben bis Version + + + + + + + weiter + + + + + + + dann + + + + + +

    + + + + + + + + + +
    + + +
    +
    +
    +
    +

    4b umgesetzt

    +
    +
      +
    • • Preset-Stapel, Gruppen-Presets, clear_overrides
    • +
    • • lit/divi-presets-assign und lit/divi-design-detach
    • +
    • • Höchstens 50 Seiten je Aufruf, Revision je Seite
    • +
    +
    + +
    +
    +
    +

    Zwischenschritt

    +
    +
      +
    • • Registrar und Kern ohne Divi-Import
    • +
    • • Jede Divi-Anforderung gehört zu Fähigkeiten
    • +
    • • Nur betroffene Tools werden gesperrt
    • +
    • • Fail closed: Adapter prüfen weiter je Fähigkeit
    • +
    +
    + +
    +
    +
    +

    Entscheidungen

    +
    +
      +
    • • Neue Divi-Version: Freigabe je Fähigkeit
    • +
    • • Lese-Tools werden bei Bruch abgelehnt
    • +
    • • Tools bleiben registriert, Aufruf lehnt ab
    • +
    +
    +
    + +
    + + + + diff --git a/docs/superpowers/plans/2026-10-18-plaene-uebersicht.workflow.json b/docs/superpowers/plans/2026-10-18-plaene-uebersicht.workflow.json new file mode 100644 index 0000000..275b8c2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-plaene-uebersicht.workflow.json @@ -0,0 +1,178 @@ +{ + "schema_version": 2, + "diagram_type": "workflow", + "meta": { + "title": "Etappe 4 – Pläne im Überblick", + "output": "2026-10-18-plaene-uebersicht.html", + "quality_profile": "showcase", + "viewBox": [ + 1000, + 534 + ], + "legend": { + "mode": "hidden" + } + }, + "lanes": [ + { + "id": "done", + "label": "Fertig" + }, + { + "id": "next", + "label": "Zwischenschritt (geplant)" + }, + { + "id": "later", + "label": "Danach" + } + ], + "phases": [ + { + "id": "built", + "label": "Umgesetzt", + "fromCol": 0, + "toCol": 1 + }, + { + "id": "between", + "label": "Kern entkoppeln + Freigabe je Fähigkeit", + "fromCol": 2, + "toCol": 5, + "variant": "emphasis" + } + ], + "mainPath": [ + "stage_4a", + "stage_4b", + "decouple", + "features", + "matrix_gate", + "display", + "stage_4c_e" + ], + "nodes": [ + { + "id": "stage_4a", + "lane": "done", + "col": 0, + "type": "database", + "label": "4a Presets", + "sublabel": "Pipeline + Snapshots" + }, + { + "id": "stage_4b", + "lane": "done", + "col": 1, + "type": "database", + "label": "4b Zuweisen", + "sublabel": "assign + detach", + "tag": "2624 Tests" + }, + { + "id": "decouple", + "lane": "next", + "col": 2, + "type": "backend", + "label": "Kern entkoppeln", + "sublabel": "Guard + Ports", + "tag": "Tasks 1–3", + "width": 112 + }, + { + "id": "features", + "lane": "next", + "col": 3, + "type": "backend", + "label": "Fähigkeiten", + "sublabel": "Manifest + Signaturen", + "tag": "Tasks 4–5" + }, + { + "id": "matrix_gate", + "lane": "next", + "col": 4, + "type": "security", + "label": "Matrix + Sperre", + "sublabel": "je Fähigkeit", + "tag": "Tasks 6–9", + "width": 112 + }, + { + "id": "display", + "lane": "next", + "col": 5, + "type": "frontend", + "label": "Anzeige", + "sublabel": "Status, Admin, CLI", + "tag": "Tasks 10–11" + }, + { + "id": "stage_4c_e", + "lane": "later", + "col": 5, + "type": "cloud", + "label": "4c – 4e", + "sublabel": "Farben bis Version" + } + ], + "edges": [ + { + "from": "stage_4a", + "to": "stage_4b" + }, + { + "from": "stage_4b", + "to": "decouple", + "label": "dann" + }, + { + "from": "decouple", + "to": "features" + }, + { + "from": "features", + "to": "matrix_gate", + "variant": "emphasis" + }, + { + "from": "matrix_gate", + "to": "display" + }, + { + "from": "display", + "to": "stage_4c_e", + "label": "weiter" + } + ], + "cards": [ + { + "dot": "emerald", + "title": "4b umgesetzt", + "items": [ + "Preset-Stapel, Gruppen-Presets, clear_overrides", + "lit/divi-presets-assign und lit/divi-design-detach", + "Höchstens 50 Seiten je Aufruf, Revision je Seite" + ] + }, + { + "dot": "cyan", + "title": "Zwischenschritt", + "items": [ + "Registrar und Kern ohne Divi-Import", + "Jede Divi-Anforderung gehört zu Fähigkeiten", + "Nur betroffene Tools werden gesperrt", + "Fail closed: Adapter prüfen weiter je Fähigkeit" + ] + }, + { + "dot": "rose", + "title": "Entscheidungen", + "items": [ + "Neue Divi-Version: Freigabe je Fähigkeit", + "Lese-Tools werden bei Bruch abgelehnt", + "Tools bleiben registriert, Aufruf lehnt ab" + ] + } + ] +}