# 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.