Files
WP-LIT-MCP/docs/superpowers/plans/2026-10-18-divi-faehigkeiten.md
T

657 KiB
Raw Blame History

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/<name>, direkte lit-<name> (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 "<Nachricht>" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>" 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<AbilityGuard>, 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<EnvironmentComponent> 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<MediaUsageSource>
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:<Symbol>"
src/Integrations/Divi/Release/Problem.php         (neu)  Befund mit Fähigkeiten und Art
src/Integrations/Divi/Release/ManifestCheck.php   (geändert) problems(): list<Problem>
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

// 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<AbilityGuard>, 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<string>, release: ?array} */
    public function describe(): array;
}
interface MediaUsageSource      // src/Integrations/Media/MediaUsageSource.php
{
    /** @return list<array<string, mixed>> 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<DiviFeature> */ 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<DiviFeature>   // 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<string,string> enthält zusätzlich Schlüssel "sig:<symbol>".

// 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<string> */,
    public readonly ?string $source /* 'code'|'database'|'auto'|null */) {} public function usable(): bool; }
final class CompatibilityMatrix {
    /** @param array<string, FeatureStatus> $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<FeatureStatus> */ public function unusable(): array;
    public function toArray(): array;   // for lit-site-status and the admin tab
}
// ManifestCheck::problems(): list<Problem>   (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<DiviFeature> 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<AbilityGuard>, 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):

--- 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<AbilityGuard> $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:

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

declare(strict_types=1);

namespace LIT\MCP\Abilities;

use WP_Error;

defined('ABSPATH') || exit;

/**
 * A cross-cutting rule the AbilityRegistrar applies to every ability it registers: it may refuse a call before the
 * ability runs, add notes to the result and extend the output schema accordingly (e.g. the Divi release lock).
 * The registrar knows only this interface; which guards exist is decided in Plugin (composition root).
 */
interface AbilityGuard
{
    /**
     * The refusal for this call before execute() runs, or null to let it through.
     *
     * @param array<string, mixed> $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<string, mixed>|null $schema
     * @return array<string, mixed>|null
     */
    public function outputSchema(Ability $ability, ?array $schema): ?array;
}

src/Abilities/ToolRefusal.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Abilities;

use WP_Error;

defined('ABSPATH') || exit;

/**
 * Implemented by exceptions that stand for a deliberate refusal instead of a failure (e.g. DiviLocked): thrown
 * anywhere below an ability's execute(), also wrapped as the cause of a WriteFailed, the AbilityRegistrar answers
 * with toError() instead of internal_error.
 */
interface ToolRefusal
{
    /** The error the client gets for this refusal. */
    public function toError(): WP_Error;
}
  • Step 4: AbilityRegistrar umbauen

src/Abilities/AbilityRegistrar.php: beide Divi-Imports entfallen, $divi wird zu $guards, execute_callback fragt die Guards der Reihe nach, das Ausgabeschema wird durch alle Guards gereicht und errorFor() sucht die erste ToolRefusal statt DiviLocked::in():

--- a/src/Abilities/AbilityRegistrar.php
+++ b/src/Abilities/AbilityRegistrar.php
@@ -5,8 +5,6 @@ declare(strict_types=1);
 namespace LIT\MCP\Abilities;
 
 use LIT\MCP\Access;
-use LIT\MCP\Integrations\Divi\Release\DiviLocked;
-use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
 use LIT\MCP\Integrations\IntegrationRegistry;
 use LIT\MCP\Log;
 use LIT\MCP\Server\RequestContext;
@@ -17,16 +15,20 @@ 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 AbilityGuards it is given (e.g. the Divi release lock) and neutral error handling.
+ * It knows no integration: guards and ToolRefusal exceptions are the only seams.
  */
 final class AbilityRegistrar
 {
     public const CATEGORY = 'lit-mcp';
 
+    /**
+     * @param list<AbilityGuard> $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<string, mixed> */
     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):

--- 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:

--- 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):

--- 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
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 <Modellname> <noreply@anthropic.com>"

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<MediaUsageSource>; siteUsage() öffentlich; kein DesignSystemReader)
  • Modify: src/Integrations/Media/MediaIntegration.php (list<MediaUsageSource> $usageSources)
  • Modify: src/Integrations/Core/EnvironmentProbe.php, src/Integrations/Core/CoreIntegration.php (list<EnvironmentComponent>)
  • 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<string>, 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:<where> 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<EnvironmentComponent>): 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 (<!-- wp:divi/, [et_pb_) und wird von jedem Schreibpfad für Kundentext gebraucht (PostWriter, MediaUpdateAbility), auch ohne Divi-Integration; der Docblock sagt das in einem Satz.
  • Bleibt unverändert (Hinweis): die String-Kopien der Gruppen-IDs in Guides/InstructionsBuilder (divi-library, divi-theme-builder) und die Aufzählung divi_logo in MediaUsageFinder::WHERE/usageSchema() sind Zeichenketten ohne Divi-Klassenbezug.

Bestehende Tests, die sich ändern (alle in tests/Unit/Integrations/, jeweils Grund in Klammern):

  • WordPress/Content/PostPresenterTest::testDiviContentIsWithheldAndPointsToTheDiviOutline (Builder-Flag kommt jetzt aus einem FakeBuilderDetector statt aus get_post_meta; Hinweis über die Instanz), testDiviHintNamesTheDiviToolsOnlyWhenDiviIsActive (Instanzaufruf statt statisch), testDiviIsAlsoDetectedFromBlockMarkup (Instanzaufruf); neu testTheBuilderDetectorDecidesWhichNoteApplies, testDiviIsDetectedFromTheBuilderFlagOfTheDetector.

  • WordPress/Content/ContentUpdateAbilityTest::testRawContentOnDiviPagesIsUnsupported (Presenter mit FakeBuilderDetector(false, [5]) statt get_post_meta = 'on'; Helfer ability() nimmt einen Presenter).

  • WordPress/Revisions/RevisionsListAbilityTest::testMarksDiviRevisionsAndAddsTheHintOnce (Presenter mit Detektor, Hinweis über die Instanz).

  • WordPress/Schema/ContentSchemaTest::testPostTypeNamesExcludeInternalTypesAndAreSorted und testDescribeIncludesTaxonomiesCountsDiviAndOptionalFields (et_pb_layout kommt jetzt über den Konstruktor); testExcludedPostTypesStayTheSame entfällt zugunsten von testWordPressExcludesItsOwnPostTypesAndIntegrationsAddTheirs (die 18er-Liste prüft jetzt DiviIntegrationTest::testDiviPostTypesAddedToTheSchemaGiveTheFormerExcludedList); neu testTheBuilderIsNeverOnWithoutACheck.

  • Media/MediaUsageFinderTest::testUnreadableEntriesHideTheirTitleAndDiviLogoIsPossible wird zu testUnreadableEntriesHideTheirTitleAndASourceUsageIsListedAfterTheCoreOnes (Fake-Quelle statt et_divi-Option; die Divi-Logo-Fälle prüft DiviLogoUsageTest); neu testASourcePlaceIsReportedOnceAndSourcesAreSkippedOnceTheLimitIsReached.

  • Core/EnvironmentProbeTest (beide Divi-Tests wandern nach Divi/DiviEnvironmentComponentTest; die Datei prüft jetzt Reihenfolge und Standard mit Fake-Komponenten); Core/CoreIntegrationTest (neu testSiteStatusReportsTheComponentsOfTheIntegrations); Divi/DiviIntegrationTest (drei neue Tests für die Ports).

  • Step 1: CoreIndependenceTest schreiben (zuerst – er zeigt, was zu entkoppeln ist)

Datei tests/Unit/Architecture/CoreIndependenceTest.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Architecture;

use FilesystemIterator;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

/**
 * The generic core knows no Divi class: nothing below the CORE_PATHS imports or names a class of
 * LIT\MCP\Integrations\Divi. Divi plugs in through ports (AbilityGuard, ToolRefusal, BuilderDetector,
 * MediaUsageSource, EnvironmentComponent, ContentSchema's excluded post types) that Plugin.php – the composition
 * root, outside these paths – wires up. Divi's own names (et_* …) are DiviBoundaryTest's business, not this test's.
 */
final class CoreIndependenceTest extends TestCase
{
    /** Directories below src/ that must not use Divi classes. */
    private const CORE_PATHS = [
        'Abilities',
        'Integrations/Core',
        'Integrations/WordPress',
        'Integrations/Media',
        'Admin',
        'Guides',
        'Server',
    ];

    /** Files inside CORE_PATHS that may use Divi classes, each with its reason. */
    private const EXCEPTIONS = [
        'Admin/DiviTab.php' => 'The settings tab "Divi" is Divi UI: release state, admin check and release/withdraw.',
    ];

    public function testTheCoreUsesNoDiviClass(): void
    {
        $offenders = [];
        foreach (self::coreFiles() as $relative => $path) {
            if (isset(self::EXCEPTIONS[$relative])) {
                continue;
            }
            $names = self::diviNames((string) file_get_contents($path));
            if ($names !== []) {
                $offenders[] = $relative . ': ' . implode(', ', $names);
            }
        }

        self::assertSame([], $offenders, "Der Kern darf keine Divi-Klasse benutzen (Port über Plugin.php verdrahten):\n"
            . implode("\n", $offenders));
    }

    public function testEveryExceptionExistsAndStillNeedsIt(): void
    {
        $files = self::coreFiles();
        foreach (array_keys(self::EXCEPTIONS) as $relative) {
            self::assertArrayHasKey($relative, $files, $relative . ' gibt es nicht mehr – Ausnahme streichen');
            self::assertNotSame(
                [],
                self::diviNames((string) file_get_contents($files[$relative])),
                $relative . ' braucht die Ausnahme nicht mehr – streichen'
            );
        }
    }

    public function testTheScanFindsImportsQualifiedNamesGroupUsesAndClassStrings(): void
    {
        $source = <<<'PHP'
            <?php
            use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
            use LIT\MCP\Integrations\{Media\Finder, Divi\Pages\DiviPages};
            $a = \LIT\MCP\Integrations\Divi\Schema\DiviModules::isAvailable();
            $b = 'LIT\\MCP\\Integrations\\Divi\\Design\\DesignSystemReader';
            $c = "LIT\\MCP\\Integrations\\Divi\\Library\\LibraryAccess";
            PHP;

        self::assertSame(
            [
                'Divi\Pages\DiviPages',
                'LIT\MCP\Integrations\Divi\Design\DesignSystemReader',
                'LIT\MCP\Integrations\Divi\Library\LibraryAccess',
                'LIT\MCP\Integrations\Divi\Release\ReleaseGate',
                'LIT\MCP\Integrations\Divi\Schema\DiviModules',
            ],
            self::diviNames($source)
        );
    }

    public function testTheScanIgnoresCommentsOtherNamespacesAndPlainWords(): void
    {
        $source = <<<'PHP'
            <?php
            // use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
            /** @see \LIT\MCP\Integrations\Divi\Pages\DiviPages */
            use LIT\MCP\Integrations\Media\MediaUsageSource;
            use LIT\MCP\Abilities\DiviMarkup;
            $x = 'Divi';
            $y = 'Ein Divi-Seitenaufbau';
            PHP;

        self::assertSame([], self::diviNames($source));
    }

    /** @return list<string> Sorted, unique names of Divi classes the source uses. */
    private static function diviNames(string $source): array
    {
        $names = [];
        foreach (token_get_all($source) as $token) {
            if (! is_array($token)) {
                continue;
            }
            $text = match ($token[0]) {
                T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED => ltrim($token[1], '\\'),
                T_CONSTANT_ENCAPSED_STRING => ltrim(str_replace('\\\\', '\\', substr($token[1], 1, -1)), '\\'),
                default => '',
            };
            if (preg_match('/^(?:LIT\\\\MCP\\\\Integrations\\\\)?Divi\\\\[A-Za-z0-9_\\\\]+$/', $text) === 1) {
                $names[] = $text;
            }
        }
        $names = array_values(array_unique($names));
        sort($names);

        return $names;
    }

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

        return $files;
    }
}
  • Step 2: RED

Run: php vendor/bin/phpunit tests/Unit/Architecture/CoreIndependenceTest.php Expected: FAIL, ein Fehler in testTheCoreUsesNoDiviClass (die drei Scan-Tests laufen grün):

Der Kern darf keine Divi-Klasse benutzen (Port über Plugin.php verdrahten):
Integrations/Core/EnvironmentProbe.php: LIT\MCP\Integrations\Divi\DiviCompatibility, LIT\MCP\Integrations\Divi\Release\DiviEnvironment, LIT\MCP\Integrations\Divi\Release\ReleaseGate
Integrations/Media/MediaUsageFinder.php: LIT\MCP\Integrations\Divi\Design\DesignSystemReader
Integrations/WordPress/Content/PostPresenter.php: LIT\MCP\Integrations\Divi\Pages\DiviPages, LIT\MCP\Integrations\Divi\Schema\DiviModules
Integrations/WordPress/Schema/ContentSchema.php: LIT\MCP\Integrations\Divi\Schema\DiviModules

(AbilityRegistrar und DiviTab tauchen nicht auf: der Registrar ist seit Task 1 sauber, DiviTab ist ausgenommen.)

  • Step 3: Failing Tests für die Ports schreiben

Testhelfer tests/Support/FakeBuilderDetector.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Support;

use LIT\MCP\Integrations\WordPress\BuilderDetector;

/** A BuilderDetector for tests: fixed availability and a fixed set of builder layouts. */
final class FakeBuilderDetector implements BuilderDetector
{
    /** @param list<int> $layouts IDs of the posts the builder flag marks. */
    public function __construct(private bool $available = true, private array $layouts = [])
    {
    }

    public function isAvailable(): bool
    {
        return $this->available;
    }

    public function usesBuilder(int $postId): bool
    {
        return in_array($postId, $this->layouts, true);
    }
}

tests/Unit/Integrations/Divi/DiviBuilderDetectorTest.php anlegen:

<?php

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\DiviBuilderDetector;
use LIT\MCP\Integrations\WordPress\BuilderDetector;
use LIT\MCP\Tests\Unit\TestCase;

final class DiviBuilderDetectorTest extends TestCase
{
    public function testIsABuilderDetectorThatFollowsDiviFiveBeingLoaded(): void
    {
        $detector = new DiviBuilderDetector();

        self::assertInstanceOf(BuilderDetector::class, $detector);
        self::assertFalse($detector->isAvailable(), 'no Divi classes in unit tests');
    }

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

        self::assertTrue($detector->usesBuilder(7));
        self::assertFalse($detector->usesBuilder(8));
    }
}

tests/Unit/Integrations/Divi/DiviLogoUsageTest.php anlegen (die Divi-Logo-Fälle, die bisher nur indirekt über MediaUsageFinder liefen):

<?php

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\DiviLogoUsage;
use LIT\MCP\Integrations\Media\MediaUsageSource;
use LIT\MCP\Tests\Unit\TestCase;

final class DiviLogoUsageTest extends TestCase
{
    private const POSSIBLE_LOGO = [
        'post_id' => 0,
        'title' => 'Divi-Logo',
        'post_type' => 'option',
        'where' => 'divi_logo',
        'certainty' => 'möglicherweise',
    ];

    private function stub(string $logo, string $file = '2026/09/foto-scaled.jpg'): void
    {
        Functions\when('get_post_meta')->justReturn($file);
        Functions\when('get_option')->alias(
            static fn (string $name): mixed => $name === 'et_divi' ? ['divi_logo' => $logo] : false
        );
    }

    public function testIsAMediaUsageSource(): void
    {
        self::assertInstanceOf(MediaUsageSource::class, new DiviLogoUsage());
    }

    public function testTheLogoThatIsTheFileIsAPossibleUsage(): void
    {
        $this->stub('https://k.de/wp-content/uploads/2026/09/foto.jpg');

        self::assertSame([self::POSSIBLE_LOGO], (new DiviLogoUsage())->usagesOf(33));
    }

    public function testAGeneratedSizeOfTheFileCountsToo(): void
    {
        $this->stub('https://k.de/wp-content/uploads/2026/09/foto-300x200.jpg');

        self::assertSame([self::POSSIBLE_LOGO], (new DiviLogoUsage())->usagesOf(33));
    }

    public function testAnotherFileIsNoUsage(): void
    {
        $this->stub('https://k.de/wp-content/uploads/2026/09/anderes.jpg');

        self::assertSame([], (new DiviLogoUsage())->usagesOf(33));
    }

    public function testWithoutALogoOrWithoutAFileThereIsNoUsage(): void
    {
        $this->stub('');
        self::assertSame([], (new DiviLogoUsage())->usagesOf(33));

        $this->stub('https://k.de/wp-content/uploads/2026/09/foto.jpg', '');
        self::assertSame([], (new DiviLogoUsage())->usagesOf(33));

        Functions\when('get_option')->justReturn(false);
        self::assertSame([], (new DiviLogoUsage())->usagesOf(33));
    }
}

tests/Unit/Integrations/Divi/DiviEnvironmentComponentTest.php anlegen (übernimmt die zwei Divi-Tests aus EnvironmentProbeTest):

<?php

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Core\EnvironmentComponent;
use LIT\MCP\Integrations\Divi\DiviCompatibility;
use LIT\MCP\Integrations\Divi\DiviEnvironmentComponent;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class DiviEnvironmentComponentTest extends TestCase
{
    private function theme(string $template, string $version = '5.2.1'): void
    {
        $theme = Mockery::mock();
        $theme->allows('get_template')->andReturn($template);
        $theme->allows('parent')->andReturn(false);
        $theme->allows('get')->with('Version')->andReturn($version);
        Functions\when('wp_get_theme')->justReturn($theme);
    }

    public function testIsAnEnvironmentComponent(): void
    {
        self::assertInstanceOf(EnvironmentComponent::class, new DiviEnvironmentComponent());
    }

    public function testCarriesTheCompatibilityFindings(): void
    {
        $this->theme('Divi');
        $component = new DiviEnvironmentComponent(
            new DiviCompatibility(['GlobalLayout' => static fn (): bool => false])
        );

        $described = $component->describe();

        self::assertSame('divi', $described['id']);
        self::assertTrue($described['active']);
        self::assertSame('5.2.1', $described['version']);
        self::assertSame(['GlobalLayout'], $described['problems']);
    }

    public function testCarriesTheReleaseStatus(): void
    {
        $this->theme('Divi');

        $described = (new DiviEnvironmentComponent(new DiviCompatibility([])))->describe();

        self::assertSame([], $described['problems']);
        self::assertSame('released', $described['release']['state'] ?? null);
    }

    public function testIsInactiveWithoutDiviAndHasNoProblemsOrRelease(): void
    {
        $this->theme('twentytwentysix', '1.0');

        $described = (new DiviEnvironmentComponent(
            new DiviCompatibility(['GlobalLayout' => static fn (): bool => false])
        ))->describe();

        self::assertSame(
            ['id' => 'divi', 'active' => false, 'version' => null, 'problems' => [], 'release' => null],
            $described
        );
    }
}

tests/Unit/Integrations/Core/EnvironmentProbeTest.php komplett ersetzen:

<?php

declare(strict_types=1);

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

use LIT\MCP\Integrations\Core\EnvironmentComponent;
use LIT\MCP\Integrations\Core\EnvironmentProbe;
use LIT\MCP\Tests\Unit\TestCase;

final class EnvironmentProbeTest extends TestCase
{
    private function component(string $id, array $problems = []): EnvironmentComponent
    {
        return new class ($id, $problems) implements EnvironmentComponent {
            public function __construct(private string $id, private array $problems)
            {
            }

            public function describe(): array
            {
                return [
                    'id' => $this->id,
                    'active' => true,
                    'version' => '1.2.3',
                    'problems' => $this->problems,
                    'release' => ['state' => 'released'],
                ];
            }
        };
    }

    public function testGivenComponentsComeFirstInTheOrderGivenFollowedByPodsAndTheEventsCalendar(): void
    {
        $probe = new EnvironmentProbe([$this->component('builder', ['Etwas fehlt']), $this->component('shop')]);

        $components = $probe->components();

        self::assertSame(['builder', 'shop', 'pods', 'the-events-calendar'], array_column($components, 'id'));
        self::assertSame(['Etwas fehlt'], $components[0]['problems']);
        self::assertSame(['state' => 'released'], $components[1]['release']);
    }

    public function testWithoutComponentsOnlyPodsAndTheEventsCalendarAreReported(): void
    {
        $components = array_column((new EnvironmentProbe())->components(), null, 'id');

        self::assertSame(['pods', 'the-events-calendar'], array_keys($components));
        self::assertFalse($components['pods']['active'], 'no Pods in unit tests');
        self::assertSame([], $components['pods']['problems']);
        self::assertNull($components['pods']['release']);
        self::assertNull($components['the-events-calendar']['version']);
    }
}

Bestehende Tests anpassen und ergänzen:

--- a/tests/Unit/Integrations/Core/CoreIntegrationTest.php
+++ b/tests/Unit/Integrations/Core/CoreIntegrationTest.php
@@ -4,8 +4,11 @@ declare(strict_types=1);
 
 namespace LIT\MCP\Tests\Unit\Integrations\Core;
 
+use Brain\Monkey\Functions;
 use LIT\MCP\Integrations\Core\CoreIntegration;
+use LIT\MCP\Integrations\Core\EnvironmentComponent;
 use LIT\MCP\Tests\Unit\TestCase;
+use WP_User;
 
 final class CoreIntegrationTest extends TestCase
 {
@@ -20,4 +23,23 @@ final class CoreIntegrationTest extends TestCase
             array_map(static fn ($a) => $a->name(), $core->abilities())
         );
     }
+
+    public function testSiteStatusReportsTheComponentsOfTheIntegrations(): void
+    {
+        Functions\when('get_bloginfo')->justReturn('x');
+        Functions\when('home_url')->justReturn('https://kunde.de');
+        Functions\when('wp_get_current_user')->justReturn(new WP_User(3, ['editor']));
+        Functions\when('get_option')->justReturn(['editor']);
+        $component = new class implements EnvironmentComponent {
+            public function describe(): array
+            {
+                return ['id' => 'builder', 'active' => true, 'version' => '9', 'problems' => [], 'release' => null];
+            }
+        };
+        $core = new CoreIntegration(dirname(__DIR__, 3) . '/Fixtures/guides', [$component]);
+
+        $status = $core->abilities()[1]->execute([]);
+
+        self::assertSame(['builder', 'pods', 'the-events-calendar'], array_column($status['components'], 'id'));
+    }
 }
--- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php
+++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php
@@ -6,8 +6,14 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi;
 
 use Brain\Monkey\Functions;
 use LIT\MCP\Abilities\Ability;
+use LIT\MCP\Integrations\Core\EnvironmentComponent;
+use LIT\MCP\Integrations\Divi\DiviBuilderDetector;
+use LIT\MCP\Integrations\Divi\DiviEnvironmentComponent;
 use LIT\MCP\Integrations\Divi\DiviIntegration;
+use LIT\MCP\Integrations\Divi\DiviLogoUsage;
 use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
+use LIT\MCP\Integrations\Media\MediaUsageSource;
+use LIT\MCP\Integrations\WordPress\BuilderDetector;
 use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
 use LIT\MCP\Tests\Support\FakeChangeLog;
 use LIT\MCP\Tests\Support\LibraryFixtures;
@@ -58,6 +64,43 @@ final class DiviIntegrationTest extends TestCase
         }
     }
 
+    /** Spec §3: the ports the core uses (Plugin wires them) are Divi's own implementations. */
+    public function testProvidesThePortsTheCoreUses(): void
+    {
+        $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
+
+        self::assertInstanceOf(BuilderDetector::class, $integration->builderDetector());
+        self::assertInstanceOf(DiviBuilderDetector::class, $integration->builderDetector());
+        $sources = $integration->mediaUsageSources();
+        self::assertCount(1, $sources);
+        self::assertInstanceOf(MediaUsageSource::class, $sources[0]);
+        self::assertInstanceOf(DiviLogoUsage::class, $sources[0]);
+        self::assertInstanceOf(EnvironmentComponent::class, $integration->environmentComponent());
+        self::assertInstanceOf(DiviEnvironmentComponent::class, $integration->environmentComponent());
+    }
+
+    /** The content tools leave the same post types alone as before the schema stopped knowing Divi. */
+    public function testDiviPostTypesAddedToTheSchemaGiveTheFormerExcludedList(): void
+    {
+        self::assertSame(
+            [
+                'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
+                'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'et_pb_layout',
+                'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout',
+                'et_pb_canvas', '_et_pb_speculation',
+            ],
+            array_merge(ContentSchema::EXCLUDED, DiviIntegration::internalPostTypes())
+        );
+    }
+
+    public function testTheSchemaAsksDiviWhetherTheBuilderIsOnForAPostType(): void
+    {
+        Functions\when('et_builder_enabled_for_post_type')->alias(static fn (string $type): bool => $type === 'page');
+
+        self::assertTrue(DiviIntegration::builderEnabledFor('page'));
+        self::assertFalse(DiviIntegration::builderEnabledFor('post'));
+    }
+
     public function testProvidesTheLibraryGroup(): void
     {
         Functions\when('__')->returnArg();
--- a/tests/Unit/Integrations/Media/MediaUsageFinderTest.php
+++ b/tests/Unit/Integrations/Media/MediaUsageFinderTest.php
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Media;
 
 use Brain\Monkey\Functions;
 use LIT\MCP\Integrations\Media\MediaUsageFinder;
+use LIT\MCP\Integrations\Media\MediaUsageSource;
 use LIT\MCP\Tests\Unit\TestCase;
 use Mockery;
 use Mockery\MockInterface;
@@ -21,6 +22,7 @@ final class MediaUsageFinderTest extends TestCase
      * @param list<object>         $fields
      * @param array<string, mixed> $options   Option name => value.
      * @param array<string, mixed> $themeMods Theme mod name => value.
+     * @param list<MediaUsageSource> $sources
      */
     private function finder(
         array $contents = [],
@@ -29,7 +31,8 @@ final class MediaUsageFinderTest extends TestCase
         array $options = [],
         array $themeMods = [],
         array $mediaKeys = [],
-        string $file = '2026/09/foto-scaled.jpg'
+        string $file = '2026/09/foto-scaled.jpg',
+        array $sources = []
     ): MediaUsageFinder {
         Functions\when('get_post_meta')->justReturn($file);
         Functions\when('get_option')->alias(static fn (string $name) => $options[$name] ?? false);
@@ -70,7 +73,7 @@ final class MediaUsageFinderTest extends TestCase
             return [];
         });
 
-        return new MediaUsageFinder(static fn (): array => $mediaKeys, $db);
+        return new MediaUsageFinder(static fn (): array => $mediaKeys, $db, $sources);
     }
 
     /** @return array<int|string, string> post_id (or title for site usages) => certainty */
@@ -269,23 +272,78 @@ final class MediaUsageFinderTest extends TestCase
         self::assertSame(['header_image', 'background_image', 'widget', 'widget', 'widget', 'widget'], array_column($usages, 'where'));
     }
 
-    public function testUnreadableEntriesHideTheirTitleAndDiviLogoIsPossible(): void
+    /**
+     * @param list<array<string, mixed>> $usages
+     * @param list<int>                  $asked  Collects the attachment IDs the source is asked about.
+     */
+    private static function source(array $usages, array &$asked = []): MediaUsageSource
+    {
+        return new class ($usages, $asked) implements MediaUsageSource {
+            public function __construct(private array $usages, private array &$asked)
+            {
+            }
+
+            public function usagesOf(int $attachmentId): array
+            {
+                $this->asked[] = $attachmentId;
+
+                return $this->usages;
+            }
+        };
+    }
+
+    public function testUnreadableEntriesHideTheirTitleAndASourceUsageIsListedAfterTheCoreOnes(): void
     {
+        $logo = MediaUsageFinder::siteUsage('Divi-Logo', 'option', 'divi_logo', MediaUsageFinder::POSSIBLE);
+        $asked = [];
+
         $usages = $this->finder(
             [13 => '<img class="wp-image-33">'],
             [self::post(20, 'Geist', 'ghost')],
             [],
-            ['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']]
+            [],
+            [],
+            [],
+            '2026/09/foto-scaled.jpg',
+            [self::source([$logo], $asked)]
         )->usages(33);
 
         self::assertSame(
             [
-                ['post_id' => 20, 'title' => '(nicht lesbar)', 'post_type' => 'ghost', 'where' => 'featured', 'certainty' => 'sicher'],
-                ['post_id' => 0, 'title' => 'Divi-Logo', 'post_type' => 'option', 'where' => 'divi_logo', 'certainty' => 'möglicherweise'],
-                ['post_id' => 13, 'title' => '(nicht lesbar)', 'post_type' => 'page', 'where' => 'content', 'certainty' => 'sicher'],
+                [
+                    'post_id' => 20, 'title' => '(nicht lesbar)', 'post_type' => 'ghost',
+                    'where' => 'featured', 'certainty' => 'sicher',
+                ],
+                $logo,
+                [
+                    'post_id' => 13, 'title' => '(nicht lesbar)', 'post_type' => 'page',
+                    'where' => 'content', 'certainty' => 'sicher',
+                ],
             ],
             $usages
         );
+        self::assertSame([33], $asked);
+    }
+
+    public function testASourcePlaceIsReportedOnceAndSourcesAreSkippedOnceTheLimitIsReached(): void
+    {
+        $first = MediaUsageFinder::siteUsage('Ort', 'option', 'place', MediaUsageFinder::POSSIBLE);
+        $second = MediaUsageFinder::siteUsage('Ort neu', 'option', 'place', MediaUsageFinder::CERTAIN);
+        $asked = [];
+
+        $usages = $this->finder([], [], [], [], [], [], '2026/09/foto.jpg', [
+            self::source([$first]),
+            self::source([$second]),
+        ])->usages(33);
+
+        self::assertSame([$second], $usages);
+
+        $finder = $this->finder([], [self::post(5, 'Team')], [], [], [], [], '2026/09/foto.jpg', [
+            self::source([$first], $asked),
+        ]);
+
+        self::assertTrue($finder->isUsed(33));
+        self::assertSame([], $asked, 'a usage was found before: the sources are not asked');
     }
 
     public function testIdReferenceWinsOverAFileMatchInTheSameContent(): void
--- a/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php
+++ b/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php
@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
 use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
 use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
 use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
+use LIT\MCP\Tests\Support\FakeBuilderDetector;
 use LIT\MCP\Tests\Unit\TestCase;
 use Mockery;
 use stdClass;
@@ -75,19 +76,21 @@ final class PostPresenterTest extends TestCase
     public function testDiviContentIsWithheldAndPointsToTheDiviOutline(): void
     {
         $this->stubLinks();
-        Functions\when('get_post_meta')->alias(static fn (int $id, string $key) => $key === '_et_pb_use_builder' ? 'on' : '');
+        Functions\when('get_post_meta')->justReturn('');
         Functions\when('get_post_thumbnail_id')->justReturn(0);
         $schema = Mockery::mock(ContentSchema::class);
         $schema->allows('taxonomies')->andReturn([]);
         $schema->allows('fields')->andReturn([]);
         $post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_content' => '<!-- wp:divi/section /-->']);
 
-        $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 <!-- wp:divi/text {} /--> y'])));
-        self::assertFalse(PostPresenter::usesDivi(new WP_Post(['post_content' => '<!-- wp:paragraph -->'])));
+        self::assertTrue($presenter->usesDivi(new WP_Post(['post_content' => 'x <!-- wp:divi/text {} /--> y'])));
+        self::assertFalse($presenter->usesDivi(new WP_Post(['post_content' => '<!-- wp:paragraph -->'])));
+    }
+
+    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'
+        );
     }
 }
--- 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' => '<p>Neu</p>']);
 
         self::assertToolError($result, 'unsupported');
--- 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' => '<p>Normal</p>']),
         ]);
 
-        $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
--- 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

declare(strict_types=1);

namespace LIT\MCP\Integrations\WordPress;

defined('ABSPATH') || exit;

/**
 * Port: what the WordPress content tools need to know about a page builder (today only Divi) without knowing the
 * builder – implemented by the builder's integration, wired in Plugin.
 */
interface BuilderDetector
{
    /** Whether the builder's tools exist on this site (the builder is loaded). */
    public function isAvailable(): bool;

    /** Whether the builder's own flag marks $postId as a builder layout. */
    public function usesBuilder(int $postId): bool;
}

src/Integrations/WordPress/NoBuilderDetector.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\WordPress;

defined('ABSPATH') || exit;

/** The BuilderDetector of a site without page-builder integration: no builder, no layouts. */
final class NoBuilderDetector implements BuilderDetector
{
    public function isAvailable(): bool
    {
        return false;
    }

    public function usesBuilder(int $postId): bool
    {
        return false;
    }
}

src/Integrations/Core/EnvironmentComponent.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Core;

defined('ABSPATH') || exit;

/**
 * Port: one theme or plugin LIT MCP integrates with, as lit-site-status reports it. EnvironmentProbe lists the
 * components it is given; the integration that owns the component (Divi) implements it, Plugin wires it in.
 */
interface EnvironmentComponent
{
    /**
     * @return array{id: string, active: bool, version: ?string, problems: list<string>, release: ?array<string, mixed>}
     *         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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

defined('ABSPATH') || exit;

/**
 * Port: a site-wide place outside the media library's own knowledge where an attachment can be used (e.g. the
 * logo in Divi's theme options). MediaUsageFinder asks every source it is given after the core options and
 * theme mods; the integration that owns the place implements it, Plugin wires it in.
 */
interface MediaUsageSource
{
    /**
     * @return list<array{post_id: int, title: string, post_type: string, where: string, certainty: string}>
     *         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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

defined('ABSPATH') || exit;

/** The stored file of an attachment, as far as usage checks need it. */
final class AttachmentFile
{
    /**
     * Upload-relative path without extension and without WordPress' suffixes for big, rotated or edited images:
     * 2026/09/foto-scaled.jpg → 2026/09/foto. '' when the attachment has no file.
     */
    public static function stem(int $attachmentId): string
    {
        $file = ltrim((string) get_post_meta($attachmentId, '_wp_attached_file', true), '/');
        $stem = (string) preg_replace('/\.[^.\/]+$/', '', $file);

        return (string) preg_replace('/(?:-scaled|-rotated|-e\d{10,})+$/', '', $stem);
    }
}

src/Integrations/Divi/DiviBuilderDetector.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi;

use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\BuilderDetector;

defined('ABSPATH') || exit;

/** The BuilderDetector for Divi 5: tools exist while Divi 5 is loaded, a layout carries Divi's builder flag. */
final class DiviBuilderDetector implements BuilderDetector
{
    public function isAvailable(): bool
    {
        return DiviModules::isAvailable();
    }

    public function usesBuilder(int $postId): bool
    {
        return DiviPages::usesBuilder($postId);
    }
}

src/Integrations/Divi/DiviLogoUsage.php (genau die heutige Fundstelle site:divi_logo: „Divi-Logo“, option, möglicherweise; ohne Datei oder Logo keine):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi;

use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Media\AttachmentFile;
use LIT\MCP\Integrations\Media\ContentMediaReferences;
use LIT\MCP\Integrations\Media\MediaUsageFinder;
use LIT\MCP\Integrations\Media\MediaUsageSource;

defined('ABSPATH') || exit;

/**
 * The logo in Divi's theme options as a usage of an attachment: "possible", because only the file URL is stored
 * (it may belong to another copy of the file); none without a file or a logo.
 */
final class DiviLogoUsage implements MediaUsageSource
{
    public function usagesOf(int $attachmentId): array
    {
        $stem = AttachmentFile::stem($attachmentId);
        $logo = DesignSystemReader::diviLogo();
        if ($stem === '' || $logo === '') {
            return [];
        }

        if (ContentMediaReferences::matches(ContentMediaReferences::fileRegex($stem), $logo) === false) {
            return [];
        }

        return [MediaUsageFinder::siteUsage('Divi-Logo', 'option', 'divi_logo', MediaUsageFinder::POSSIBLE)];
    }
}

src/Integrations/Divi/DiviEnvironmentComponent.php (die heutige Form des Divi-Eintrags aus EnvironmentProbe):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi;

use LIT\MCP\Integrations\Core\EnvironmentComponent;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;

defined('ABSPATH') || exit;

/**
 * Divi in lit-site-status: version, the internals that are missing (DiviCompatibility) and the release status
 * (spec §5.5); inactive without problems and release when Divi is not the template.
 */
final class DiviEnvironmentComponent implements EnvironmentComponent
{
    public function __construct(private ?DiviCompatibility $compatibility = null)
    {
    }

    public function describe(): array
    {
        $version = DiviEnvironment::version();
        $active = $version !== null;

        return [
            'id' => '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):

--- 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<string, mixed> */
@@ -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<string, mixed> */
     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, '<!-- wp:divi/');
     }
 

ContentUpdateAbility, RevisionsListAbility, WordPressIntegration (der Presenter mit dem Detektor wird durchgereicht):

--- a/src/Integrations/WordPress/Content/ContentUpdateAbility.php
+++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php
@@ -45,11 +45,16 @@ final class ContentUpdateAbility implements Ability
     /** Post columns a revision stores (and wp_restore_post_revision() restores). */
     private const REVISIONED = ['post_title', 'post_content', 'post_excerpt'];
 
+    private PostPresenter $presenter;
+
+    /** @param PostPresenter|null $presenter Knows the page builder (BuilderDetector); null = no builder. */
     public function __construct(
         private ContentSchema $schema,
         private PostWriter $writer,
-        private ChangeLog $log
+        private ChangeLog $log,
+        ?PostPresenter $presenter = null
     ) {
+        $this->presenter = $presenter ?? new PostPresenter($schema);
     }
 
     public function name(): string
@@ -123,10 +128,10 @@ final class ContentUpdateAbility implements Ability
             return $unstable;
         }
 
-        if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) {
+        if (array_key_exists('content', $input) && $this->presenter->usesDivi($post)) {
             return Errors::unsupported(
                 'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.',
-                PostPresenter::diviNote() . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.'
+                $this->presenter->diviNote() . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.'
             );
         }
 
--- a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php
+++ b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php
@@ -26,8 +26,12 @@ defined('ABSPATH') || exit;
  */
 final class RevisionsListAbility implements Ability
 {
-    public function __construct(private ContentSchema $schema)
+    private PostPresenter $presenter;
+
+    /** @param PostPresenter|null $presenter Knows the page builder (BuilderDetector); null = no builder. */
+    public function __construct(private ContentSchema $schema, ?PostPresenter $presenter = null)
     {
+        $this->presenter = $presenter ?? new PostPresenter($schema);
     }
 
     public function name(): string
@@ -127,7 +131,7 @@ final class RevisionsListAbility implements Ability
             'post_id' => (int) $post->ID,
             'items' => $items,
             'next_cursor' => $page['next_cursor'],
-            'hints' => in_array(true, array_column($items, 'divi'), true) ? [PostPresenter::diviHint()] : [],
+            'hints' => in_array(true, array_column($items, 'divi'), true) ? [$this->presenter->diviHint()] : [],
         ];
     }
 
@@ -146,7 +150,7 @@ final class RevisionsListAbility implements Ability
             'title' => (string) $revision->post_title,
             'autosave' => (bool) wp_is_post_autosave($revision),
             'excerpt' => mb_substr($text, 0, 160),
-            'divi' => PostPresenter::usesDivi($revision),
+            'divi' => $this->presenter->usesDivi($revision),
         ];
     }
 }
--- a/src/Integrations/WordPress/WordPressIntegration.php
+++ b/src/Integrations/WordPress/WordPressIntegration.php
@@ -53,12 +53,15 @@ final class WordPressIntegration implements Integration
      * @param ContentComposer|null   $divi             Divi short form for lit/content-create (DiviIntegration).
      * @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether lit-page-render-summary may name
      *        global module IDs (the Divi integration's library access); null = always.
+     * @param BuilderDetector|null   $builders         The page builder behind the Divi flag of a post (Divi's
+     *        BuilderDetector); null = no builder.
      */
     public function __construct(
         private ContentSchema $schema,
         private ChangeLog $log,
         private ?ContentComposer $divi = null,
-        private ?Closure $globalIdsVisible = null
+        private ?Closure $globalIdsVisible = null,
+        private ?BuilderDetector $builders = null
     ) {
     }
 
@@ -79,7 +82,7 @@ final class WordPressIntegration implements Integration
 
     public function abilities(): array
     {
-        $presenter = new PostPresenter($this->schema);
+        $presenter = new PostPresenter($this->schema, $this->builders);
         $writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner());
         $cloner = new PostCloner();
         $whitelist = new SettingsWhitelist();
@@ -89,14 +92,14 @@ final class WordPressIntegration implements Integration
             new ContentQueryAbility($this->schema, $presenter),
             new ContentGetAbility($this->schema, $presenter),
             new ContentCreateAbility($this->schema, $writer, $this->log, $this->divi),
-            new ContentUpdateAbility($this->schema, $writer, $this->log),
+            new ContentUpdateAbility($this->schema, $writer, $this->log, $presenter),
             new PostPublishAbility($this->schema, $this->log),
             new PostUnpublishAbility($this->schema, $this->log),
             new PostScheduleAbility($this->schema, $this->log),
             new PostTrashAbility($this->schema, $this->log),
             new PostDuplicateAbility($this->schema, $cloner, $this->log),
             new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log),
-            new RevisionsListAbility($this->schema),
+            new RevisionsListAbility($this->schema, $presenter),
             new RevisionRestoreAbility($this->schema, $this->log),
             new MenuListAbility(),
             new MenuGetAbility(),

ContentSchema (ausgeschlossene Post-Typen und Builder-Prüfung per Konstruktor):

--- a/src/Integrations/WordPress/Schema/ContentSchema.php
+++ b/src/Integrations/WordPress/Schema/ContentSchema.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
 
 namespace LIT\MCP\Integrations\WordPress\Schema;
 
-use LIT\MCP\Integrations\Divi\Schema\DiviModules;
 use LogicException;
 
 defined('ABSPATH') || exit;
@@ -16,11 +15,10 @@ defined('ABSPATH') || exit;
  */
 class ContentSchema
 {
-    /** Post types handled elsewhere (media tools, Divi tools, WordPress internals). */
+    /** WordPress' own post types and those of the media tools; integrations add theirs (the constructor). */
     public const EXCLUDED = [
         'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
         'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face',
-        ...DiviModules::INTERNAL_POST_TYPES,
     ];
 
     private const COUNTED_STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
@@ -31,19 +29,27 @@ class ContentSchema
     /** @var array<string, array<string, FieldDefinition>> */
     private array $fieldCache = [];
 
+    /** @var list<string> */
+    private array $excluded;
+
     /**
-     * @param list<FieldSource> $sources Highest priority first.
+     * @param list<FieldSource>           $sources           Highest priority first.
+     * @param callable(string): bool|null $diviEnabled       Whether the page builder is switched on for a post type;
+     *        null = never (Plugin passes Divi's check).
+     * @param list<string>                $excludedPostTypes Post types of integrations that the content tools leave
+     *        alone (Plugin passes Divi's library, Theme Builder, canvas and speculation types), added to EXCLUDED.
      */
-    public function __construct(private array $sources, ?callable $diviEnabled = null)
+    public function __construct(private array $sources, ?callable $diviEnabled = null, array $excludedPostTypes = [])
     {
-        $this->diviEnabled = $diviEnabled ?? DiviModules::builderEnabledFor(...);
+        $this->diviEnabled = $diviEnabled ?? static fn (string $postType): bool => false;
+        $this->excluded = array_values(array_unique([...self::EXCLUDED, ...$excludedPostTypes]));
     }
 
     /** @return list<string> */
     public function postTypeNames(): array
     {
-        $excluded = apply_filters('lit_mcp_excluded_post_types', self::EXCLUDED);
-        $excluded = is_array($excluded) ? $excluded : self::EXCLUDED;
+        $excluded = apply_filters('lit_mcp_excluded_post_types', $this->excluded);
+        $excluded = is_array($excluded) ? $excluded : $this->excluded;
         $names = array_values(array_diff(
             array_map('strval', array_keys(get_post_types(['show_ui' => true], 'names'))),
             $excluded

MediaUsageFinder und MediaIntegration (Quellen statt DesignSystemReader; Dateistamm über AttachmentFile):

--- a/src/Integrations/Media/MediaUsageFinder.php
+++ b/src/Integrations/Media/MediaUsageFinder.php
@@ -5,7 +5,6 @@ declare(strict_types=1);
 namespace LIT\MCP\Integrations\Media;
 
 use Closure;
-use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
 use LIT\MCP\Integrations\WordPress\PostStatuses;
 use LIT\MCP\Log;
 
@@ -59,9 +58,14 @@ class MediaUsageFinder
 
     /**
      * @param Closure(): list<string> $mediaMetaKeys Keys of file/image fields (ContentSchema::mediaFieldKeys()).
+     * @param list<MediaUsageSource>  $sources       Further site-wide places (e.g. the Divi logo), asked after the
+     *        core options and theme mods.
      */
-    public function __construct(private Closure $mediaMetaKeys, private ?\wpdb $db = null)
-    {
+    public function __construct(
+        private Closure $mediaMetaKeys,
+        private ?\wpdb $db = null,
+        private array $sources = []
+    ) {
     }
 
     /**
@@ -102,7 +106,7 @@ class MediaUsageFinder
     private function collect(int $attachmentId, int $max, int $limit): array
     {
         $wpdb = $this->db ?? $GLOBALS['wpdb'];
-        $stem = $this->fileStem($attachmentId);
+        $stem = AttachmentFile::stem($attachmentId);
         $fileRegex = $stem === '' ? null : ContentMediaReferences::fileRegex($stem);
 
         $found = [];
@@ -257,14 +261,10 @@ class MediaUsageFinder
             );
         }
 
-        $diviLogo = DesignSystemReader::diviLogo();
-        if ($diviLogo !== '' && ContentMediaReferences::matches($fileRegex, $diviLogo) !== false) {
-            $found['site:divi_logo'] = self::siteUsage(
-                'Divi-Logo',
-                'option',
-                'divi_logo',
-                self::POSSIBLE
-            );
+        foreach ($this->sources as $source) {
+            foreach ($source->usagesOf($attachmentId) as $usage) {
+                $found['site:' . $usage['where']] = $usage;
+            }
         }
 
         $this->addWidgetUsages($found, $attachmentId, $fileRegex);
@@ -420,18 +420,6 @@ class MediaUsageFinder
         return $contents;
     }
 
-    /**
-     * Upload-relative path without extension and without WordPress' suffixes for big,
-     * rotated or edited images: 2026/09/foto-scaled.jpg → 2026/09/foto.
-     */
-    private function fileStem(int $attachmentId): string
-    {
-        $file = ltrim((string) get_post_meta($attachmentId, '_wp_attached_file', true), '/');
-        $stem = (string) preg_replace('/\.[^.\/]+$/', '', $file);
-
-        return (string) preg_replace('/(?:-scaled|-rotated|-e\d{10,})+$/', '', $stem);
-    }
-
     /**
      * @param array<string, array<string, mixed>> $found
      * @param list<object>                        $rows
@@ -470,8 +458,12 @@ class MediaUsageFinder
         ];
     }
 
-    /** @return array{post_id: int, title: string, post_type: string, where: string, certainty: string} */
-    private static function siteUsage(string $title, string $type, string $where, string $certainty): array
+    /**
+     * A site-wide usage (post_id 0): option, theme mod, widget or a MediaUsageSource's place.
+     *
+     * @return array{post_id: int, title: string, post_type: string, where: string, certainty: string}
+     */
+    public static function siteUsage(string $title, string $type, string $where, string $certainty): array
     {
         return ['post_id' => 0, 'title' => $title, 'post_type' => $type, 'where' => $where, 'certainty' => $certainty];
     }
--- a/src/Integrations/Media/MediaIntegration.php
+++ b/src/Integrations/Media/MediaIntegration.php
@@ -17,8 +17,13 @@ defined('ABSPATH') || exit;
  */
 final class MediaIntegration implements Integration
 {
-    public function __construct(private ContentSchema $schema, private ChangeLog $log, private Limits $limits)
-    {
+    /** @param list<MediaUsageSource> $usageSources Further places an attachment can be used (e.g. the Divi logo). */
+    public function __construct(
+        private ContentSchema $schema,
+        private ChangeLog $log,
+        private Limits $limits,
+        private array $usageSources = []
+    ) {
     }
 
     public function id(): string
@@ -39,7 +44,11 @@ final class MediaIntegration implements Integration
     public function abilities(): array
     {
         $presenter = new MediaPresenter();
-        $finder = new MediaUsageFinder(fn (): array => $this->schema->mediaFieldKeys());
+        $finder = new MediaUsageFinder(
+            fn (): array => $this->schema->mediaFieldKeys(),
+            null,
+            $this->usageSources
+        );
         $importer = new MediaImporter($this->limits);
         $uploads = new MediaUploads($presenter, $this->log, $this->schema);
 

EnvironmentProbe und CoreIntegration:

--- a/src/Integrations/Core/EnvironmentProbe.php
+++ b/src/Integrations/Core/EnvironmentProbe.php
@@ -4,19 +4,16 @@ declare(strict_types=1);
 
 namespace LIT\MCP\Integrations\Core;
 
-use LIT\MCP\Integrations\Divi\DiviCompatibility;
-use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
-use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
-
 defined('ABSPATH') || exit;
 
 /**
- * Detects the themes/plugins LIT MCP integrates with; for Divi also the internals that are missing
- * (DiviCompatibility).
+ * Detects the themes/plugins LIT MCP integrates with: the components it is given (Divi, wired in Plugin) first, then
+ * Pods and The Events Calendar.
  */
 class EnvironmentProbe
 {
-    public function __construct(private ?DiviCompatibility $divi = null)
+    /** @param list<EnvironmentComponent> $components Components of integrations, reported in this order. */
+    public function __construct(private array $components = [])
     {
     }
 
@@ -26,19 +23,14 @@ class EnvironmentProbe
      */
     public function components(): array
     {
-        $diviVersion = DiviEnvironment::version();
-        $isDivi = $diviVersion !== null;
-
         $tecActive = class_exists('Tribe__Events__Main');
+        $described = array_map(
+            static fn (EnvironmentComponent $component): array => $component->describe(),
+            $this->components
+        );
 
         return [
-            [
-                'id' => 'divi',
-                'active' => $isDivi,
-                'version' => $diviVersion,
-                'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [],
-                'release' => $isDivi ? ReleaseGate::status()->toArray() : null,
-            ],
+            ...$described,
             [
                 'id' => 'pods',
                 'active' => defined('PODS_VERSION'),
--- a/src/Integrations/Core/CoreIntegration.php
+++ b/src/Integrations/Core/CoreIntegration.php
@@ -12,7 +12,8 @@ defined('ABSPATH') || exit;
 
 final class CoreIntegration implements Integration
 {
-    public function __construct(private string $guidesDirectory)
+    /** @param list<EnvironmentComponent> $components What lit-site-status reports besides Pods and Events Calendar. */
+    public function __construct(private string $guidesDirectory, private array $components = [])
     {
     }
 
@@ -35,7 +36,7 @@ final class CoreIntegration implements Integration
     {
         return [
             new GuideAbility(new GuideRepository($this->guidesDirectory)),
-            new SiteStatusAbility(new EnvironmentProbe(), new Access()),
+            new SiteStatusAbility(new EnvironmentProbe($this->components), new Access()),
         ];
     }
 }
  • Step 7: Verdrahtung in DiviIntegration und Plugin.php; Entscheidung zu DiviMarkup

DiviIntegration liefert die Ports (die zwei statischen Methoden für das Schema, das vor der Integration entsteht):

--- a/src/Integrations/Divi/DiviIntegration.php
+++ b/src/Integrations/Divi/DiviIntegration.php
@@ -53,7 +53,10 @@ use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
 use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
 use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
 use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderIntegration;
+use LIT\MCP\Integrations\Core\EnvironmentComponent;
 use LIT\MCP\Integrations\Integration;
+use LIT\MCP\Integrations\Media\MediaUsageSource;
+use LIT\MCP\Integrations\WordPress\BuilderDetector;
 use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
 use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
 
@@ -131,6 +134,45 @@ final class DiviIntegration implements Integration
         return DiviModules::isAvailable();
     }
 
+    /**
+     * Divi's own post types (library, Theme Builder, canvases, speculation), which the content tools leave alone:
+     * ContentSchema's excluded post types. Static because the schema is built before this integration (Plugin).
+     *
+     * @return list<string>
+     */
+    public static function internalPostTypes(): array
+    {
+        return DiviModules::INTERNAL_POST_TYPES;
+    }
+
+    /** Whether Divi's builder is switched on for $postType: ContentSchema::diviEnabled() (static, like above). */
+    public static function builderEnabledFor(string $postType): bool
+    {
+        return DiviModules::builderEnabledFor($postType);
+    }
+
+    /** Port for the content tools: Divi's builder flag and availability (PostPresenter). */
+    public function builderDetector(): BuilderDetector
+    {
+        return new DiviBuilderDetector();
+    }
+
+    /**
+     * Port for the media tools: places outside the library where Divi stores an attachment (its logo).
+     *
+     * @return list<MediaUsageSource>
+     */
+    public function mediaUsageSources(): array
+    {
+        return [new DiviLogoUsage()];
+    }
+
+    /** Port for lit-site-status: Divi's version, missing internals and release status. */
+    public function environmentComponent(): EnvironmentComponent
+    {
+        return new DiviEnvironmentComponent();
+    }
+
     /**
      * Divi short form for lit/content-create; available() reports whether Divi 5 is loaded,
      * writable() asks $writable (the Tools-tab gate, security M2).

Plugin.php bleibt Composition Root und darf Divi kennen:

--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -125,14 +125,15 @@ final class Plugin
             themeBuilderVisible: $themeBuilderVisible
         );
         $registry = new IntegrationRegistry(
-            new CoreIntegration(self::pluginDir() . '/docs/guides'),
+            new CoreIntegration(self::pluginDir() . '/docs/guides', [$divi->environmentComponent()]),
             new WordPressIntegration(
                 $schema,
                 $changeLog,
                 $divi->contentComposer($diviWritable),
-                $divi->libraryIdsVisible(...)
+                $divi->libraryIdsVisible(...),
+                $divi->builderDetector()
             ),
-            new MediaIntegration($schema, $changeLog, $limits),
+            new MediaIntegration($schema, $changeLog, $limits, $divi->mediaUsageSources()),
             $divi,
             $divi->library(),
             $divi->themeBuilder()
@@ -217,6 +218,11 @@ final class Plugin
             array_unshift($sources, new PodsFieldSource());
         }
 
-        return new ContentSchema($sources);
+        // Divi's post types and builder check come in statically: the schema exists before the Divi integration.
+        return new ContentSchema(
+            $sources,
+            DiviIntegration::builderEnabledFor(...),
+            DiviIntegration::internalPostTypes()
+        );
     }
 }

DiviMarkup – nur der Docblock, mit der Entscheidung in einem Satz:

--- a/src/Abilities/DiviMarkup.php
+++ b/src/Abilities/DiviMarkup.php
@@ -19,6 +19,9 @@ defined('ABSPATH') || exit;
  * form holds a delimiter WordPress' block parser accepts for divi/* blocks ("<!--", whitespace,
  * optional "/", lower-case "wp:divi/" – Divi's BlockParser extends it) or a Divi 4 shortcode.
  * Other block markup stays allowed and goes through WordPress' kses. Pure PHP.
+ *
+ * Stays in Abilities although it knows Divi's markup: it only recognizes text patterns (no Divi class, no import,
+ * CoreIndependenceTest), and every writer of client text – Divi or not – needs the same check.
  */
 final class DiviMarkup
 {
  • Step 8: GREEN

Run: composer test -- --filter 'CoreIndependenceTest|DiviBoundaryTest|PostPresenterTest|ContentSchemaTest|MediaUsageFinderTest|EnvironmentProbeTest|DiviLogoUsageTest|DiviBuilderDetectorTest|DiviEnvironmentComponentTest|DiviIntegrationTest|CoreIntegrationTest|ContentUpdateAbilityTest|RevisionsListAbilityTest' Expected: OK.

  • Step 9: Gesamtlauf und Lint

Run: composer test Expected: OK, 2654 Tests (2631 nach Task 1 + 23 neue).

Run: composer lint Expected: keine Beanstandung, Exit 0.

  • Step 10: Commit
git add src/Integrations/WordPress/BuilderDetector.php src/Integrations/WordPress/NoBuilderDetector.php \
  src/Integrations/Core/EnvironmentComponent.php src/Integrations/Media/MediaUsageSource.php \
  src/Integrations/Media/AttachmentFile.php src/Integrations/Divi/DiviBuilderDetector.php \
  src/Integrations/Divi/DiviLogoUsage.php src/Integrations/Divi/DiviEnvironmentComponent.php \
  src/Integrations/WordPress/Content/PostPresenter.php src/Integrations/WordPress/Content/ContentUpdateAbility.php \
  src/Integrations/WordPress/Revisions/RevisionsListAbility.php src/Integrations/WordPress/WordPressIntegration.php \
  src/Integrations/WordPress/Schema/ContentSchema.php src/Integrations/Media/MediaUsageFinder.php \
  src/Integrations/Media/MediaIntegration.php src/Integrations/Core/EnvironmentProbe.php \
  src/Integrations/Core/CoreIntegration.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php \
  src/Abilities/DiviMarkup.php tests/Unit/Architecture/CoreIndependenceTest.php tests/Support/FakeBuilderDetector.php \
  tests/Unit/Integrations/Divi/DiviBuilderDetectorTest.php tests/Unit/Integrations/Divi/DiviLogoUsageTest.php \
  tests/Unit/Integrations/Divi/DiviEnvironmentComponentTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php \
  tests/Unit/Integrations/Core/EnvironmentProbeTest.php tests/Unit/Integrations/Core/CoreIntegrationTest.php \
  tests/Unit/Integrations/Media/MediaUsageFinderTest.php \
  tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php \
  tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php \
  tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php \
  tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php
git commit -m "refactor: Kern ohne Divi – Ports BuilderDetector, MediaUsageSource, EnvironmentComponent und ausgeschlossene Post-Typen im ContentSchema; Divi-Implementierungen und Verdrahtung über DiviIntegration/Plugin; CoreIndependenceTest" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Offene Punkte (Teil 1 und 2)

  • PostPresenter wird Instanz (Abweichung vom bisherigen statischen Aufruf). usesDivi(), diviNote(), diviHint() sind keine statischen Methoden mehr, weil der Detektor injiziert wird; ContentUpdateAbility und RevisionsListAbility bekommen dafür einen optionalen letzten Konstruktorparameter ?PostPresenter. Ohne Detektor (Standard NoBuilderDetector) zählt nur Block-Markup <!-- wp:divi/ als Divi – in Produktion übergibt Plugin.php immer den DiviBuilderDetector. Andere Tasks, die PostPresenter::usesDivi()/diviHint() statisch aufrufen wollten, müssen die Instanz benutzen.
  • Zusätzliche Dateien gegenüber der Dateistruktur im Gerüst: src/Integrations/WordPress/NoBuilderDetector.php, src/Integrations/Media/AttachmentFile.php (Dateistamm, damit DiviLogoUsage ihn ohne MediaUsageFinder-Interna berechnen kann) und tests/Support/FakeBuilderDetector.php. MediaUsageFinder::siteUsage() wird dafür public static.
  • ToolRefusal erweitert Throwable nicht, wie im Gerüst; die Suche in der getPrevious()-Kette läuft über instanceof im privaten AbilityRegistrar::refusalIn(). DiviLocked::in() bleibt für die Divi-internen Aufrufer (DesignChange, DiviDesignDetachAbility).
  • src/Cli liegt nicht im Scan-Bereich des CoreIndependenceTest (Vorgabe nennt es nicht); Cli/DiviCheckCommand importiert viele Divi-Klassen. Task 3 verlegt die Registrierung in DiviIntegration::boot(); ob die Klasse danach nach src/Integrations/Divi/ zieht (und Cli dann in den Scan aufgenommen wird), entscheidet Task 3.
  • Admin/DiviTab.php ist die einzige Ausnahme im CoreIndependenceTest; ein Test verlangt, dass die Ausnahme noch gebraucht wird. AdminNotices und die übrigen Admin-Dateien importieren heute kein Divi; falls Task 10 dort Divi-Klassen braucht, muss die Anzeige über einen Port laufen oder in DiviTab bleiben.
  • DiviEnvironmentComponent hat heute den Konstruktor (?DiviCompatibility $compatibility = null); Task 10 (Matrix) darf ihn erweitern, EnvironmentProbe und CoreIntegration bleiben dafür unverändert.
  • MediaUsageFinder::WHERE/usageSchema() enthalten weiter den Wert divi_logo (Zeichenkette, kein Klassenbezug); ebenso die Gruppen-ID-Kopien in Guides/InstructionsBuilder. Beides bleibt bewusst.
  • Der Kommentar in tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php (Zeile 188) nennt noch „ContentSchema::EXCLUDED“ für die Theme-Builder-Typen; sachlich weiter richtig über den Konstruktor, nur die Kommentarformulierung ist ungenau (nicht angefasst).

Verifiziert: In einer Kopie des Repos (Stand e7793fd, mit vendor) beide Tasks nacheinander gebaut; Task 1 allein: composer test OK mit 2631 Tests (13864 Assertions), nach Task 2: OK mit 2654 Tests (13932 Assertions), composer lint jeweils sauber; RED-Läufe vor der Implementierung gegen die gestashte Implementierung nachgestellt (Task 1: 8 Fehler in AbilityRegistrarTest; Task 2: 26 Fehler und 2 Failures, CoreIndependenceTest listet vier Dateien); DiviBoundaryTest grün. Nicht geprüft: Verdrahtung in einer echten WordPress-/DDEV-Instanz (lit-site-status, Medienprüfung mit Divi-Logo).


Signatur-Änderungen (gegenüber dem Gerüst)

Das Gerüst legt für Task 3 nur Bootable::boot(): void fest – unverändert übernommen. Neu bzw. geändert sind:

  • Neu: src/Abilities/ToolAvailability.php (fehlt in der File Structure des Gerüsts): final class ToolAvailability { public function bind(IntegrationRegistry $registry, ExposurePolicy $policy): void; public function groupEnabled(string $group): bool; public function groupWritable(string $group): bool; public function allows(string $ability): bool; }. Eine Frage vor bind() und ein zweites bind() werfen LogicException (statt still false).
  • Geändert: DiviIntegration::__construct(ContentSchema $schema, ChangeLog $log, ?ToolAvailability $tools = null) – die sechs Closure-Parameter ($libraryWritable, $libraryVisible, $diviWritable, $libraryInsertable, $themeBuilderWritable, $themeBuilderVisible) entfallen. null = kein Tools-Tab, alles erlaubt (wie vorher alle Closures null). Hat Task 2 dem Konstruktor weitere Parameter gegeben, stehen sie vor $tools.
  • Geändert: DiviIntegration::contentComposer(): ContentComposer – der Parameter ?Closure $writable entfällt; die Kurzform folgt dem Tools-Tab-Zustand von lit/divi-insert über $tools.
  • Neu: DiviIntegration implements Integration, Bootable mit boot(): void.
  • Neu: IntegrationRegistry::boot(): void – bootet jede Bootable-Integration genau einmal, verfügbar oder nicht.
  • Neu: SettingsPage::TABS_FILTER = 'lit_mcp_admin_tabs' (Filter auf list<AdminTab>, einmal beim ersten Zugriff auf die Reiter angewandt). Der erste Konstruktorparameter heißt $baseTabs (Position und Typ unverändert).
  • Neu: DiviTab::removeOrphans(): void (öffentlich, Callback des Hooks load-settings_page_lit-mcp).
  • Neu: Plugin::integrations(ContentSchema $schema, ChangeLog $changeLog, Limits $limits, ToolAvailability $tools): IntegrationRegistry (öffentlich statisch, @internal für PluginTest).

Task 3a: Bootable – Divi-Boot-Schritte in DiviIntegration::boot(), Reiter über lit_mcp_admin_tabs, Prüfseiten-Aufräumen im load-Hook

Files:

  • Create: src/Integrations/Bootable.php
  • Modify: src/Integrations/IntegrationRegistry.php (boot())
  • Modify: src/Integrations/Divi/DiviIntegration.php (implements Bootable, boot())
  • Modify: src/Admin/SettingsPage.php (TABS_FILTER, Reiter lazy)
  • Modify: src/Admin/DiviTab.php (removeOrphans() im load-Hook statt in render())
  • Modify: src/Plugin.php (Upgrader-Hook, Prüf-Route, CLI, DiviTab raus; $registry->boot())
  • Test: tests/Unit/Integrations/IntegrationRegistryTest.php, tests/Unit/Integrations/Divi/DiviIntegrationTest.php, tests/Unit/Admin/SettingsPageTest.php, tests/Unit/Admin/DiviTabTest.php

Interfaces:

  • Consumes: Integration (vorhanden); ReleaseResolver::CACHE, CompatibilityCheck::create(ContentComposer), DiviCheckRoute::__construct(Closure $check)/register(), DiviCheckCommand::register(Closure $check) (vorhanden, Release/, Cli/); DiviTab::__construct(..., ?ChangeLog $log = null, ...), SettingsPage::SCREEN_HOOK ('settings_page_lit-mcp'), WriteProbe::removeOrphans(): list<int> (vorhanden, unverändert). Nach Task 1/2: AbilityRegistrar-Zeile und die Port-Argumente von Task 2 in Plugin::boot() bleiben unberührt.
  • Produces:
    • interface Bootable { public function boot(): void; } (Gerüst, unverändert)
    • IntegrationRegistry::boot(): void
    • DiviIntegration implements Integration, Bootable; DiviIntegration::boot(): void
    • SettingsPage::TABS_FILTER = 'lit_mcp_admin_tabs'
    • DiviTab::removeOrphans(): void

Entscheidungen:

  • Booten auch nicht verfügbarer Integrationen. Plugin::boot() läuft auf plugins_loaded, also vor dem Theme; DiviModules::isAvailable() ist dort noch false, auch wenn Divi aktiv ist (siehe Docblock ExposurePolicy::withGroups()). Heute registriert Plugin Upgrader-Hook, Prüf-Route, CLI-Befehl und Divi-Reiter unbedingt; der Reiter zeigt ohne Divi selbst „Divi ist … nicht aktiv“. Das bleibt so: IntegrationRegistry::boot() fragt isAvailable() nicht. Ein zweiter Aufruf tut nichts (Flag), damit „genau einmal“ nicht an Plugin hängt.

  • Reiter-Registrierung über einen Filter. Bootable::boot() hat keinen Parameter, SettingsPage entsteht in Plugin erst nach den Integrationen. Die Divi-Integration hängt ihren Reiter daher an den Filter lit_mcp_admin_tabs; SettingsPage wendet ihn einmal beim ersten Zugriff auf die Reiter an (register(), tabSlugs(), currentTab(), render()), also nach $registry->boot(). Reihenfolge bleibt: Verbindung, Zugriff, Werkzeuge, Protokoll, Grenzen, Divi (angehängt). Kein neues Konstruktorargument für DiviIntegration.

  • WriteProbe::removeOrphans() im load-Hook. Die Spec/Notizen (Divi-Absicherung Task 13) legen nur „beim Öffnen des Reiters“ fest; das Gerüst sagt „im load-Hook“. DiviTab::registerActions() hängt removeOrphans() an load-settings_page_lit-mcp (läuft vor jeder Ausgabe, nur auf dieser Seite; WordPress prüft dort schon die Menü-Berechtigung manage_options). Die Methode löscht nur, wenn tab=divi angefragt ist, der Nutzer manage_options hat und Divi aktiv ist. Bewusst ohne Nonce auf GET: gelöscht werden nur eigene Prüfentwürfe mit Marker und Prüftitel, älter als zehn Minuten (ORPHAN_AGE) – kein laufender Prüflauf nutzt sie, der Vorgang ist idempotent; ein untergeschobener Aufruf kann nichts anderes löschen. render() meldet nur noch, was gelöscht wurde. Kein Cron (das wäre eine Verhaltenserweiterung).

  • Prüf-Kurzform. boot() baut CompatibilityCheck::create($this->contentComposer()) – in 3a noch ohne Tools-Tab-Gate wie heute ($divi->contentComposer() ohne Argument). 3b ersetzt das durch shortForm(null).

  • Step 1: Failing Tests schreiben

tests/Unit/Integrations/IntegrationRegistryTest.php – Import ergänzen und vor private function ability( einfügen:

use LIT\MCP\Integrations\Bootable;
    /** Integration-owned boot steps run once each, in registry order, whether or not the integration is available. */
    public function testBootsEveryBootableIntegrationOnce(): void
    {
        $booted = [];
        $bootable = function (string $id, bool $available) use (&$booted): Integration {
            $integration = Mockery::mock(Integration::class . ', ' . Bootable::class);
            $integration->allows('id')->andReturn($id);
            $integration->allows('isAvailable')->andReturn($available);
            $integration->expects('abilities')->never();
            $integration->expects('boot')->once()->andReturnUsing(static function () use (&$booted, $id): void {
                $booted[] = $id;
            });

            return $integration;
        };
        $registry = new IntegrationRegistry(
            $this->integration('core', true, []),
            $bootable('divi', false),
            $bootable('pods', true)
        );

        $registry->boot();
        $registry->boot();

        self::assertSame(['divi', 'pods'], $booted);
    }

tests/Unit/Integrations/Divi/DiviIntegrationTest.php – Importe ergänzen (zu den vorhandenen):

use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
use LIT\MCP\Admin\AdminTab;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;

und als letzte Methode der Klasse anfügen:

    /** Bootable: Divi's own hooks – upgrader cache reset, check route, tab "Divi" after the plugin's tabs. */
    public function testBootRegistersDivisOwnHooks(): void
    {
        $upgraded = null;
        $tabs = null;
        Actions\expectAdded('upgrader_process_complete')->once()->whenHappen(
            static function (callable $callback) use (&$upgraded): void {
                $upgraded = $callback;
            }
        );
        Actions\expectAdded('rest_api_init')->once()->with(Mockery::on(
            static fn (array $callback): bool => $callback[0] instanceof DiviCheckRoute
        ));
        Filters\expectAdded(SettingsPage::TABS_FILTER)->once()->whenHappen(
            static function (callable $callback) use (&$tabs): void {
                $tabs = $callback;
            }
        );
        Functions\expect('delete_transient')->once()->with(ReleaseResolver::CACHE)->andReturn(true);
        $base = Mockery::mock(AdminTab::class);

        (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->boot();
        self::assertIsCallable($upgraded);
        $upgraded();
        self::assertIsCallable($tabs);
        $all = $tabs([$base]);

        self::assertCount(2, $all);
        self::assertSame($base, $all[0]);
        self::assertInstanceOf(DiviTab::class, $all[1]);
    }

(DiviCheckCommand::register() ist ohne WP_CLI ein No-op – im Unit-Test nicht prüfbar, wie bisher.)

tests/Unit/Admin/SettingsPageTest.php – Import use Brain\Monkey\Filters; ergänzen und vor testRendersTheLogoTopRightOnlyWhenGiven einfügen:

    /** Integrations add their tabs in Bootable::boot() (Divi: "Divi", last); the filter runs once, on first use. */
    public function testIntegrationsAppendTheirTabsThroughTheFilter(): void
    {
        $divi = Mockery::mock(AdminTab::class);
        $divi->allows('slug')->andReturn('divi');
        $divi->expects('registerActions')->once();
        Filters\expectApplied(SettingsPage::TABS_FILTER)->once()->andReturnUsing(
            static fn (array $tabs): array => [...$tabs, $divi]
        );
        $page = new SettingsPage([$this->tab('verbindung', 'Verbindung'), $this->tab('zugriff', 'Zugriff')]);
        Functions\when('add_action')->justReturn(true);

        $page->register();

        self::assertSame(['verbindung', 'zugriff', 'divi'], $page->tabSlugs());
    }

tests/Unit/Admin/DiviTabTest.php:

Importe ergänzen:

use Brain\Monkey\Actions;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use WP_Post;

In setUp() nach dem add_query_arg-Stub ergänzen und ein tearDown() anlegen:

Vorher:

        Functions\when('add_query_arg')->alias(
            static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
        );
    }

Nachher:

        Functions\when('add_query_arg')->alias(
            static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
        );
        Functions\when('sanitize_key')->alias(static fn (string $key): string => strtolower($key));
    }

    protected function tearDown(): void
    {
        unset($_GET['tab']);
        parent::tearDown();
    }

Helfer rendered() – kein get_posts-Stub mehr (das Rendern darf nicht löschen), optional ein fertiger Reiter:

Vorher:

    private function rendered(ReleaseStatus $status, ?CheckReport $report): string
    {
        ReleaseGate::useResolver(static fn (): ReleaseStatus => $status);
        Functions\when('get_posts')->justReturn([]);
        Functions\when('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check');

Nachher:

    private function rendered(ReleaseStatus $status, ?CheckReport $report, ?DiviTab $tab = null): string
    {
        ReleaseGate::useResolver(static fn (): ReleaseStatus => $status);
        Functions\when('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check');

Vorher:

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

Nachher:

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

Am Ende der Klasse anfügen:

    /** The tab removes the pages of a dead check in the settings page's load hook, never while rendering. */
    public function testRegistersTheCleanupOnTheSettingsPageLoadHook(): void
    {
        Actions\expectAdded('admin_post_lit_mcp_divi_release')->once();
        Actions\expectAdded('admin_post_lit_mcp_divi_withdraw')->once();
        Actions\expectAdded('load-settings_page_lit-mcp')->once()->with(Mockery::type('array'));

        $this->tab(null, Mockery::mock(ReleaseStore::class))->registerActions();
    }

    public function testTheLoadHookOnThisTabRemovesOrphansAndTheTabReportsThem(): void
    {
        $_GET['tab'] = 'divi';
        Functions\expect('get_posts')->once()->andReturn([
            new WP_Post(['ID' => 51, 'post_title' => WriteProbe::TITLE . ' 2026-10-18 08:00']),
        ]);
        Functions\expect('wp_delete_post')->once()->with(51, true)->andReturn(new WP_Post(['ID' => 51]));
        $tab = $this->tab(self::report(), Mockery::mock(ReleaseStore::class));

        $tab->removeOrphans();
        $html = $this->rendered(ReleaseStatus::released('5.3.0'), self::report(), $tab);

        self::assertStringContainsString('Liegengebliebene Prüfseiten gelöscht: 51', $html);
    }

    public function testRenderingAloneDeletesNothing(): void
    {
        Functions\expect('get_posts')->never();
        Functions\expect('wp_delete_post')->never();

        $html = $this->rendered(ReleaseStatus::released('5.3.0'), self::report());

        self::assertStringNotContainsString('Liegengebliebene Prüfseiten', $html);
    }

    public function testTheLoadHookDeletesNothingOnOtherTabsWithoutTheRightOrWithoutDivi(): void
    {
        Functions\expect('get_posts')->never();
        $tab = $this->tab(null, Mockery::mock(ReleaseStore::class));

        unset($_GET['tab']);
        $tab->removeOrphans();
        $_GET['tab'] = 'tools';
        $tab->removeOrphans();

        $_GET['tab'] = 'divi';
        Functions\when('current_user_can')->justReturn(false);
        $tab->removeOrphans();

        Functions\when('current_user_can')->justReturn(true);
        (new DiviTab(
            Mockery::mock(CheckReportStore::class),
            Mockery::mock(ReleaseStore::class),
            FakeChangeLog::forbidden(),
            null,
            static fn (): string => ''
        ))->removeOrphans();
    }
  • Step 2: Tests laufen lassen – RED

Run: vendor/bin/phpunit tests/Unit/Integrations/IntegrationRegistryTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php tests/Unit/Admin/SettingsPageTest.php tests/Unit/Admin/DiviTabTest.php

Expected: FAIL, u. a.

  • Error: Call to undefined method LIT\MCP\Integrations\IntegrationRegistry::boot()

  • Error: Undefined constant LIT\MCP\Admin\SettingsPage::TABS_FILTER (SettingsPageTest und DiviIntegrationTest)

  • Error: Call to undefined function LIT\MCP\Integrations\Divi\Release\get_posts() (die sechs vorhandenen Render-Tests – render() löscht noch)

  • Error: Call to undefined method LIT\MCP\Admin\DiviTab::removeOrphans()

  • Mockery\Exception\InvalidCountException: Method get_posts(<Any Arguments>) … should be called (exactly 0 times)

  • InvalidCountException: Method add_action_load_hyphen_settings_page_lit_hyphen_mcp(…) (load-Hook fehlt)

  • Step 3: Bootable anlegen

src/Integrations/Bootable.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations;

defined('ABSPATH') || exit;

/**
 * An integration with boot steps of its own (hooks, REST routes, WP-CLI commands, admin tabs), so the composition
 * root does not need to know them. IntegrationRegistry::boot() calls boot() whether or not the integration is
 * available: Plugin boots on plugins_loaded, before the theme – isAvailable() cannot be asked yet.
 */
interface Bootable
{
    /** Registers the integration's own hooks, routes, CLI commands and admin tabs. Called once by Plugin. */
    public function boot(): void;
}
  • Step 4: IntegrationRegistry::boot()

Vorher:

    /** @var list<array{Integration, list<Ability>}>|null Available integrations with their abilities. */
    private ?array $loaded = null;

    public function __construct(Integration ...$integrations)
    {
        $this->integrations = array_values($integrations);
    }

    /** @return list<Integration> */
    public function available(): array

Nachher:

    /** @var list<array{Integration, list<Ability>}>|null Available integrations with their abilities. */
    private ?array $loaded = null;

    private bool $booted = false;

    public function __construct(Integration ...$integrations)
    {
        $this->integrations = array_values($integrations);
    }

    /**
     * Boots every Bootable integration once, in registry order – available or not: Plugin boots on plugins_loaded,
     * before the theme (Divi) is loaded, so isAvailable() is not known yet (as the hooks were registered before
     * Bootable). A second call does nothing.
     */
    public function boot(): void
    {
        if ($this->booted) {
            return;
        }

        $this->booted = true;
        foreach ($this->integrations as $integration) {
            if ($integration instanceof Bootable) {
                $integration->boot();
            }
        }
    }

    /** @return list<Integration> */
    public function available(): array
  • Step 5: SettingsPage – Reiter über TABS_FILTER, lazy

Vorher:

    /** @var array<string, AdminTab> */
    private array $tabs = [];

    /**
     * @param list<AdminTab> $tabs    The first tab is the default.
     * @param string         $logoUrl   Loosen-IT logo shown top right of the page ('' = none).
     * @param string         $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS).
     * @param string         $version   Asset version for cache busting.
     */
    public function __construct(
        array $tabs,
        private string $logoUrl = '',
        private string $assetsUrl = '',
        private string $version = ''
    ) {
        foreach ($tabs as $tab) {
            $this->tabs[$tab->slug()] = $tab;
        }
    }

Nachher:

    /**
     * Filter on the tab list (list<AdminTab>): integrations append their own tabs in Bootable::boot() – Divi its
     * tab "Divi". Applied once, on first use of the tabs (after Plugin booted the integrations).
     */
    public const TABS_FILTER = 'lit_mcp_admin_tabs';

    /** @var array<string, AdminTab>|null null = TABS_FILTER not applied yet. */
    private ?array $tabs = null;

    /**
     * @param list<AdminTab> $baseTabs  The plugin's own tabs; the first tab is the default.
     * @param string         $logoUrl   Loosen-IT logo shown top right of the page ('' = none).
     * @param string         $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS).
     * @param string         $version   Asset version for cache busting.
     */
    public function __construct(
        private array $baseTabs,
        private string $logoUrl = '',
        private string $assetsUrl = '',
        private string $version = ''
    ) {
    }

Jeden Zugriff auf $this->tabs durch $this->tabs() ersetzen:

Vorher (register()):

        foreach ($this->tabs as $tab) {
            $tab->registerActions();
        }

Nachher:

        foreach ($this->tabs() as $tab) {
            $tab->registerActions();
        }

Vorher (registerSettings()):

        foreach ($this->tabs as $tab) {
            $tab->registerSettings();
        }

Nachher:

        foreach ($this->tabs() as $tab) {
            $tab->registerSettings();
        }

Vorher (tabSlugs()):

        return array_keys($this->tabs);

Nachher:

        return array_keys($this->tabs());

Vorher (currentTab()):

        return isset($this->tabs[$requested]) ? $requested : (string) array_key_first($this->tabs);

Nachher:

        $tabs = $this->tabs();

        return isset($tabs[$requested]) ? $requested : (string) array_key_first($tabs);

Vorher (render()):

        foreach ($this->tabs as $slug => $tab) {

Nachher:

        foreach ($this->tabs() as $slug => $tab) {

Vorher (render()):

        $this->tabs[$current]->render();

Nachher:

        $this->tabs()[$current]->render();

Vor private function renderLogo(): void einfügen:

    /** @return array<string, AdminTab> The plugin's tabs and those the integrations add (TABS_FILTER), by slug. */
    private function tabs(): array
    {
        if ($this->tabs === null) {
            $this->tabs = [];
            foreach ((array) apply_filters(self::TABS_FILTER, $this->baseTabs) as $tab) {
                if ($tab instanceof AdminTab) {
                    $this->tabs[$tab->slug()] = $tab;
                }
            }
        }

        return $this->tabs;
    }

  • Step 6: DiviTab – Aufräumen im load-Hook, render() meldet nur

Klassen-Docblock, vorher:

 * for a database release). Opening the tab removes check pages a dead check left behind.
 */

Nachher:

 * for a database release). Loading the tab removes check pages a dead check left behind – in the page's load hook
 * (removeOrphans()), not while rendering.
 */

Vorher:

    public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw';

Nachher:

    public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw';

    /** @var list<int> Check pages removeOrphans() deleted in this request; render() reports them. */
    private array $removed = [];

Vorher:

        add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
    }

Nachher:

        add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
        add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'removeOrphans']);
    }

    /**
     * load-<page> of the settings page, before any output: on this tab, for manage_options and with Divi active,
     * deletes the check pages a dead check left behind (WriteProbe::removeOrphans(): marked drafts with the check
     * title, older than ten minutes – no running check uses them). On a GET without nonce on purpose: it removes
     * only the plugin's own leftovers and is idempotent. render() only reports what was deleted.
     */
    public function removeOrphans(): void
    {
        if (! $this->requested() || ! current_user_can('manage_options') || $this->version() === '') {
            return;
        }

        $this->removed = WriteProbe::removeOrphans();
    }

In render(), vorher:

        $removed = WriteProbe::removeOrphans();
        $status = ReleaseGate::status();

Nachher:

        $status = ReleaseGate::status();

Vorher:

        if ($removed !== []) {

Nachher:

        if ($this->removed !== []) {

Vorher:

                    implode(', ', $removed)

Nachher:

                    implode(', ', $this->removed)

Vor private function version(): string einfügen:

    /** Whether this request opens this tab of the settings page (the load hook fires for every tab). */
    private function requested(): bool
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
        $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';

        return $tab === self::SLUG;
    }

(SettingsPage liegt im selben Namespace – kein Import nötig.)

  • Step 7: DiviIntegration implements Bootable

Importe ergänzen (alphabetisch einsortieren):

use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Cli\DiviCheckCommand;
use LIT\MCP\Integrations\Bootable;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;

Vorher:

 * contentComposer() gives lit/content-create the Divi short form (spec §6.2).
 */
final class DiviIntegration implements Integration
{

Nachher:

 * contentComposer() gives lit/content-create the Divi short form (spec §6.2). boot() registers Divi's own hooks.
 */
final class DiviIntegration implements Integration, Bootable
{

Unmittelbar vor public function abilities(): array einfügen:

    /**
     * Divi's own boot steps, registered whether or not Divi is loaded yet (Bootable): a theme update checks the
     * fingerprints again at once (spec §5.4), the admin check as REST route and WP-CLI command (spec §6, §7) and
     * the tab "Divi" after the plugin's own tabs.
     */
    public function boot(): void
    {
        // Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too).
        add_action('upgrader_process_complete', static function (): void {
            delete_transient(ReleaseResolver::CACHE);
        });

        // Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no
        // Tools-tab gate: the check tests Divi, not this site's tool switches.
        $check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->contentComposer());
        (new DiviCheckRoute($check))->register();
        DiviCheckCommand::register($check);

        add_filter(SettingsPage::TABS_FILTER, fn (array $tabs): array => [...$tabs, new DiviTab(log: $this->log)]);
    }

  • Step 8: Plugin.php – Divi-Boot-Schritte raus, $registry->boot() rein

Importe entfernen:

use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Cli\DiviCheckCommand;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;

Vorher:

        (new ChangeLogCleanup($changeLog, $limits))->register();

        // Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too).
        add_action('upgrader_process_complete', static function (): void {
            delete_transient(ReleaseResolver::CACHE);
        });

        $schema = self::contentSchema();

Nachher:

        (new ChangeLogCleanup($changeLog, $limits))->register();

        $schema = self::contentSchema();

Vorher:

        // Spec §6: the admin check runs as a REST request (same context as MCP calls).
        $diviCheck = static fn (): CompatibilityCheck => CompatibilityCheck::create($divi->contentComposer());
        (new DiviCheckRoute($diviCheck))->register();
        DiviCheckCommand::register($diviCheck);

        $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));

Nachher:

        // Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
        // page reads its tabs.
        $registry->boot();

        $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));

Vorher:

            new LimitsTab(),
            new DiviTab(log: $changeLog),
        ];

Nachher:

            new LimitsTab(),
        ];

(Die Zeile new AbilityRegistrar(...) in der Form nach Task 1 und die Port-Argumente aus Task 2 bleiben unverändert.)

  • Step 9: Tests laufen lassen – GREEN

Run: vendor/bin/phpunit tests/Unit/Integrations/IntegrationRegistryTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php tests/Unit/Admin/SettingsPageTest.php tests/Unit/Admin/DiviTabTest.php Expected: PASS (OK).

  • Step 10: Gesamte Suite und Lint

Run: composer test && composer lint Expected: alle Tests grün (+7 gegenüber dem Stand vor Task 3a), phpcs ohne Befund.

  • Step 11: Commit
git add src/Integrations/Bootable.php src/Integrations/IntegrationRegistry.php \
  src/Integrations/Divi/DiviIntegration.php src/Admin/SettingsPage.php src/Admin/DiviTab.php src/Plugin.php \
  tests/Unit/Integrations/IntegrationRegistryTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php \
  tests/Unit/Admin/SettingsPageTest.php tests/Unit/Admin/DiviTabTest.php
git commit -m "refactor: Divi-Boot-Schritte in DiviIntegration::boot() (Bootable) – Upgrader-Hook, Prüf-Route, CLI und Reiter „Divi“ über lit_mcp_admin_tabs; IntegrationRegistry::boot() bootet jede Integration genau einmal; Prüfseiten-Aufräumen im load-Hook statt beim Rendern" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 3b: ToolAvailability statt der sechs Referenz-Closures; Plugin::integrations() mit Verdrahtungstest

Files:

  • Create: src/Abilities/ToolAvailability.php
  • Modify: src/Integrations/Divi/DiviIntegration.php (Konstruktor, contentComposer(), gate(), shortForm(), boot())
  • Modify: src/Plugin.php (Closures raus; Plugin::integrations())
  • Test: tests/Unit/Abilities/ToolAvailabilityTest.php (neu), tests/Unit/PluginTest.php (neu), tests/Unit/Integrations/Divi/DiviIntegrationTest.php

Interfaces:

  • Consumes: ExposurePolicy::groupEnabled(string), groupWritable(string), allows(string, list<Ability>), fromOption(), withGroups(callable) (vorhanden); IntegrationRegistry::abilities(), ids(), boot() (3a); LibraryAccess::GROUP, ThemeBuilderAccess::GROUP; die Konstruktoren von LibraryAccess, ThemeBuilderAccess (?Closure $writable, ?Closure $visible), DiviNodeCopyAbility, LibraryIntegration (?Closure $diviWritable), DiviContentComposer (?Closure $writable) bleiben unverändert – DiviIntegration baut die Closures selbst.
  • Produces:
    • final class ToolAvailability (siehe Signatur-Änderungen)
    • DiviIntegration::__construct(ContentSchema $schema, ChangeLog $log, ?ToolAvailability $tools = null)
    • DiviIntegration::contentComposer(): ContentComposer
    • Plugin::integrations(ContentSchema $schema, ChangeLog $changeLog, Limits $limits, ToolAvailability $tools): IntegrationRegistry

Entscheidungen:

  • Name und Ort. ToolAvailability in src/Abilities neben ExposurePolicy: es beantwortet nur Fragen zum Tools-Tab-Zustand (Gruppe an, Gruppe schreibbar, Werkzeug erlaubt), ohne Divi-Bezug; Abilities kennt IntegrationRegistry schon (AbilityRegistrar). Damit bleibt es im Kern (CoreIndependenceTest).

  • Zeitliche Kopplung. Registry und Policy brauchen die Integrationen, die Integrationen brauchen die Gates – ein Zyklus, den heute use (&$registry, &$policy) löst; vor der Zuweisung lieferten die Closures still false. Jetzt bekommt DiviIntegration das Objekt, Plugin bindet es genau einmal (bind()), sobald Registry und Policy stehen. Gefragt wird erst zur Laufzeit eines Tools (lazy). Eine Frage vor bind() ist ein Verdrahtungsfehler und wirft LogicException – kein stilles false mehr. In Plugin::boot() fragt zwischen Bau und bind() niemand (keine Hooks feuern dazwischen; PluginTest sichert das für Bau und boot() ab).

  • ToolAvailability final, kein Interface. Tests binden es an eine echte ExposurePolicy und eine Registry mit Mock-Integration; das ersetzt die Zähl-Closures der alten Tests. Ob ein Gate gefragt wird, zeigt ein ungebundenes Objekt (wirft).

  • Testbare Verdrahtung. Plugin::boot() selbst ist nicht sinnvoll unit-testbar (≈30 WordPress-Funktionen, \WPMedia\MCP\OAuth\Bootstrap::instance(), statischer Zustand). Der Teil mit Logik – welche Integrationen in welcher Reihenfolge, wer $tools bekommt – wandert nach Plugin::integrations() (öffentlich statisch, @internal); boot() ruft ihn auf, bindet und bootet. Kein eigenes Wiring-Klasse: Plugin bleibt die einzige Composition Root (Ausnahme in CoreIndependenceTest).

  • Prüf-Kurzform bleibt ohne Gate. boot() nutzt shortForm(null) wie heute $divi->contentComposer() ohne Argument (WriteProbe fragt writable() ohnehin nicht).

  • Step 1: Failing Test für ToolAvailability schreiben

tests/Unit/Abilities/ToolAvailabilityTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Abilities;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LogicException;
use Mockery;
use PHPUnit\Framework\TestCase;

final class ToolAvailabilityTest extends TestCase
{
    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    private static function registry(string ...$names): IntegrationRegistry
    {
        $abilities = array_map(static function (string $name): Ability {
            $ability = Mockery::mock(Ability::class);
            $ability->allows('name')->andReturn($name);
            $ability->allows('isDirect')->andReturn(false);

            return $ability;
        }, $names);
        $integration = Mockery::mock(Integration::class);
        $integration->allows('id')->andReturn('divi');
        $integration->allows('isAvailable')->andReturn(true);
        $integration->allows('abilities')->andReturn($abilities);

        return new IntegrationRegistry($integration);
    }

    /** The temporal coupling of the former use-by-reference closures: they answered false until Plugin set $policy. */
    public function testAQuestionBeforeBindIsAWiringErrorNotFalse(): void
    {
        $tools = new ToolAvailability();

        foreach ([
            static fn (): bool => $tools->groupEnabled('divi-library'),
            static fn (): bool => $tools->groupWritable('divi-library'),
            static fn (): bool => $tools->allows('lit/divi-insert'),
        ] as $ask) {
            try {
                $ask();
                self::fail('answered before bind()');
            } catch (LogicException $e) {
                self::assertSame('ToolAvailability asked before Plugin bound it.', $e->getMessage());
            }
        }
    }

    /** A gate built before bind() (as DiviIntegration builds its gates) answers from the policy bound later. */
    public function testAGateBuiltBeforeBindResolvesLazily(): void
    {
        $tools = new ToolAvailability();
        $libraryVisible = static fn (): bool => $tools->groupEnabled('divi-library');
        $libraryWritable = static fn (): bool => $tools->groupWritable('divi-library');
        $diviWritable = static fn (): bool => $tools->allows('lit/divi-insert');

        $tools->bind(self::registry('lit/divi-insert'), new ExposurePolicy(['@readonly:divi-library' => true]));

        self::assertTrue($libraryVisible());
        self::assertFalse($libraryWritable());
        self::assertTrue($diviWritable());
    }

    public function testFollowsTheBoundPolicy(): void
    {
        $tools = new ToolAvailability();
        $tools->bind(
            self::registry('lit/divi-insert', 'lit/library-insert'),
            new ExposurePolicy([
                '@group:divi-theme-builder' => ExposurePolicy::DISABLED,
                'lit/library-insert' => ExposurePolicy::DISABLED,
            ])
        );

        self::assertFalse($tools->groupEnabled('divi-theme-builder'));
        self::assertFalse($tools->groupWritable('divi-theme-builder'));
        self::assertTrue($tools->groupEnabled('divi-library'));
        self::assertTrue($tools->groupWritable('divi-library'));
        self::assertTrue($tools->allows('lit/divi-insert'));
        self::assertFalse($tools->allows('lit/library-insert'));
        self::assertFalse($tools->allows('lit/unknown'), 'not registered = not allowed (ExposurePolicy::allows)');
    }

    public function testBindsOnlyOnce(): void
    {
        $tools = new ToolAvailability();
        $tools->bind(self::registry(), new ExposurePolicy());

        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('ToolAvailability is already bound.');
        $tools->bind(self::registry(), new ExposurePolicy());
    }
}
  • Step 2: Failing Verdrahtungstest schreiben

tests/Unit/PluginTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit;

use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Limits;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Support\FakeChangeLog;
use Mockery;

/** The wiring of the composition root: Plugin::integrations() and the steps Plugin::boot() takes with it. */
final class PluginTest extends TestCase
{
    private static function registry(ToolAvailability $tools = new ToolAvailability()): IntegrationRegistry
    {
        return Plugin::integrations(
            Mockery::mock(ContentSchema::class),
            FakeChangeLog::forbidden(),
            new Limits(),
            $tools
        );
    }

    /** Registry order = order of the Tools tab, the guide and the server instructions. */
    public function testIntegrationsInRegistryOrder(): void
    {
        self::assertSame(
            ['core', 'wordpress', 'media', 'divi', 'divi-library', 'divi-theme-builder'],
            self::registry()->ids()
        );
    }

    /** Only the Divi integration has boot steps; a second boot registers nothing twice. */
    public function testTheBootableIntegrationsBootExactlyOnce(): void
    {
        Actions\expectAdded('upgrader_process_complete')->once();
        Actions\expectAdded('rest_api_init')->once();
        Filters\expectAdded(SettingsPage::TABS_FILTER)->once();
        $registry = self::registry();

        $registry->boot();
        $registry->boot();
    }

    /**
     * No temporal coupling: building and booting the integrations never asks the Tools-tab state (an unbound
     * ToolAvailability throws when asked); bound afterwards, it answers from the bound policy.
     */
    public function testTheToolsTabStateIsAskedOnlyAfterBind(): void
    {
        Functions\stubTranslationFunctions();
        $tools = new ToolAvailability();
        $registry = self::registry($tools);
        $registry->boot();

        $tools->bind($registry, new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED]));

        self::assertFalse($tools->groupEnabled('divi-library'));
        self::assertTrue($tools->groupWritable('divi-theme-builder'));
        self::assertFalse($tools->allows('lit/divi-insert'), 'no Divi in unit tests: lit/divi-insert not registered');
    }
}
  • Step 3: DiviIntegrationTest auf ToolAvailability umstellen

Importe ergänzen:

use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;

Vorher:

    /** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */
    public function testNodeCopyFollowsLitDiviInsert(): void
    {
        $integration = new DiviIntegration(
            Mockery::mock(ContentSchema::class),
            FakeChangeLog::forbidden(),
            diviWritable: static fn (): bool => false
        );

Nachher:

    /** A Tools-tab state bound to $policy, with lit/divi-insert registered (the Divi group available). */
    private static function tools(ExposurePolicy $policy = new ExposurePolicy()): ToolAvailability
    {
        $insert = Mockery::mock(Ability::class);
        $insert->allows('name')->andReturn('lit/divi-insert');
        $insert->allows('isDirect')->andReturn(true);
        $divi = Mockery::mock(Integration::class);
        $divi->allows('id')->andReturn('divi');
        $divi->allows('isAvailable')->andReturn(true);
        $divi->allows('abilities')->andReturn([$insert]);
        $tools = new ToolAvailability();
        $tools->bind(new IntegrationRegistry($divi), $policy);

        return $tools;
    }

    /** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */
    public function testNodeCopyFollowsLitDiviInsert(): void
    {
        $integration = new DiviIntegration(
            Mockery::mock(ContentSchema::class),
            FakeChangeLog::forbidden(),
            self::tools(new ExposurePolicy(['lit/divi-insert' => ExposurePolicy::DISABLED]))
        );

Vor public function testGivesContentCreateTheDiviShortForm(): void einfügen:

    /** Security M2: content-create's divi input follows lit/divi-insert, asked when content-create asks. */
    public function testTheShortFormFollowsLitDiviInsert(): void
    {
        $schema = Mockery::mock(ContentSchema::class);
        $open = new DiviIntegration($schema, FakeChangeLog::forbidden(), self::tools());
        $closed = new DiviIntegration(
            $schema,
            FakeChangeLog::forbidden(),
            self::tools(new ExposurePolicy(['lit/divi-insert' => ExposurePolicy::DISABLED]))
        );

        self::assertTrue($open->contentComposer()->writable());
        self::assertFalse($closed->contentComposer()->writable());
        self::assertTrue((new DiviIntegration($schema, FakeChangeLog::forbidden()))->contentComposer()->writable());
    }

In testTheReadToolsReachTheLibraryUnlessItIsHidden, vorher:

        // No Divi in unit tests: DiviRoles allows nothing, so the entry is not found either way (ruling P12);
        // the wiring shows in which gate the integration asks.
        $asked = 0;
        $visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), null, static function () use (&$asked): bool {
            ++$asked;

            return true;
        });
        self::assertToolError($outline($visible), 'not_found');
        self::assertSame(1, $asked);
        self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'not_found');

        $hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static fn (): bool => false);
        $refusal = $outline($hidden);
        self::assertInstanceOf(WP_Error::class, $refusal);
        self::assertToolError($refusal, 'not_found');
    }

Nachher:

        // No Divi in unit tests: DiviRoles allows nothing, so the entry is not found either way (ruling P12);
        // the wiring shows in which gate the integration asks: an unbound ToolAvailability throws when asked.
        $visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), self::tools());
        self::assertToolError($outline($visible), 'not_found');
        self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'not_found');

        $hidden = new DiviIntegration(
            $schema,
            FakeChangeLog::forbidden(),
            self::tools(new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED]))
        );
        $refusal = $outline($hidden);
        self::assertInstanceOf(WP_Error::class, $refusal);
        self::assertToolError($refusal, 'not_found');

        $this->expectExceptionMessage('ToolAvailability asked before Plugin bound it.');
        $outline(new DiviIntegration($schema, FakeChangeLog::forbidden(), new ToolAvailability()));
    }

In testLibraryIdsFollowTheLibraryAccess, vorher:

        $asked = 0;
        $hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static function () use (&$asked): bool {
            ++$asked;

            return false;
        });

        self::assertFalse($hidden->libraryIdsVisible());
        self::assertSame(1, $asked);
        self::assertFalse((new DiviIntegration($schema, FakeChangeLog::forbidden()))->libraryIdsVisible());
    }

Nachher:

        $hidden = new DiviIntegration(
            $schema,
            FakeChangeLog::forbidden(),
            self::tools(new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED]))
        );

        self::assertFalse($hidden->libraryIdsVisible());
        self::assertFalse((new DiviIntegration($schema, FakeChangeLog::forbidden()))->libraryIdsVisible());
        $this->expectExceptionMessage('ToolAvailability asked before Plugin bound it.');
        (new DiviIntegration($schema, FakeChangeLog::forbidden(), new ToolAvailability()))->libraryIdsVisible();
    }
  • Step 4: Tests laufen lassen – RED

Run: vendor/bin/phpunit tests/Unit/Abilities/ToolAvailabilityTest.php tests/Unit/PluginTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php

Expected: FAIL, u. a.

  • Error: Class "LIT\MCP\Abilities\ToolAvailability" not found (ToolAvailabilityTest, PluginTest, vier DiviIntegrationTest-Fälle)

  • Error: Call to undefined method LIT\MCP\Plugin::integrations()

  • Step 5: ToolAvailability anlegen

src/Abilities/ToolAvailability.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Abilities;

use LIT\MCP\Integrations\IntegrationRegistry;
use LogicException;

defined('ABSPATH') || exit;

/**
 * The Tools-tab state for integrations that gate their own tools on another group or tool (rulings P9, E9, audit
 * M2/L5). Integrations get this object before the registry and the policy exist – both need the integrations –
 * and ask it only at call time; Plugin binds it right after building them. A question before bind() is a wiring
 * error and throws, instead of quietly answering false.
 */
final class ToolAvailability
{
    private ?IntegrationRegistry $registry = null;
    private ?ExposurePolicy $policy = null;

    /** Called once by Plugin after the registry and the policy are built. */
    public function bind(IntegrationRegistry $registry, ExposurePolicy $policy): void
    {
        if ($this->policy !== null) {
            throw new LogicException('ToolAvailability is already bound.');
        }

        $this->registry = $registry;
        $this->policy = $policy;
    }

    /** Whether $group is switched on at all (ExposurePolicy::groupEnabled()). */
    public function groupEnabled(string $group): bool
    {
        return $this->policy()->groupEnabled($group);
    }

    /** Whether abilities of $group may write (ExposurePolicy::groupWritable()). */
    public function groupWritable(string $group): bool
    {
        return $this->policy()->groupWritable($group);
    }

    /** Whether the ability named $ability is registered and not disabled (ExposurePolicy::allows()). */
    public function allows(string $ability): bool
    {
        $policy = $this->policy();

        return $policy->allows($ability, $this->registry()->abilities());
    }

    private function policy(): ExposurePolicy
    {
        return $this->policy ?? throw new LogicException('ToolAvailability asked before Plugin bound it.');
    }

    private function registry(): IntegrationRegistry
    {
        return $this->registry ?? throw new LogicException('ToolAvailability asked before Plugin bound it.');
    }
}
  • Step 6: DiviIntegration – ToolAvailability statt sechs Closures

Import ergänzen: use LIT\MCP\Abilities\ToolAvailability; (use Closure; bleibt – gate()/shortForm()).

Vorher:

    /**
     * @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries
     *        (group "Divi-Bibliothek" writable); null = always.
     * @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group
     *        "Divi-Bibliothek" not disabled, ruling P9); null = always.
     * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy
     *        and lit-library-insert follow it (ruling P9, E9, audit M2); null = always.
     * @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled –
     *        lit/divi-node-copy follows it for library entries as source (audit L5); null = always.
     * @param (Closure(): bool)|null $themeBuilderWritable Tools-tab gate for writes to Theme Builder layouts in
     *        the Divi tools (group "Divi Theme Builder" writable, E9); null = always.
     * @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.
     */
    public function __construct(
        private ContentSchema $schema,
        private ChangeLog $log,
        ?Closure $libraryWritable = null,
        ?Closure $libraryVisible = null,
        private ?Closure $diviWritable = null,
        private ?Closure $libraryInsertable = null,
        ?Closure $themeBuilderWritable = null,
        ?Closure $themeBuilderVisible = null
    ) {
        $this->modules = new DiviModules();
        $this->design = new DesignSystemReader();
        $this->designWriter = new DesignSystemWriter();
        $roles = new DiviRoles();
        $this->access = new LibraryAccess(new DiviLibraryStore(), $roles, $libraryWritable, $libraryVisible);
        $this->themeBuilderAccess = new ThemeBuilderAccess(
            new DiviThemeBuilder(),
            $roles,
            $themeBuilderWritable,
            $themeBuilderVisible
        );
    }

Nachher:

    /** @var (Closure(): bool)|null Whether lit/divi-insert is enabled (ruling P9, E9, audit M2); null = always. */
    private ?Closure $diviWritable;

    /** @var (Closure(): bool)|null Whether lit/library-insert is enabled (audit L5); null = always. */
    private ?Closure $libraryInsertable;

    /**
     * @param ToolAvailability|null $tools The Tools-tab state, asked at call time: the groups "Divi-Bibliothek" and
     *        "Divi Theme Builder" gate their entries in the Divi tools (ruling P9, E9), lit/divi-insert gates
     *        lit/divi-node-copy, lit-library-insert and content-create's divi input (audit M2), lit/library-insert
     *        gates lit/divi-node-copy from a library entry (audit L5). null = no Tools tab: everything allowed.
     */
    public function __construct(
        private ContentSchema $schema,
        private ChangeLog $log,
        private ?ToolAvailability $tools = null
    ) {
        $this->modules = new DiviModules();
        $this->design = new DesignSystemReader();
        $this->designWriter = new DesignSystemWriter();
        $this->diviWritable = $this->gate(static fn (ToolAvailability $t): bool => $t->allows('lit/divi-insert'));
        $this->libraryInsertable = $this->gate(
            static fn (ToolAvailability $t): bool => $t->allows('lit/library-insert')
        );
        $roles = new DiviRoles();
        $this->access = new LibraryAccess(
            new DiviLibraryStore(),
            $roles,
            $this->gate(static fn (ToolAvailability $t): bool => $t->groupWritable(LibraryAccess::GROUP)),
            $this->gate(static fn (ToolAvailability $t): bool => $t->groupEnabled(LibraryAccess::GROUP))
        );
        $this->themeBuilderAccess = new ThemeBuilderAccess(
            new DiviThemeBuilder(),
            $roles,
            $this->gate(static fn (ToolAvailability $t): bool => $t->groupWritable(ThemeBuilderAccess::GROUP)),
            $this->gate(static fn (ToolAvailability $t): bool => $t->groupEnabled(ThemeBuilderAccess::GROUP))
        );
    }

(Falls Task 2 dem Konstruktor weitere Zeilen gegeben hat, bleiben sie erhalten; nur die sechs Closure-Parameter und ihre Weitergabe ändern sich.)

Vorher:

    /**
     * Divi short form for lit/content-create; available() reports whether Divi 5 is loaded,
     * writable() asks $writable (the Tools-tab gate, security M2).
     *
     * @param (Closure(): bool)|null $writable
     */
    public function contentComposer(?Closure $writable = null): ContentComposer
    {
        return new DiviContentComposer(
            $this->schema,
            $this->composition(),
            new DiviSaver(),
            $this->design,
            $writable
        );
    }

Nachher:

    /**
     * Divi short form for lit/content-create; available() reports whether Divi 5 is loaded, writable() follows
     * lit/divi-insert's Tools-tab state (security M2).
     */
    public function contentComposer(): ContentComposer
    {
        return $this->shortForm($this->diviWritable);
    }

In boot(), vorher:

        $check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->contentComposer());

Nachher:

        $check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->shortForm(null));

Vor dem Docblock von private function composition(): DiviComposition („The one wiring of the composition …“) einfügen:

    /**
     * A gate on the Tools-tab state for the classes that take a (Closure(): bool)|null: asked at call time, null
     * without $tools (always allowed).
     *
     * @param Closure(ToolAvailability): bool $ask
     * @return (Closure(): bool)|null
     */
    private function gate(Closure $ask): ?Closure
    {
        $tools = $this->tools;

        return $tools === null ? null : static fn (): bool => $ask($tools);
    }

    /** @param (Closure(): bool)|null $writable */
    private function shortForm(?Closure $writable): DiviContentComposer
    {
        return new DiviContentComposer($this->schema, $this->composition(), new DiviSaver(), $this->design, $writable);
    }

abilities() und library() bleiben unverändert (sie lesen $this->diviWritable/$this->libraryInsertable, jetzt Properties statt promoted Parameter).

  • Step 7: Plugin.php – Closures raus, Plugin::integrations()

Importe: use LIT\MCP\Abilities\ToolAvailability; ergänzen; entfernen:

use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;

Danach importiert Plugin aus Integrations\Divi nur noch DiviIntegration und den Guard aus Task 1 (DiviToolGate, ab Task 9 DiviFeatureGate).

Vorher (Stand nach 3a; die Argumente von CoreIntegration, WordPressIntegration, MediaIntegration ggf. mit den Ports aus Task 2):

        $schema = self::contentSchema();
        $registry = null;
        $policy = null;
        // Ruling P9: with the group "Divi-Bibliothek" disabled, the Divi tools do not see library entries.
        $libraryVisible = static function () use (&$policy): bool {
            return $policy instanceof ExposurePolicy && $policy->groupEnabled(LibraryAccess::GROUP);
        };
        // Security M2: content-create's divi input follows lit/divi-insert (asked at execute time,
        // when registry and policy exist), so the Divi group's switches stop every Divi layout write.
        $diviWritable = static function () use (&$registry, &$policy): bool {
            return $registry instanceof IntegrationRegistry
                && $policy instanceof ExposurePolicy
                && $policy->allows('lit/divi-insert', $registry->abilities());
        };
        // The Divi tools write to library entries only while the group "Divi-Bibliothek" may write (E9).
        $libraryWritable = static function () use (&$policy): bool {
            return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP);
        };
        // Audit L5: lit/divi-node-copy from a library entry follows lit/library-insert's Tools-tab state.
        $libraryInsertable = static function () use (&$registry, &$policy): bool {
            return $registry instanceof IntegrationRegistry
                && $policy instanceof ExposurePolicy
                && $policy->allows('lit/library-insert', $registry->abilities());
        };
        // Etappe 3b: the group "Divi Theme Builder" gates TB layouts in the Divi tools like the library group (E9).
        $themeBuilderVisible = static function () use (&$policy): bool {
            return $policy instanceof ExposurePolicy && $policy->groupEnabled(ThemeBuilderAccess::GROUP);
        };
        $themeBuilderWritable = static function () use (&$policy): bool {
            return $policy instanceof ExposurePolicy && $policy->groupWritable(ThemeBuilderAccess::GROUP);
        };
        $divi = new DiviIntegration(
            $schema,
            $changeLog,
            $libraryWritable,
            libraryVisible: $libraryVisible,
            diviWritable: $diviWritable,
            libraryInsertable: $libraryInsertable,
            themeBuilderWritable: $themeBuilderWritable,
            themeBuilderVisible: $themeBuilderVisible
        );
        $registry = new IntegrationRegistry(
            new CoreIntegration(self::pluginDir() . '/docs/guides'),
            new WordPressIntegration(
                $schema,
                $changeLog,
                $divi->contentComposer($diviWritable),
                $divi->libraryIdsVisible(...)
            ),
            new MediaIntegration($schema, $changeLog, $limits),
            $divi,
            $divi->library(),
            $divi->themeBuilder()
        );

        // Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
        // page reads its tabs.
        $registry->boot();

        $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));

Nachher:

        $schema = self::contentSchema();
        $tools = new ToolAvailability();
        $registry = self::integrations($schema, $changeLog, $limits, $tools);
        $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
        $tools->bind($registry, $policy);
        // Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
        // page reads its tabs.
        $registry->boot();

Vor public static function settingsPage(): ?SettingsPage einfügen (den new IntegrationRegistry(...)-Aufruf wortgleich mit dem Stand nach Task 2 übernehmen – nur $divi-Bau und contentComposer() ändern sich):

    /**
     * The integrations in registry order – the order of the Tools tab, the guide and the server instructions. The
     * Divi integration and its groups share $tools, which Plugin binds once registry and policy exist.
     *
     * @internal Public for PluginTest only.
     */
    public static function integrations(
        ContentSchema $schema,
        ChangeLog $changeLog,
        Limits $limits,
        ToolAvailability $tools
    ): IntegrationRegistry {
        $divi = new DiviIntegration($schema, $changeLog, $tools);

        return new IntegrationRegistry(
            new CoreIntegration(self::pluginDir() . '/docs/guides'),
            new WordPressIntegration(
                $schema,
                $changeLog,
                $divi->contentComposer(),
                $divi->libraryIdsVisible(...)
            ),
            new MediaIntegration($schema, $changeLog, $limits),
            $divi,
            $divi->library(),
            $divi->themeBuilder()
        );
    }

Reihenfolge in boot() danach: Registry bauen → Policy → bind() → $registry->boot() → AbilityRegistrar, ServerRegistrar → Reiter/SettingsPage (liest lit_mcp_admin_tabs erst bei register()/tabSlugs()).

  • Step 8: Tests laufen lassen – GREEN

Run: vendor/bin/phpunit tests/Unit/Abilities/ToolAvailabilityTest.php tests/Unit/PluginTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php Expected: PASS (OK).

  • Step 9: Restverweise prüfen

Run: grep -rn 'libraryVisible:\|diviWritable:\|libraryInsertable:\|themeBuilderWritable:\|themeBuilderVisible:\|contentComposer(\$' src tests Expected: genau ein Treffer, tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php:171 ($this->ability(diviWritable: false) – der eigene Test-Helfer dieses Tests, nicht DiviIntegration); sonst keine.

Run: grep -n 'use LIT\\MCP\\Integrations\\Divi' src/Plugin.php Expected: genau DiviIntegration und der Guard (Release\DiviToolGate, nach Task 9 Release\DiviFeatureGate).

  • Step 10: Gesamte Suite und Lint

Run: composer test && composer lint Expected: alle Tests grün (+8 gegenüber 3a; Task 3 gesamt +15), phpcs ohne Befund.

  • Step 11: Commit
git add src/Abilities/ToolAvailability.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php \
  tests/Unit/Abilities/ToolAvailabilityTest.php tests/Unit/PluginTest.php \
  tests/Unit/Integrations/Divi/DiviIntegrationTest.php
git commit -m "refactor: ToolAvailability ersetzt die sechs Referenz-Closures in Plugin (Frage vor der Verdrahtung wirft statt still false); DiviIntegration bekommt den Tools-Tab-Zustand als Objekt; Plugin::integrations() mit Verdrahtungstest für Reihenfolge, einmaliges Booten und spätes Binden" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Offene Punkte

  1. Zusammenführen mit Task 1/2: Plan und Probelauf beruhen auf HEAD e7793fd ohne Tasks 1/2. Die AbilityRegistrar-Zeile (Task 1: [$guard]) und die Port-Argumente aus Task 2 (CoreIntegration, WordPressIntegration, MediaIntegration, contentSchema()) bleiben unberührt bzw. wandern wortgleich nach Plugin::integrations(). Hat Task 2 DiviIntegration::__construct erweitert oder contentComposer($diviWritable) anders verdrahtet, ist das in 3b Step 6/7 nachzuziehen.
  2. Guard bleibt in Plugin: DiviToolGate (Task 9: DiviFeatureGate) wird weiter in Plugin::boot() gebaut – der einzige verbleibende Divi-Import neben DiviIntegration. Eine Methode DiviIntegration::guards(): list<AbilityGuard> wäre sauberer, würde aber Task 9 (Verdrahtung) überschneiden; Entscheidung bei Task 9.
  3. Öffentlicher Filter: lit_mcp_admin_tabs ist technisch ein öffentlicher Hook (als intern dokumentiert). Andere Plugins könnten Reiter anhängen – unkritisch (Admin-Seite, manage_options), aber in den Guides nicht erwähnen.
  4. CoreIndependenceTest (Task 2): DiviIntegration importiert jetzt Admin\DiviTab, Admin\SettingsPage, Cli\DiviCheckCommand – Richtung Divi → Kern, also zulässig. Admin/DiviTab.php bleibt die Ausnahme im Admin.
  5. Aufräumen nur beim Öffnen des Reiters: wie bisher kein Cron; liegengebliebene Prüfseiten verschwinden erst, wenn ein Admin den Reiter „Divi“ öffnet. Alternative (Aufräumen nach jedem Prüflauf innerhalb der Sperre in DiviCheckRoute::handle()) bewusst nicht umgesetzt – Verhaltensänderung über das Gerüst hinaus.
  6. Task 10 (Matrix-Tabelle, Admin-Hinweis im Reiter „Divi“) baut auf dem neuen DiviTab auf ($removed, load-Hook).
  7. Integrationsskript: tests/Integration/admin-render.php nutzt Plugin::settingsPage()->tabSlugs(); der Reiter „divi“ kommt jetzt über den Filter. Ein Lauf in DDEV (Admin-Seiten rendern, Reiter „Divi“ öffnen) steht aus.

Verifiziert: In einer Kopie des Repos (HEAD e7793fd, ohne Tasks 1/2) beide Stufen umgesetzt: nach 3a composer test OK (2631 Tests), composer lint sauber; nach 3b composer test OK (2639 Tests, +15 gegenüber 2624), composer lint sauber. RED-Meldungen aus Step 2 (3a) und Step 4 (3b) mit HEAD-Quellen bzw. Stand 3a beobachtet. Nicht verifiziert: Zusammenspiel mit dem Code aus Tasks 1/2 und ein DDEV-Lauf.


Teilplan: Tasks 4 und 5 – Fähigkeiten im Manifest und Signatur-Fingerabdruck

Teil von docs/superpowers/plans/2026-10-18-divi-faehigkeiten.md (Gerüst, D1–D7, E1–E4, Constraints gelten unverändert). Beide Tasks sind verhaltensneutral: Nichts wertet die Fähigkeiten oder die Signaturen aus, bevor Task 6 beginnt. Modell je Gerüst: Sonnet. Ausgangsstand: Branch etappe-4, HEAD e7793fd plus Tasks 1–3.

Abweichungen und Ergänzungen zu den „Verbindlichen Signaturen“ des Gerüsts (bitte zuerst lesen)

  1. Manifest::$constants ist jetzt list<ConstantRequirement> (Typ der öffentlichen Eigenschaft ändert sich von list<string>). Der Konstruktor akzeptiert weiter list<string|ConstantRequirement>: ein bloßer Name wird zu einem ConstantRequirement ohne eigene Fähigkeiten. Die Adapter schreiben deshalb weiter constants: ['ET_…']. Nutzer von $constants/names() (alle geprüft): Manifest::names(), ManifestCheck::codeChecks() (Label bleibt <Bereich>: Konstante <NAME>), DiviModulesTest (angepasst), ManifestTest/ManifestCheckTest (bauen nur, bleiben unverändert grün), DiviBoundaryTest (nutzt nur names(), unverändert).
  2. Requirement::features() liefert nur die eigenen Fähigkeiten ([] = „Standard des Manifests“). Wer die wirksamen Fähigkeiten braucht, ruft immer Manifest::featuresOf($requirement) (Task 6, 7, 10). Zusätzlich neu: Manifest::requirements(): list<Requirement> (Calls, Konstanten, Mirrors in dieser Reihenfolge).
  3. Call::__construct(string $symbol, ?int $args = null, array $features = []) und Mirror::__construct(string $id, string $file, string $function, ?string $class, string $what, array $features = []) wie im Gerüst; $features ist private readonly und nur über features() lesbar. Requirement::label() liefert den Namen ohne Bereich (GlobalPreset::get_data(), Konstante ET_X).
  4. Neu gegenüber dem Gerüst (alle klein, alle in Release/): DiviAdapters::calls(): list<Call> (jedes Symbol einmal), SignatureFingerprint::canonical(Call): ?string (der Text, aus dem der Hash entsteht; für Tests und Diagnose), DiviReference::SIGNATURE_PREFIX = 'sig:', DiviReference::signatureKey(Call): string, DiviReference::currentSignatures(?array $calls = null): array<string, string>.
  5. DiviCheckCommand::__construct(…, ?Closure $sourceDir = null, ?Closure $signatures = null) – siebter, angehängter Parameter (Test-Naht; null = DiviReference::currentSignatures()); bestehende Aufrufer bleiben gültig.
  6. DiviReference::expected() bleibt im Code unverändert: Es liest schon heute alle String-Schlüssel der Datei, sig:<symbol> kommt also ohne Änderung mit. Nur Docblock und Test ändern sich. ManifestCheck::mirrorChecks() schlägt weiter nur nach Mirror-IDs nach und ignoriert die sig:-Schlüssel (Task 6 wertet sie aus).
  7. Fachliche Festlegungen, die über das Gerüst hinausgehen (Begründung in den Tabellen): PageRead hängt an den drei Aufrufen des DesignSystemReader (Seiten-Lesetools holen die Presetnamen über read()), weil kein Adapter sonst einen Divi-Aufruf zum Seitenlesen hat; die lesenden Hilfsaufrufe des DesignSystemWriter (Attribut-Mapping, Modul-Einstellungen, Rollenrecht) tragen nur PresetWrite, nicht DesignRead (siehe „Offene Punkte“ 1 und 2).
  8. Divi-Aufrufe ruft das Plugin nie mit benannten Argumenten auf (geprüft, siehe Task 5): Der Signatur-Fingerabdruck enthält deshalb keine Parameternamen und keine Default-Werte.

Task 4: DiviFeature, Requirement, Fähigkeiten an allen Manifest-Einträgen

Files:

  • Create: src/Integrations/Divi/Release/DiviFeature.php
  • Create: src/Integrations/Divi/Release/Requirement.php
  • Create: src/Integrations/Divi/Release/ConstantRequirement.php
  • Modify: src/Integrations/Divi/Release/Call.php, Mirror.php, Manifest.php, ManifestCheck.php
  • Modify (nur manifest() und ein use): src/Integrations/Divi/Pages/DiviPages.php, src/Integrations/Divi/Library/DiviLibraryStore.php, src/Integrations/Divi/Library/DiviRoles.php, src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php, src/Integrations/Divi/Design/DesignSystemReader.php, src/Integrations/Divi/Design/DesignSystemWriter.php, src/Integrations/Divi/Schema/DiviModules.php, src/Integrations/Divi/Pages/DynamicContentMap.php
  • Create: tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php, tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php
  • Modify: tests/Unit/Integrations/Divi/Release/ManifestTest.php, tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php

Interfaces:

enum DiviFeature: string { /* D1, elf Fälle */ public function isWrite(): bool; public function label(): string; }
interface Requirement { public function label(): string; /** @return list<DiviFeature> own features, [] = default */ public function features(): array; }
final class ConstantRequirement implements Requirement { public function __construct(public readonly string $name, array $features = []); }
final class Call implements Requirement     { __construct(string $symbol, ?int $args = null, array $features = []) }
final class Mirror implements Requirement   { __construct(string $id, string $file, string $function, ?string $class, string $what, array $features = []) }
final class Manifest {
    public readonly array $constants;       // list<ConstantRequirement>
    public function __construct(string $area, array $calls = [], array $constants = [], array $postTypes = [],
        array $taxonomies = [], array $blockTypes = [], array $keys = [], array $mirrors = [], array $features = []);
    public function requirements(): array;                       // list<Requirement>
    public function featuresOf(Requirement $requirement): array; // own, else the manifest default
}

Zuordnung (Ergebnis der Prüfung aller acht Adapter). Gelesen wurde jede Methode jedes Adapters samt Aufrufern (grep über src/): welche Methode welchen Call nutzt und ob sie von Lese-Tools, Schreib-Tools oder beiden erreicht wird. Registrierungs-Einträge (postTypes, taxonomies, blockTypes, keys) sind keine Anforderungen (D3) und tragen keine Fähigkeit. Die Spalte „Eintrag“ nennt das Label (Nachbau = Mirror).

Adapter Eintrag Fähigkeiten Begründung
DiviPages ET_Core_PageResource::remove_static_resources() page-write, tb-write clearPageCss() (DiviSaver nach jedem Seiten-Speichern) und DiviThemeBuilder::clearSiteCaches() (TB-Tools) rufen denselben Divi-Aufruf; ein Bruch sperrt bewusst beide (D2).
DiviPages et_builder_get_public_post_types() page-write Nur isLinkable() ← WpLinkTargets ← Compose ({{link:ID}}, post_link_url_*), also nur beim Schreiben; kein Lese-Pfad.
DiviPages Nachbau SyncToServerController::update() (vb-sync-flags) page-write setFlags() schreibt die Builder-Kennzeichen jeder gespeicherten Seite.
DiviLibraryStore GlobalLayout::is_global_layout_template() library-read, library-write isGlobal() dient dem Beschreiben und Suchen von Bibliothekseinträgen (Lesen) und den globalen Änderungen/dem Einfügen (Schreiben). Geteilter Aufruf, beide Fähigkeiten.
DiviLibraryStore Nachbau et_pb_submit_layout() (library-submit-layout) library-write create() legt Terme und Meta neuer Einträge so an wie Divi.
DiviRoles et_pb_is_allowed() roles Einziger Aufruf; alle Rechteprüfungen (Bibliothek, Theme Builder, Dynamic Content) laufen darüber. Die Tools erklären Roles zusätzlich in Task 8.
DiviThemeBuilder et_theme_builder_get_theme_builder_post_id() tb-read, tb-write liveId() liest den Live-Eintrag ($create = false) oder legt ihn wie Divis erster Speichervorgang an (true).
DiviThemeBuilder et_theme_builder_get_theme_builder_template_ids() tb-read templateIds(): nur Lesen der Vorlagenliste.
DiviThemeBuilder et_theme_builder_get_template() tb-read template(): nur Lesen einer Vorlage.
DiviThemeBuilder et_theme_builder_insert_layout() tb-write insertLayout().
DiviThemeBuilder et_theme_builder_store_template() tb-write storeTemplate().
DiviThemeBuilder et_theme_builder_get_flat_template_settings_options() tb-read flatConditions(): Bedingungsliste für Anzeige und Validierung (Schreib-Tools erklären zusätzlich tb-read, Task 8).
DiviThemeBuilder et_theme_builder_clear_wp_cache() tb-write, page-write clearSiteCaches() läuft nach TB-Schreibvorgängen und nach jedem Seiten-Speichern mit seitenübergreifendem Bereich (DiviSaver, Bibliothek/global).
DiviThemeBuilder et_() (nur Existenz) tb-write Hilfsfunktion, die et_theme_builder_store_template() intern braucht (E15).
DiviThemeBuilder ET_Core_PageResource::remove_static_resources() tb-write, page-write Derselbe Aufruf wie in DiviPages (clearSiteCaches()); gleiche Fähigkeiten.
DiviThemeBuilder Nachbau et_theme_builder_api_save() (tb-api-save) tb-write attach()/detach()/unmarkUnused() spiegeln die Vorlagenliste.
DiviThemeBuilder Nachbau et_theme_builder_store_template() (tb-store-template) tb-write setConditions()/setEnabled().
DiviThemeBuilder Nachbau et_theme_builder_load_template_setting_options() (tb-load-setting-options) tb-read, tb-write TemplateConditions zerlegt Bedingungs-IDs beim Anzeigen (lit/tb-list) wie beim Schreiben.
DesignSystemReader GlobalData::get_global_colors() design-read, page-read read() holt Farben, Variablen und Presets immer zusammen; lit-divi-design-system braucht sie, lit-divi-outline/lit-divi-node-get die Presetnamen daraus.
DesignSystemReader GlobalData::get_global_variables() design-read, page-read wie oben.
DesignSystemReader GlobalPreset::get_data() design-read, page-read wie oben.
DesignSystemWriter GlobalPreset::get_data() design-read, preset-write presets() liest den Bestand für lit/divi-preset-get und das Design-System-Tool; savePresets() liest den aktuellen Bestand vor dem Schreiben.
DesignSystemWriter GlobalPreset::prepare_data() preset-write savePresets().
DesignSystemWriter GlobalPreset::validate_preset_count() preset-write savePresets().
DesignSystemWriter GlobalPreset::get_deleted_preset_ids() preset-write savePresets() beim Löschen.
DesignSystemWriter GlobalPreset::remove_preset_from_legacy_option() preset-write savePresets() beim Löschen.
DesignSystemWriter GlobalPreset::save_data() preset-write savePresets().
DesignSystemWriter Conversion::get_preset_attrs_mapping() preset-write presetAttrs() für Preset-Aufbau; bewusst nicht design-read (siehe Offene Punkte 1).
DesignSystemWriter Conversion::get_preset_attrs() preset-write wie oben.
DesignSystemWriter GlobalPreset::get_group_preset_default_attr() catalog, preset-write groupsOf(): Gruppen eines Moduls – gebraucht von Preset-Tools und vom Composer (group_presets in insert/update/content-create, Massenzuweisung, preset-get), die alle catalog erklären (Controller-Ruling, Abstimmung mit Task 8).
DesignSystemWriter ModuleRegistration::get_module_settings() catalog, preset-write groupsOf(); wie oben. Ein Bruch hier sperrt lit-divi-design-system nicht (es erklärt nur design-read), wohl aber alle Tools, die Gruppen auflösen.
DesignSystemWriter UserRole::can_current_user_use_visual_builder() preset-write DesignRights::presets() prüft das Rollenrecht nur vor Preset-Schreibvorgängen.
DesignSystemWriter Nachbau GlobalPresetController::sync() (preset-sync) preset-write Speicherablauf der Presets.
DiviModules ModuleRegistration::get_all_core_modules_metadata() catalog catalog().
DiviModules ModuleRegistration::get_default_attrs() catalog catalog() (Standardwerte je Modul).
DiviModules et_builder_enabled_for_post_type() catalog builderEnabledFor() (Divi-Schalter je Beitragstyp; hat heute keinen Aufrufer, siehe Offene Punkte 6).
DiviModules Konstante ET_BUILDER_PRODUCT_VERSION catalog builderVersion() schreibt die Version in neue Blöcke; gehört zum Katalog, den die Compose-Tools nutzen.
DynamicContentMap DynamicContentOptions::get_options() dynamic-sources forPost()/options().

Abdeckung: page-read (Reader), page-write (Pages, TB-Cache), catalog, dynamic-sources, design-read, preset-write, library-read, library-write, roles, tb-read, tb-write – alle elf Fälle von D1 kommen in mindestens einem Manifest vor (DiviFeatureAssignmentTest). Lesende Adapter (DesignSystemReader, DiviRoles, DiviModules, DynamicContentMap) tragen keine Schreib-Fähigkeit; jeder Mirror dient mindestens einer Schreib-Fähigkeit (beides im Test).

  • Step 1: Tests schreiben (RED)

Neue Datei tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php:

<?php

declare(strict_types=1);

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

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

final class DiviFeatureTest extends TestCase
{
    public function testTheElevenFeaturesAndTheirValues(): void
    {
        self::assertSame(
            [
                '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',
            ],
            array_column(array_map(
                static fn (DiviFeature $feature): array => [$feature->name, $feature->value],
                DiviFeature::cases()
            ), 1, 0)
        );
    }

    public function testOnlyTheFourWriteFeaturesWrite(): void
    {
        $writing = array_values(array_map(
            static fn (DiviFeature $feature): string => $feature->value,
            array_filter(DiviFeature::cases(), static fn (DiviFeature $feature): bool => $feature->isWrite())
        ));

        self::assertSame(['page-write', 'preset-write', 'library-write', 'tb-write'], $writing);
    }

    public function testEveryFeatureHasAUniqueGermanLabel(): void
    {
        $labels = array_map(static fn (DiviFeature $feature): string => $feature->label(), DiviFeature::cases());

        self::assertCount(count(DiviFeature::cases()), array_unique($labels));
        self::assertSame('Seiten schreiben', DiviFeature::PageWrite->label());
        self::assertSame('Theme Builder lesen', DiviFeature::ThemeBuilderRead->label());
        foreach ($labels as $label) {
            self::assertNotSame('', trim($label));
        }
    }

    public function testValuesResolveBackToTheFeature(): void
    {
        self::assertSame(DiviFeature::ThemeBuilderWrite, DiviFeature::from('tb-write'));
        self::assertNull(DiviFeature::tryFrom('page'));
    }
}

Neue Datei tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php (die Tabelle oben als ausführbare Festlegung; ein geänderter Tag erscheint im Review als geänderte Zeile):

<?php

declare(strict_types=1);

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

use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use PHPUnit\Framework\TestCase;

/**
 * Every manifest entry of every adapter names the Divi features it serves (D2 of the plan "Divi-Fähigkeiten"), and
 * the assignment is pinned here: a changed tag shows up in review as a changed row of this table.
 */
final class DiviFeatureAssignmentTest extends TestCase
{
    /** Adapter => requirement ("Nachbau " before a mirror's label) => features (sorted values). */
    private const EXPECTED = [
        DiviPages::class => [
            'ET_Core_PageResource::remove_static_resources()' => ['page-write', 'tb-write'],
            'et_builder_get_public_post_types()' => ['page-write'],
            'Nachbau SyncToServerController::update()' => ['page-write'],
        ],
        DiviLibraryStore::class => [
            'GlobalLayout::is_global_layout_template()' => ['library-read', 'library-write'],
            'Nachbau et_pb_submit_layout()' => ['library-write'],
        ],
        DiviRoles::class => [
            'et_pb_is_allowed()' => ['roles'],
        ],
        DiviThemeBuilder::class => [
            'et_theme_builder_get_theme_builder_post_id()' => ['tb-read', 'tb-write'],
            'et_theme_builder_get_theme_builder_template_ids()' => ['tb-read'],
            'et_theme_builder_get_template()' => ['tb-read'],
            'et_theme_builder_insert_layout()' => ['tb-write'],
            'et_theme_builder_store_template()' => ['tb-write'],
            'et_theme_builder_get_flat_template_settings_options()' => ['tb-read'],
            'et_theme_builder_clear_wp_cache()' => ['page-write', 'tb-write'],
            'et_()' => ['tb-write'],
            'ET_Core_PageResource::remove_static_resources()' => ['page-write', 'tb-write'],
            'Nachbau et_theme_builder_api_save()' => ['tb-write'],
            'Nachbau et_theme_builder_store_template()' => ['tb-write'],
            'Nachbau et_theme_builder_load_template_setting_options()' => ['tb-read', 'tb-write'],
        ],
        DesignSystemReader::class => [
            'GlobalData::get_global_colors()' => ['design-read', 'page-read'],
            'GlobalData::get_global_variables()' => ['design-read', 'page-read'],
            'GlobalPreset::get_data()' => ['design-read', 'page-read'],
        ],
        DesignSystemWriter::class => [
            'GlobalPreset::get_data()' => ['design-read', 'preset-write'],
            'GlobalPreset::prepare_data()' => ['preset-write'],
            'GlobalPreset::validate_preset_count()' => ['preset-write'],
            'GlobalPreset::get_deleted_preset_ids()' => ['preset-write'],
            'GlobalPreset::remove_preset_from_legacy_option()' => ['preset-write'],
            'GlobalPreset::save_data()' => ['preset-write'],
            'Conversion::get_preset_attrs_mapping()' => ['preset-write'],
            'Conversion::get_preset_attrs()' => ['preset-write'],
            'GlobalPreset::get_group_preset_default_attr()' => ['catalog', 'preset-write'],
            'ModuleRegistration::get_module_settings()' => ['catalog', 'preset-write'],
            'UserRole::can_current_user_use_visual_builder()' => ['preset-write'],
            'Nachbau GlobalPresetController::sync()' => ['preset-write'],
        ],
        DiviModules::class => [
            'ModuleRegistration::get_all_core_modules_metadata()' => ['catalog'],
            'ModuleRegistration::get_default_attrs()' => ['catalog'],
            'et_builder_enabled_for_post_type()' => ['catalog'],
            'Konstante ET_BUILDER_PRODUCT_VERSION' => ['catalog'],
        ],
        DynamicContentMap::class => [
            'DynamicContentOptions::get_options()' => ['dynamic-sources'],
        ],
    ];

    public function testEveryRequirementOfEveryAdapterServesAtLeastOneFeature(): void
    {
        $bare = [];
        foreach (DiviAdapters::manifests() as $class => $manifest) {
            foreach ($manifest->requirements() as $requirement) {
                $features = $manifest->featuresOf($requirement);
                if ($features === [] || array_unique($features, SORT_REGULAR) !== $features) {
                    $bare[] = $class . ': ' . $requirement->label();
                }
            }
        }

        self::assertSame([], $bare, "Anforderungen ohne (eindeutige) Fähigkeit:\n" . implode("\n", $bare));
    }

    public function testEveryFeatureIsServedByAtLeastOneRequirement(): void
    {
        $served = [];
        foreach (DiviAdapters::manifests() as $manifest) {
            foreach ($manifest->requirements() as $requirement) {
                foreach ($manifest->featuresOf($requirement) as $feature) {
                    $served[$feature->value] = true;
                }
            }
        }

        $missing = array_values(array_diff(
            array_map(static fn (DiviFeature $feature): string => $feature->value, DiviFeature::cases()),
            array_keys($served)
        ));

        self::assertSame([], $missing, 'Fähigkeiten ohne Anforderung in einem Manifest');
    }

    public function testTheAssignmentIsThePinnedTable(): void
    {
        $actual = [];
        foreach (DiviAdapters::manifests() as $class => $manifest) {
            foreach ($manifest->requirements() as $requirement) {
                $values = array_map(
                    static fn (DiviFeature $feature): string => $feature->value,
                    $manifest->featuresOf($requirement)
                );
                sort($values);
                $name = ($requirement instanceof Mirror ? 'Nachbau ' : '') . $requirement->label();
                $actual[$class][$name] = $values;
            }
        }

        self::assertSame(self::EXPECTED, $actual);
        self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all eight adapters, in report order');
    }

    public function testReadingAdaptersCarryNoWriteFeature(): void
    {
        $readingOnly = [DesignSystemReader::class, DiviRoles::class, DiviModules::class, DynamicContentMap::class];

        foreach ($readingOnly as $class) {
            $manifest = DiviAdapters::manifests()[$class];
            foreach ($manifest->requirements() as $requirement) {
                foreach ($manifest->featuresOf($requirement) as $feature) {
                    self::assertFalse($feature->isWrite(), $class . ': ' . $requirement->label());
                }
            }
        }
    }

    public function testEveryMirrorServesAWriteFeature(): void
    {
        foreach (DiviAdapters::manifests() as $class => $manifest) {
            foreach ($manifest->mirrors as $mirror) {
                $writes = array_filter(
                    $manifest->featuresOf($mirror),
                    static fn (DiviFeature $feature): bool => $feature->isWrite()
                );
                self::assertNotSame([], $writes, $class . ': Nachbau ' . $mirror->id . ' dient keinem Schreib-Feature');
            }
        }
    }
}

Änderungen an bestehenden Tests (ManifestTest: vier neue Tests am Ende; DiviModulesTest: constants sind jetzt Objekte):

diff --git a/tests/Unit/Integrations/Divi/Release/ManifestTest.php b/tests/Unit/Integrations/Divi/Release/ManifestTest.php
--- a/tests/Unit/Integrations/Divi/Release/ManifestTest.php
+++ b/tests/Unit/Integrations/Divi/Release/ManifestTest.php
@@ -5,6 +5,8 @@ declare(strict_types=1);
 namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
 
 use LIT\MCP\Integrations\Divi\Release\Call;
+use LIT\MCP\Integrations\Divi\Release\ConstantRequirement;
+use LIT\MCP\Integrations\Divi\Release\DiviFeature;
 use LIT\MCP\Integrations\Divi\Release\Manifest;
 use LIT\MCP\Integrations\Divi\Release\Mirror;
 use PHPUnit\Framework\TestCase;
@@ -53,4 +55,61 @@ final class ManifestTest extends TestCase
         );
         self::assertSame('Bibliothek', $manifest->area);
     }
+
+    public function testPlainConstantNamesBecomeRequirementsWithALabel(): void
+    {
+        $manifest = new Manifest(
+            Manifest::CATALOG,
+            constants: ['ET_BUILDER_PRODUCT_VERSION', new ConstantRequirement('ET_OTHER', [DiviFeature::Roles])]
+        );
+
+        self::assertSame(
+            ['ET_BUILDER_PRODUCT_VERSION', 'ET_OTHER'],
+            array_map(static fn (ConstantRequirement $constant): string => $constant->name, $manifest->constants)
+        );
+        self::assertSame('Konstante ET_OTHER', $manifest->constants[1]->label());
+        self::assertSame([], $manifest->constants[0]->features());
+        self::assertSame([DiviFeature::Roles], $manifest->constants[1]->features());
+        self::assertSame(['ET_BUILDER_PRODUCT_VERSION', 'ET_OTHER'], $manifest->names());
+    }
+
+    public function testRequirementsAreCallsConstantsAndMirrorsInThatOrder(): void
+    {
+        $call = new Call('et_pb_is_allowed', 1);
+        $mirror = new Mirror('m', 'a.php', 'a', null, 'Terme');
+        $manifest = new Manifest(Manifest::LIBRARY, calls: [$call], constants: ['ET_X'], mirrors: [$mirror]);
+
+        $requirements = $manifest->requirements();
+
+        self::assertCount(3, $requirements);
+        self::assertSame($call, $requirements[0]);
+        self::assertSame('Konstante ET_X', $requirements[1]->label());
+        self::assertSame($mirror, $requirements[2]);
+    }
+
+    public function testFeaturesOfPrefersTheOwnFeaturesOverTheManifestDefault(): void
+    {
+        $own = new Call('et_a', null, [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite]);
+        $inherited = new Call('et_b');
+        $mirror = new Mirror('m', 'a.php', 'a', null, 'x', [DiviFeature::LibraryWrite]);
+        $manifest = new Manifest(
+            Manifest::PAGES,
+            calls: [$own, $inherited],
+            constants: ['ET_X'],
+            mirrors: [$mirror],
+            features: [DiviFeature::PageRead]
+        );
+
+        self::assertSame([DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite], $manifest->featuresOf($own));
+        self::assertSame([DiviFeature::PageRead], $manifest->featuresOf($inherited));
+        self::assertSame([DiviFeature::PageRead], $manifest->featuresOf($manifest->constants[0]));
+        self::assertSame([DiviFeature::LibraryWrite], $manifest->featuresOf($mirror));
+    }
+
+    public function testWithoutAnyFeaturesAnEntryHasNone(): void
+    {
+        $call = new Call('et_a');
+
+        self::assertSame([], (new Manifest(Manifest::PAGES, calls: [$call]))->featuresOf($call));
+    }
 }
diff --git a/tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php b/tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php
--- a/tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php
+++ b/tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php
@@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Schema;
 use Brain\Monkey\Functions;
 use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
 use LIT\MCP\Integrations\Divi\Release\Call;
+use LIT\MCP\Integrations\Divi\Release\ConstantRequirement;
 use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
 use LIT\MCP\Integrations\Divi\Release\Manifest;
 use LIT\MCP\Integrations\Divi\Schema\DiviModules;
@@ -35,7 +36,10 @@ final class DiviModulesTest extends TestCase
             array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
         );
         self::assertSame(['divi/global-layout'], $manifest->blockTypes);
-        self::assertSame(['ET_BUILDER_PRODUCT_VERSION'], $manifest->constants);
+        self::assertSame(
+            ['ET_BUILDER_PRODUCT_VERSION'],
+            array_map(static fn (ConstantRequirement $constant): string => $constant->name, $manifest->constants)
+        );
     }
 
     public function testInternalPostTypesAreDivisOwn(): void

Run: composer test -- --filter 'DiviFeatureTest|DiviFeatureAssignmentTest|ManifestTest|DiviModulesTest' Expected: FAIL – Error: Class "LIT\MCP\Integrations\Divi\Release\DiviFeature" not found (bzw. ConstantRequirement).

  • Step 2: Enum, Interface und Konstanten-Anforderung anlegen

Der phpcs:disable-Kommentar in DiviFeature.php folgt SaveScope/ValueKind: Die installierten PHPCompatibility-Regeln lesen $this in Enum-Methoden sonst als Fehler.

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

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
// phpcs:disable PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext

/**
 * What the plugin can do with Divi, as far as a Divi update may break it separately: every manifest entry (call,
 * mirror, constant) belongs to one or more features, and the release gate decides per feature.
 */
enum DiviFeature: string
{
    case PageRead = 'page-read';
    case PageWrite = 'page-write';
    case Catalog = 'catalog';
    case DynamicSources = 'dynamic-sources';
    case DesignRead = 'design-read';
    case PresetWrite = 'preset-write';
    case LibraryRead = 'library-read';
    case LibraryWrite = 'library-write';
    case Roles = 'roles';
    case ThemeBuilderRead = 'tb-read';
    case ThemeBuilderWrite = 'tb-write';

    /** Whether the feature changes data (the four *Write cases). */
    public function isWrite(): bool
    {
        return match ($this) {
            self::PageWrite, self::PresetWrite, self::LibraryWrite, self::ThemeBuilderWrite => true,
            default => false,
        };
    }

    /** Name for messages, the status tool and the admin tab (German). */
    public function label(): string
    {
        return match ($this) {
            self::PageRead => 'Seiten lesen',
            self::PageWrite => 'Seiten schreiben',
            self::Catalog => 'Modulkatalog',
            self::DynamicSources => 'Dynamische Inhalte',
            self::DesignRead => 'Designsystem lesen',
            self::PresetWrite => 'Presets schreiben',
            self::LibraryRead => 'Bibliothek lesen',
            self::LibraryWrite => 'Bibliothek schreiben',
            self::Roles => 'Divi-Rollen',
            self::ThemeBuilderRead => 'Theme Builder lesen',
            self::ThemeBuilderWrite => 'Theme Builder schreiben',
        };
    }
}

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

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/** One thing an adapter needs from Divi (a call, a mirrored function or a constant) and the features it serves. */
interface Requirement
{
    /** Name for reports, e.g. "GlobalPreset::get_data()" (without the manifest area). */
    public function label(): string;

    /**
     * The features this requirement serves itself; [] = the default of the manifest that lists it
     * (Manifest::featuresOf() resolves that).
     *
     * @return list<DiviFeature>
     */
    public function features(): array;
}

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

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/** A Divi constant an adapter reads (spec §4.1); it must be defined. */
final class ConstantRequirement implements Requirement
{
    /** @param list<DiviFeature> $features [] = the default of the manifest. */
    public function __construct(public readonly string $name, private readonly array $features = [])
    {
    }

    public function label(): string
    {
        return 'Konstante ' . $this->name;
    }

    public function features(): array
    {
        return $this->features;
    }
}
  • Step 3: Call, Mirror, Manifest, ManifestCheck ändern

src/Integrations/Divi/Release/Call.php (vollständig, neu):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

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

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

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

    public function features(): array
    {
        return $this->features;
    }

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

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

src/Integrations/Divi/Release/Mirror.php (vollständig, neu):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

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

    public function features(): array
    {
        return $this->features;
    }

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

        return $class . $this->function . '()';
    }
}

src/Integrations/Divi/Release/Manifest.php (vollständig, neu):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

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

    /** @var list<ConstantRequirement> */
    public readonly array $constants;

    /**
     * @param string                           $area       One of the constants above (the five areas of spec §3).
     * @param list<Call>                       $calls      Divi functions and static methods the adapter calls.
     * @param list<string|ConstantRequirement> $constants  Divi constants it reads; a plain name stands for a
     *                                                     ConstantRequirement without features of its own.
     * @param list<string>                     $postTypes  Divi post types it needs registered.
     * @param list<string>                     $taxonomies Divi taxonomies it needs registered.
     * @param list<string>                     $blockTypes Divi block types it needs (registered or in Divi's
     *                                                     module metadata).
     * @param list<string>                     $keys       Other Divi names it uses as strings: meta keys, options,
     *                                                     internal post types that need no registration check,
     *                                                     query arguments.
     * @param list<Mirror>                     $mirrors    Divi functions whose storage logic it repeats.
     * @param list<DiviFeature>                $features   The features of every call, mirror and constant that names
     *                                                     none itself (featuresOf()).
     */
    public function __construct(
        public readonly string $area,
        public readonly array $calls = [],
        array $constants = [],
        public readonly array $postTypes = [],
        public readonly array $taxonomies = [],
        public readonly array $blockTypes = [],
        public readonly array $keys = [],
        public readonly array $mirrors = [],
        public readonly array $features = []
    ) {
        $this->constants = array_map(
            static fn (string|ConstantRequirement $constant): ConstantRequirement => is_string($constant)
                ? new ConstantRequirement($constant)
                : $constant,
            array_values($constants)
        );
    }

    /** @return list<Requirement> Calls, constants and mirrors – everything the release gate checks as code. */
    public function requirements(): array
    {
        return [...$this->calls, ...$this->constants, ...$this->mirrors];
    }

    /** @return list<DiviFeature> The features $requirement names itself, else this manifest's default. */
    public function featuresOf(Requirement $requirement): array
    {
        $own = $requirement->features();

        return $own !== [] ? $own : $this->features;
    }

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

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

src/Integrations/Divi/Release/ManifestCheck.php – Konstanten-Check nutzt Label und Namen der Anforderung (Schlüssel bleibt <Bereich>: Konstante <NAME>):

diff --git a/src/Integrations/Divi/Release/ManifestCheck.php b/src/Integrations/Divi/Release/ManifestCheck.php
--- a/src/Integrations/Divi/Release/ManifestCheck.php
+++ b/src/Integrations/Divi/Release/ManifestCheck.php
@@ -57,7 +57,8 @@ class ManifestCheck
                 $checks[$manifest->area . ': ' . $call->label()] = static fn (): bool => self::accepts($call);
             }
             foreach ($manifest->constants as $constant) {
-                $checks[$manifest->area . ': Konstante ' . $constant] = static fn (): bool => defined($constant);
+                $checks[$manifest->area . ': ' . $constant->label()] =
+                    static fn (): bool => defined($constant->name);
             }
         }
 

Run: composer test -- --filter 'DiviFeatureTest|ManifestTest|ManifestCheckTest|DiviModulesTest|DiviBoundaryTest' Expected: PASS. DiviFeatureAssignmentTest ist noch rot: testEveryRequirementOfEveryAdapterServesAtLeastOneFeature meldet Anforderungen ohne (eindeutige) Fähigkeit: mit 37 Zeilen (…\DiviPages: ET_Core_PageResource::remove_static_resources() usw.), testEveryFeatureIsServedByAtLeastOneRequirement meldet die elf Fähigkeiten als fehlend.

  • Step 4: Alle acht Adapter taggen

Jeweils ein neues use LIT\MCP\Integrations\Divi\Release\DiviFeature; (alphabetisch nach DiviAdapter) und der neue manifest()-Body. Außer manifest() und dem use ändert sich nichts an den Dateien.

DiviPages::manifest() (Standard page-write; der geteilte Aufruf trägt beide Fähigkeiten):

    public static function manifest(): Manifest
    {
        return new Manifest(
            Manifest::PAGES,
            calls: [
                // Also called by DiviThemeBuilder::clearSiteCaches() – a break locks both features.
                new Call(
                    'ET_Core_PageResource::remove_static_resources',
                    3,
                    [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite]
                ),
                new Call('et_builder_get_public_post_types', 0),
            ],
            keys: ['_et_pb_use_divi_5', '_et_pb_use_builder', '_et_pb_show_page_creation', 'et_fb'],
            mirrors: [
                new Mirror(
                    'vb-sync-flags',
                    'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php',
                    'update',
                    'SyncToServerController',
                    'Builder-Kennzeichen einer Seite (_et_pb_use_divi_5, _et_pb_use_builder, _et_pb_show_page_creation)'
                ),
            ],
            features: [DiviFeature::PageWrite]
        );
    }

DiviLibraryStore::manifest() (Standard library-write gilt nur für den Mirror; der Aufruf nennt beide):

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

DiviRoles::manifest():

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

DiviThemeBuilder::manifest() (Standard tb-read; Schreib- und geteilte Einträge nennen ihre Fähigkeiten):

    public static function manifest(): Manifest
    {
        $dir = 'includes/builder/frontend-builder/theme-builder/';

        return new Manifest(
            Manifest::THEME_BUILDER,
            calls: [
                // liveId(): reads the live post, or creates it like Divi's first save.
                new Call(
                    'et_theme_builder_get_theme_builder_post_id',
                    2,
                    [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite]
                ),
                new Call('et_theme_builder_get_theme_builder_template_ids', 2),
                new Call('et_theme_builder_get_template', 1),
                new Call('et_theme_builder_insert_layout', 1, [DiviFeature::ThemeBuilderWrite]),
                new Call('et_theme_builder_store_template', 3, [DiviFeature::ThemeBuilderWrite]),
                new Call('et_theme_builder_get_flat_template_settings_options', 0),
                // clearSiteCaches() also runs after every page save with a site-wide scope (DiviSaver).
                new Call(
                    'et_theme_builder_clear_wp_cache',
                    1,
                    [DiviFeature::ThemeBuilderWrite, DiviFeature::PageWrite]
                ),
                // store_template needs Divi's et_() helper (E15).
                new Call('et_', null, [DiviFeature::ThemeBuilderWrite]),
                // Also called by DiviPages::clearPageCss() – a break locks both features.
                new Call(
                    'ET_Core_PageResource::remove_static_resources',
                    3,
                    [DiviFeature::ThemeBuilderWrite, DiviFeature::PageWrite]
                ),
            ],
            postTypes: self::postTypes(),
            keys: [
                self::TEMPLATE_LIST, self::USE_ON, self::EXCLUDE_FROM, self::ENABLED, self::UNUSED, self::BACKUP_OPTION,
            ],
            mirrors: [
                new Mirror(
                    'tb-api-save',
                    $dir . 'api.php',
                    'et_theme_builder_api_save',
                    null,
                    'Vorlagenliste des Live-Theme-Builders (_et_template mit Sicherung et_tb_templates_backup_) und '
                        . '„ungenutzt“-Markierung',
                    [DiviFeature::ThemeBuilderWrite]
                ),
                new Mirror(
                    'tb-store-template',
                    $dir . 'theme-builder.php',
                    'et_theme_builder_store_template',
                    null,
                    'Bedingungen (_et_use_on, _et_exclude_from) und Aktiv-Schalter (_et_enabled) einer Vorlage',
                    [DiviFeature::ThemeBuilderWrite]
                ),
                new Mirror(
                    'tb-load-setting-options',
                    $dir . 'theme-builder.php',
                    'et_theme_builder_load_template_setting_options',
                    null,
                    'Zerlegen der Bedingungs-IDs (TemplateConditions)',
                    [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite]
                ),
            ],
            features: [DiviFeature::ThemeBuilderRead]
        );
    }

DesignSystemReader::manifest():

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

DesignSystemWriter::manifest() (Standard preset-write; get_data ist geteilt):

    public static function manifest(): Manifest
    {
        return new Manifest(
            Manifest::DESIGN,
            calls: [
                // Read by the preset tools as well as by the design system tool.
                new Call(GlobalPreset::class . '::get_data', 0, [DiviFeature::DesignRead, DiviFeature::PresetWrite]),
                new Call(GlobalPreset::class . '::prepare_data', 1),
                new Call(GlobalPreset::class . '::validate_preset_count', 3),
                new Call(GlobalPreset::class . '::get_deleted_preset_ids', 3),
                new Call(GlobalPreset::class . '::remove_preset_from_legacy_option', 2),
                new Call(GlobalPreset::class . '::save_data', 1),
                new Call(Conversion::class . '::get_preset_attrs_mapping', 1),
                new Call(Conversion::class . '::get_preset_attrs', 3),
                // groupsOf(): also used by the composer for group presets (insert/update/assign), so Catalog too.
                new Call(
                    GlobalPreset::class . '::get_group_preset_default_attr',
                    1,
                    [DiviFeature::Catalog, DiviFeature::PresetWrite]
                ),
                new Call(
                    ModuleRegistration::class . '::get_module_settings',
                    1,
                    [DiviFeature::Catalog, DiviFeature::PresetWrite]
                ),
                new Call(UserRole::class . '::can_current_user_use_visual_builder', 0),
            ],
            mirrors: [
                new Mirror(
                    'preset-sync',
                    'includes/builder-5/server/Packages/GlobalData/GlobalPresetController.php',
                    'sync',
                    'GlobalPresetController',
                    'Speicherablauf der Presets (prepare_data, validate_preset_count, Bereinigung der Divi-4-Option '
                        . 'beim Löschen, save_data)'
                ),
            ],
            features: [DiviFeature::PresetWrite]
        );
    }

DiviModules::manifest():

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

DynamicContentMap::manifest():

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

DiviBoundaryTest bleibt grün: DiviTouches meldet nur et_*/ET*-Namen, DiviFeature::… ist keiner, und die Divi-Namen im Manifest ändern sich nicht (names() liefert dieselbe Liste wie vorher).

  • Step 5: Gesamtlauf, Lint, Commit

Run: composer test – Expected: PASS (2624 + 13 neue Tests = 2637 gegenüber dem Stand vor diesem Task; die Zahl verschiebt sich um das, was Tasks 1–3 hinzugefügt haben). Run: composer lint – Expected: sauber.

git add src/Integrations/Divi/Release/DiviFeature.php src/Integrations/Divi/Release/Requirement.php \
  src/Integrations/Divi/Release/ConstantRequirement.php src/Integrations/Divi/Release/Call.php \
  src/Integrations/Divi/Release/Mirror.php src/Integrations/Divi/Release/Manifest.php \
  src/Integrations/Divi/Release/ManifestCheck.php src/Integrations/Divi/Pages/DiviPages.php \
  src/Integrations/Divi/Library/DiviLibraryStore.php src/Integrations/Divi/Library/DiviRoles.php \
  src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php src/Integrations/Divi/Design/DesignSystemReader.php \
  src/Integrations/Divi/Design/DesignSystemWriter.php src/Integrations/Divi/Schema/DiviModules.php \
  src/Integrations/Divi/Pages/DynamicContentMap.php \
  tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php \
  tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php \
  tests/Unit/Integrations/Divi/Release/ManifestTest.php tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php
git commit -m "feat: Divi-Fähigkeiten (DiviFeature, Requirement, ConstantRequirement) an Call, Mirror und Manifest; alle acht Adapter getaggt; Tests für Vollständigkeit und die gepinnte Zuordnung" \
  -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 5: SignatureFingerprint, Signaturen in fingerprints.json, --update-reference, echte Referenz für Divi 5.15.1

Files:

  • Create: src/Integrations/Divi/Release/SignatureFingerprint.php
  • Modify: src/Integrations/Divi/Release/DiviAdapters.php, src/Integrations/Divi/Release/DiviReference.php
  • Modify: src/Cli/DiviCheckCommand.php
  • Modify (erzeugt): src/Integrations/Divi/Release/fingerprints.json
  • Create: tests/Unit/Integrations/Divi/Release/SignatureFingerprintTest.php
  • Modify: tests/Unit/Integrations/Divi/Release/DiviReferenceTest.php, tests/Unit/Cli/DiviCheckCommandTest.php

Interfaces:

final class SignatureFingerprint {
    public static function of(Call $call): ?string;         // sha256 hex, null = Symbol fehlt
    public static function canonical(Call $call): ?string;  // Text hinter dem Hash, null = Symbol fehlt
}
// DiviAdapters::calls(): list<Call>                                  jedes Symbol einmal (erster Eintrag gewinnt)
// DiviReference::SIGNATURE_PREFIX = 'sig:'
// DiviReference::signatureKey(Call $call): string                    "sig:" . ltrim($call->symbol, '\')
// DiviReference::currentSignatures(?array $calls = null): array      "sig:<symbol>" => sha256, nach Schlüssel sortiert;
//                                                                    RuntimeException, wenn ein Symbol fehlt
// DiviReference::expected(): array<string,string>                    Mirror-IDs ∪ "sig:<symbol>" (Code unverändert)
// DiviCheckCommand::__construct(..., ?Closure $sourceDir = null, ?Closure $signatures = null)

Entscheidungen (mit Begründung).

  • Ohne Parameternamen. Geprüft: Kein Adapter ruft einen Divi-Aufruf mit benannten Argumenten auf (Regex-Suche über alle acht Adapter nach <name>: innerhalb der Divi-Aufrufe: keine Treffer; DynamicContentMap::divi() nutzt call_user_func mit positionalem Array). Eine Umbenennung eines Parameters kann einen Aufruf deshalb nicht brechen und darf keine Fähigkeit sperren. Ein Adapter, der künftig benannte Argumente nutzt, müsste die Namen in den Hash aufnehmen – dann canonical() erweitern und diesen Absatz ändern.
  • Default „vorhanden ja/nein“, nicht der Wert. Defaults sind Ausdrücke (array(), '', Konstanten wie ET_BUILDER_…); Reflection kann sie je nach Request nicht auswerten (ReflectionException bei noch nicht definierten Konstanten), und die Schreibweise ändert sich ohne Bedeutungswechsel. Relevant für die Aufruf-Passung ist, ob ein Parameter weggelassen werden darf. Restrisiko: Ein geänderter Default eines weggelassenen Parameters fällt hier nicht auf (siehe Offene Punkte 3).
  • Weitere Bestandteile: Funktion/Methode, bei Methoden Sichtbarkeit und static (eine Methode, die privat oder nicht statisch wird, bricht Klasse::methode(); method_exists() im bestehenden accepts() merkt das nicht), je Parameter Typ (nullable/Union über (string) ReflectionType), by-ref, variadic; Rückgabetyp. Kein Typ liest sich als mixed (ein nachträglich ergänztes mixed ändert nichts). Nicht im Hash: Datei, Zeilen, Rumpf, Name.
  • Nie eine Datei einbinden: Reflection arbeitet auf geladenen Symbolen; im Kontext WordPress ist Divi geladen (ManifestCheck::accepts() verlässt sich schon heute darauf). Die Klasse eines Methoden-Symbols wird – wie in accepts() – von Divis eigenem Autoloader geladen (method_exists()), niemals per include/require; ein Test scannt die Quelldatei nach include/require/eval.
  • Heutiger Ablauf von --update-reference (geprüft): (1) Bericht des Prüflaufs; (2) bricht ab, wenn ein anderer Schritt als MIRRORS rot ist; (3) bricht ab ohne tests/-Verzeichnis; (4) liest alle Mirror-Fingerabdrücke aus dem Divi-Theme-Verzeichnis (Dateien, Tokens); (5) schreibt zuerst die Referenzquellen, zuletzt fingerprints.json (die Datei entscheidet das Gate, ein früher Fehler lässt das Gate unverändert); (6) leert den Zustands-Transient. Neu: zwischen (4) und (5) berechnet er die Signaturen aus dem laufenden Prozess (DiviReference::currentSignatures()) und bricht bei einem fehlenden Symbol ab, bevor irgendetwas geschrieben wird; in (5) werden Mirror-Hashes und Signaturen zusammen sortiert geschrieben. Die Signaturen kommen also aus der laufenden WordPress/Divi-Instanz – deshalb gehört der Aufruf in DDEV (Step 6).

Divi 5.15.1 (PHP 8.4, DDEV, nur lesend mit Reflection ermittelt): alle 29 Symbole der Manifeste existieren; diese Texte entstehen (Spalte 3: so viele Argumente übergibt das Plugin / so viele Parameter hat das Symbol):

Symbol canonical() übergeben
ET_Core_PageResource::remove_static_resources public static (mixed, mixed=, mixed=, mixed=, mixed=, mixed=, mixed=): mixed 3 von 7
et_builder_get_public_post_types function (): mixed 0 von 0
ET\Builder\Packages\GlobalLayout\GlobalLayout::is_global_layout_template public static (?int): bool 1 von 1
et_pb_is_allowed function (mixed, mixed=): mixed 1 von 2
et_theme_builder_get_theme_builder_post_id function (mixed, mixed=): mixed 2 von 2
et_theme_builder_get_theme_builder_template_ids function (mixed, mixed=): mixed 2 von 2
et_theme_builder_get_template function (mixed): mixed 1 von 1
et_theme_builder_insert_layout function (mixed): mixed 1 von 1
et_theme_builder_store_template function (mixed, mixed, mixed): mixed 3 von 3
et_theme_builder_get_flat_template_settings_options function (): mixed 0 von 0
et_theme_builder_clear_wp_cache function (mixed=): mixed 1 von 1
et_ function (): mixed nur Existenz von 0
ET\Builder\Packages\GlobalData\GlobalData::get_global_colors public static (): array 0 von 0
ET\Builder\Packages\GlobalData\GlobalData::get_global_variables public static (): array 0 von 0
ET\Builder\Packages\GlobalData\GlobalPreset::get_data public static (): array 0 von 0
ET\Builder\Packages\GlobalData\GlobalPreset::prepare_data public static (array): array 1 von 1
ET\Builder\Packages\GlobalData\GlobalPreset::validate_preset_count public static (array, array, string): ?WP_Error 3 von 3
ET\Builder\Packages\GlobalData\GlobalPreset::get_deleted_preset_ids public static (array, array, string): array 3 von 3
ET\Builder\Packages\GlobalData\GlobalPreset::remove_preset_from_legacy_option public static (string, string): bool 2 von 2
ET\Builder\Packages\GlobalData\GlobalPreset::save_data public static (array, bool=): array 1 von 2
ET\Builder\Packages\Conversion\Conversion::get_preset_attrs_mapping public static (mixed): mixed 1 von 1
ET\Builder\Packages\Conversion\Conversion::get_preset_attrs public static (mixed, mixed, mixed): mixed 3 von 3
ET\Builder\Packages\GlobalData\GlobalPreset::get_group_preset_default_attr public static (mixed): array 1 von 1
ET\Builder\Packages\ModuleLibrary\ModuleRegistration::get_module_settings public static (mixed): ?WP_Block_Type 1 von 1
ET\Builder\Framework\UserRole\UserRole::can_current_user_use_visual_builder public static (): bool 0 von 0
ET\Builder\Packages\ModuleLibrary\ModuleRegistration::get_all_core_modules_metadata public static (): array 0 von 0
ET\Builder\Packages\ModuleLibrary\ModuleRegistration::get_default_attrs public static (string, string=, mixed=): array 2 von 3
et_builder_enabled_for_post_type function (mixed): mixed 1 von 1
ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions::get_options public static (mixed, string): array 2 von 2

Auffällig: 14 der 29 Symbole haben weder Parameter- noch Rückgabetypen (mixed überall), drei GlobalData/GlobalPreset-Getter haben dieselbe Signatur (public static (): array) und damit denselben Hash unter verschiedenen Schlüsseln. Der Signatur-Fingerabdruck sichert also Aufrufform und Rückgabetyp, nicht das Verhalten (siehe Offene Punkte 2).

  • Step 1: Test für SignatureFingerprint schreiben (RED)

tests/Unit/Integrations/Divi/Release/SignatureFingerprintTest.php (die Fixture-Klasse SignatureFixture steht in derselben Datei und bildet eine Divi-Klasse nach: eine Basismethode und je eine Variante pro Eigenschaft, die der Hash abdeckt; PHP-eigene Funktionen/Klassen wie strlen, ArrayObject::count ergänzen es wie in ManifestCheckTest):

<?php

declare(strict_types=1);

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

use ArrayObject;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\SignatureFingerprint;
use PHPUnit\Framework\TestCase;

/** Stand-in for a Divi class: one baseline method and one variant per property the fingerprint covers. */
final class SignatureFixture
{
    public static function base(int $id, string|array $data = [], &$ref = null, ...$rest): ?bool
    {
        return null;
    }

    /** Same signature, other parameter names and other default values: no change. */
    public static function renamed(int $post, string|array $payload = '', &$out = 5, ...$more): ?bool
    {
        return null;
    }

    public static function extraRequired(int $id, string|array $data, &$ref = null, ...$rest): ?bool
    {
        return null;
    }

    public static function fewer(int $id, string|array $data = []): ?bool
    {
        return null;
    }

    public static function typeChanged(string $id, string|array $data = [], &$ref = null, ...$rest): ?bool
    {
        return null;
    }

    public static function unionWidened(int $id, string|array|int $data = [], &$ref = null, ...$rest): ?bool
    {
        return null;
    }

    public static function notNullable(int $id, string|array $data = [], &$ref = null, ...$rest): bool
    {
        return true;
    }

    public static function byValue(int $id, string|array $data = [], $ref = null, ...$rest): ?bool
    {
        return null;
    }

    public static function notVariadic(int $id, string|array $data = [], &$ref = null, $rest = null): ?bool
    {
        return null;
    }

    public static function defaultRemoved(int $id, string|array $data, &$ref, ...$rest): ?bool
    {
        return null;
    }

    public static function returnChanged(int $id, string|array $data = [], &$ref = null, ...$rest): ?int
    {
        return null;
    }

    public function notStatic(int $id, string|array $data = [], &$ref = null, ...$rest): ?bool
    {
        return null;
    }

    protected static function notPublic(int $id, string|array $data = [], &$ref = null, ...$rest): ?bool
    {
        return null;
    }

    public static function untyped($id, $data = [])
    {
        return null;
    }

    public static function mixedTyped(mixed $id, mixed $data = []): mixed
    {
        return null;
    }
}

final class SignatureFingerprintTest extends TestCase
{
    private const FIXTURE = SignatureFixture::class;

    private static function method(string $name): Call
    {
        return new Call(self::FIXTURE . '::' . $name);
    }

    public function testIsASha256OfAnExistingFunctionAndStable(): void
    {
        $hash = SignatureFingerprint::of(new Call('strlen', 1));

        self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', (string) $hash);
        self::assertSame($hash, SignatureFingerprint::of(new Call('strlen', 1)));
        self::assertNotSame($hash, SignatureFingerprint::of(new Call('str_replace', 3)));
    }

    public function testMissingSymbolsGiveNull(): void
    {
        self::assertNull(SignatureFingerprint::of(new Call('lit_mcp_missing_function')));
        self::assertNull(SignatureFingerprint::of(new Call('Missing\Klass::run')));
        self::assertNull(SignatureFingerprint::of(new Call(ArrayObject::class . '::missing')));
        self::assertNull(SignatureFingerprint::canonical(new Call('lit_mcp_missing_function')));
    }

    public function testCanonicalTextDescribesTheSignature(): void
    {
        self::assertSame(
            'public static (int, array|string=, &mixed=, ...mixed): ?bool',
            SignatureFingerprint::canonical(self::method('base'))
        );
        self::assertSame('function (string): int', SignatureFingerprint::canonical(new Call('strlen')));
        self::assertSame(
            'public (int, array|string=, &mixed=, ...mixed): ?bool',
            SignatureFingerprint::canonical(self::method('notStatic'))
        );
    }

    /** @return array<string, array{string}> */
    public static function changedSignatures(): array
    {
        return [
            'extra required parameter' => ['extraRequired'],
            'fewer parameters' => ['fewer'],
            'parameter type' => ['typeChanged'],
            'wider union' => ['unionWidened'],
            'return type loses null' => ['notNullable'],
            'by reference lost' => ['byValue'],
            'variadic lost' => ['notVariadic'],
            'default removed' => ['defaultRemoved'],
            'return type' => ['returnChanged'],
            'no longer static' => ['notStatic'],
            'no longer public' => ['notPublic'],
        ];
    }

    /** @dataProvider changedSignatures */
    public function testAnyChangeOfTheInterfaceChangesTheHash(string $variant): void
    {
        self::assertNotSame(
            SignatureFingerprint::of(self::method('base')),
            SignatureFingerprint::of(self::method($variant)),
            $variant
        );
    }

    public function testRenamedParametersAndOtherDefaultValuesKeepTheHash(): void
    {
        self::assertSame(
            SignatureFingerprint::of(self::method('base')),
            SignatureFingerprint::of(self::method('renamed'))
        );
    }

    public function testNoTypeReadsAsMixed(): void
    {
        self::assertSame(
            SignatureFingerprint::of(self::method('untyped')),
            SignatureFingerprint::of(self::method('mixedTyped'))
        );
    }

    public function testTheCallsOwnArgumentCountAndFeaturesAreNotPartOfIt(): void
    {
        self::assertSame(
            SignatureFingerprint::of(new Call(self::FIXTURE . '::base', 2)),
            SignatureFingerprint::of(new Call('\\' . self::FIXTURE . '::base', null, []))
        );
    }

    public function testAFunctionAndAMethodOfTheSameShapeDiffer(): void
    {
        self::assertNotSame(
            SignatureFingerprint::of(new Call('strlen')),
            SignatureFingerprint::of(new Call(ArrayObject::class . '::count'))
        );
    }

    public function testTheClassNeverIncludesAFile(): void
    {
        $source = (string) file_get_contents(
            dirname(__DIR__, 5) . '/src/Integrations/Divi/Release/SignatureFingerprint.php'
        );
        $forbidden = [T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE, T_EVAL];

        foreach (token_get_all($source) as $token) {
            self::assertFalse(is_array($token) && in_array($token[0], $forbidden, true), 'no include, require or eval');
        }
    }
}

Run: composer test -- --filter SignatureFingerprintTest Expected: FAIL – Error: Class "LIT\MCP\Integrations\Divi\Release\SignatureFingerprint" not found.

  • Step 2: SignatureFingerprint implementieren (GREEN)

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

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use ReflectionException;
use ReflectionFunction;
use ReflectionFunctionAbstract;
use ReflectionMethod;
use ReflectionParameter;
use ReflectionType;

defined('ABSPATH') || exit;

/**
 * Fingerprint of the signature of one Divi function or static method (spec §4.2, E1 of the plan "Divi-Fähigkeiten"),
 * read with Reflection from the running PHP process – so it needs the symbol to be loaded (in WordPress Divi is) and
 * never includes a file. The class of a method is autoloaded the way ManifestCheck::accepts() already does it.
 *
 * What the hash covers: function or method; for a method its visibility and whether it is static; per parameter the
 * type (nullable and union included; no type reads as "mixed"), by reference, variadic and whether it is optional
 * (has a default); the return type (none reads as "mixed"). What it leaves out:
 * - Parameter names: the plugin passes every argument to Divi positionally (PHP 8 named arguments are never used),
 *   so a rename cannot break a call, and a harmless rename must not lock a feature.
 * - Default values: they are expressions (constants, arrays) that Reflection may be unable to evaluate in some
 *   requests and that change spelling without changing meaning; only "has a default" matters for the number of
 *   arguments the plugin may omit.
 * - The body: a changed body is no change of the interface (mirrors cover the code the plugin repeats).
 */
final class SignatureFingerprint
{
    /** sha256 of canonical(), null when the symbol does not exist or cannot be reflected. */
    public static function of(Call $call): ?string
    {
        $canonical = self::canonical($call);

        return $canonical === null ? null : hash('sha256', $canonical);
    }

    /**
     * The text the hash is built from, e.g. "public static (int, array|string=, &mixed=, ...mixed): ?bool".
     * Public for tests and diagnostics; null when the symbol does not exist.
     */
    public static function canonical(Call $call): ?string
    {
        try {
            $reflection = self::reflect($call);
        } catch (ReflectionException) {
            return null;
        }
        if ($reflection === null) {
            return null;
        }

        return sprintf(
            '%s (%s): %s',
            self::kind($reflection),
            implode(', ', array_map(self::parameter(...), $reflection->getParameters())),
            self::type($reflection->getReturnType())
        );
    }

    /** "function", or a method's visibility and "static" – a method that turns private or non-static breaks a call. */
    private static function kind(ReflectionFunctionAbstract $reflection): string
    {
        if (! $reflection instanceof ReflectionMethod) {
            return 'function';
        }

        $visibility = $reflection->isPublic() ? 'public' : ($reflection->isProtected() ? 'protected' : 'private');

        return $reflection->isStatic() ? $visibility . ' static' : $visibility;
    }

    /** @throws ReflectionException */
    private static function reflect(Call $call): ?ReflectionFunctionAbstract
    {
        $class = $call->className();
        if ($class === null) {
            return function_exists($call->member()) ? new ReflectionFunction($call->member()) : null;
        }

        return method_exists($class, $call->member()) ? new ReflectionMethod($class, $call->member()) : null;
    }

    private static function parameter(ReflectionParameter $parameter): string
    {
        return ($parameter->isPassedByReference() ? '&' : '')
            . ($parameter->isVariadic() ? '...' : '')
            . self::type($parameter->getType())
            . ($parameter->isOptional() && ! $parameter->isVariadic() ? '=' : '');
    }

    private static function type(?ReflectionType $type): string
    {
        return $type === null ? 'mixed' : (string) $type;
    }
}

Run: composer test -- --filter SignatureFingerprintTest Expected: PASS (19 Tests).

  • Step 3: Tests für DiviReference und DiviCheckCommand anpassen (RED)

tests/Unit/Integrations/Divi/Release/DiviReferenceTest.php (vollständig, neu). Der Schlüsselvergleich ist jetzt Mirror-IDs ∪ sig:-Schlüssel aller Calls, unabhängig von DiviAdapters::calls() aus den Manifesten gebildet:

<?php

declare(strict_types=1);

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

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

final class DiviReferenceTest extends TestCase
{
    public function testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource(): void
    {
        $mirrorIds = array_map(static fn ($mirror): string => $mirror->id, DiviAdapters::mirrors());
        $signatureKeys = [];
        foreach (DiviAdapters::manifests() as $manifest) {
            foreach ($manifest->calls as $call) {
                $signatureKeys[] = 'sig:' . ltrim($call->symbol, '\\');
            }
        }
        $keys = array_values(array_unique(array_merge($mirrorIds, $signatureKeys)));
        sort($keys);
        $expected = DiviReference::expected();

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

    public function testEverySignatureKeyStartsWithThePrefixNoMirrorIdUses(): void
    {
        foreach (DiviAdapters::mirrors() as $mirror) {
            self::assertStringStartsNotWith(DiviReference::SIGNATURE_PREFIX, $mirror->id);
        }
        self::assertSame('sig:et_pb_is_allowed', DiviReference::signatureKey(new Call('et_pb_is_allowed', 1)));
        self::assertSame(
            'sig:ET_Core_PageResource::remove_static_resources',
            DiviReference::signatureKey(new Call('\\ET_Core_PageResource::remove_static_resources', 3))
        );
    }

    public function testCallsListsEverySymbolOnceAcrossTheManifests(): void
    {
        $symbols = array_map(static fn (Call $call): string => ltrim($call->symbol, '\\'), DiviAdapters::calls());

        self::assertSame($symbols, array_values(array_unique($symbols)));
        self::assertContains('ET_Core_PageResource::remove_static_resources', $symbols);
        self::assertCount(1, array_keys($symbols, 'ET_Core_PageResource::remove_static_resources', true));
        self::assertContains('et_pb_is_allowed', $symbols);
    }

    public function testCurrentSignaturesAreSortedAndKeyedLikeTheReference(): void
    {
        $signatures = DiviReference::currentSignatures([
            new Call('strlen', 1),
            new Call('\\str_replace', 3),
            new Call('strlen', 1),
        ]);

        self::assertSame(['sig:str_replace', 'sig:strlen'], array_keys($signatures));
        self::assertSame(SignatureFingerprint::of(new Call('strlen')), $signatures['sig:strlen']);
    }

    public function testCurrentSignaturesRefuseASymbolThatIsNotLoaded(): void
    {
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('signature of lit_mcp_missing_function(): symbol is not loaded');

        DiviReference::currentSignatures([new Call('strlen', 1), new Call('lit_mcp_missing_function', 0)]);
    }

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

        try {
            DiviReference::writeExpected([
                'sig:Z\\Klass::run' => str_repeat('c', 64),
                'tb-api-save' => str_repeat('b', 64),
                'sig:a_function' => str_repeat('a', 64),
            ], $file);

            self::assertSame(
                ['sig:Z\\Klass::run', 'sig:a_function', 'tb-api-save'],
                array_keys(DiviReference::expected($file))
            );
        } finally {
            unlink($file);
        }
    }

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

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

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

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

tests/Unit/Cli/DiviCheckCommandTest.php – die Signaturen kommen über die neue Naht aus dem Test (in der PHPUnit-Umgebung ist Divi nicht geladen):

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
@@ -13,6 +13,7 @@ use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
 use LIT\MCP\Integrations\Divi\Release\DiviReference;
 use LIT\MCP\Tests\Unit\TestCase;
 use Mockery;
+use RuntimeException;
 
 final class DiviCheckCommandTest extends TestCase
 {
@@ -23,10 +24,18 @@ final class DiviCheckCommandTest extends TestCase
     private string $sourceDir = '';
     private string $expectedFile = '';
 
+    /** @var array<string, string> What the command reads as the signatures of the running Divi. */
+    private array $signatures = [];
+
+    /** Set to true to make the signature read fail like a Divi call that is not loaded. */
+    private bool $signaturesUnreadable = false;
+
     protected function setUp(): void
     {
         parent::setUp();
         $this->lines = [];
+        $this->signatures = ['sig:et_pb_is_allowed' => str_repeat('a', 64), 'sig:A::b' => str_repeat('b', 64)];
+        $this->signaturesUnreadable = false;
         $base = sys_get_temp_dir() . '/lit-cli-' . uniqid();
         $this->themeDir = $base . '/Divi';
         $this->sourceDir = $base . '/reference';
@@ -75,7 +84,14 @@ final class DiviCheckCommandTest extends TestCase
             },
             fn (): ?string => $this->themeDir,
             $this->expectedFile,
-            $noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir
+            $noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir,
+            function (): array {
+                if ($this->signaturesUnreadable) {
+                    throw new RuntimeException('signature of et_pb_is_allowed(): symbol is not loaded');
+                }
+
+                return $this->signatures;
+            }
         );
     }
 
@@ -176,7 +192,24 @@ final class DiviCheckCommandTest extends TestCase
         }
     }
 
-    public function testUpdateReferenceWritesFingerprintsAndSources(): void
+    public function testUpdateReferenceWritesNothingWhenASignatureIsUnreadable(): void
+    {
+        $this->writeDivi();
+        $this->signaturesUnreadable = true;
+
+        $code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]);
+
+        self::assertSame(1, $code);
+        self::assertStringContainsString(
+            'Referenz nicht aktualisiert – die Signatur eines Divi-Aufrufs ist nicht lesbar: '
+                . 'signature of et_pb_is_allowed(): symbol is not loaded',
+            implode("\n", $this->lines)
+        );
+        self::assertFileDoesNotExist($this->expectedFile, 'all or nothing');
+        self::assertSame([], glob($this->sourceDir . '/*.txt'), 'no source written either');
+    }
+
+    public function testUpdateReferenceWritesFingerprintsSignaturesAndSources(): void
     {
         $this->writeDivi();
 
@@ -185,11 +218,16 @@ final class DiviCheckCommandTest extends TestCase
         self::assertSame(0, $code);
         self::assertSame(
             [
-                'library-submit-layout', 'preset-sync', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template',
-                'vb-sync-flags',
+                'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed', 'tb-api-save',
+                'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags',
             ],
             array_keys(DiviReference::expected($this->expectedFile))
         );
+        self::assertSame(str_repeat('b', 64), DiviReference::expected($this->expectedFile)['sig:A::b']);
+        self::assertContains(
+            'Referenz aktualisiert (6 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.',
+            $this->lines
+        );
         self::assertStringStartsWith(
             'function et_pb_submit_layout(',
             (string) DiviReference::readSource('library-submit-layout', $this->sourceDir)

Run: composer test -- --filter 'DiviReferenceTest|DiviCheckCommandTest' Expected: FAIL – Error: Call to undefined method LIT\MCP\Integrations\Divi\Release\DiviReference::signatureKey() (bzw. DiviAdapters::calls()); DiviCheckCommandTest::testUpdateReferenceWritesFingerprintsSignaturesAndSources meldet fehlende sig:-Schlüssel, …WritesNothingWhenASignatureIsUnreadable Failed asserting that 0 is identical to 1.

  • Step 4: DiviAdapters, DiviReference, DiviCheckCommand ändern (GREEN bis auf die echte Referenz)
diff --git a/src/Cli/DiviCheckCommand.php b/src/Cli/DiviCheckCommand.php
--- a/src/Cli/DiviCheckCommand.php
+++ b/src/Cli/DiviCheckCommand.php
@@ -35,6 +35,8 @@ final class DiviCheckCommand
      * @param (Closure(): ?string)|null     $themeDir  null = DiviEnvironment::themeDir().
      * @param string|null                   $expectedFile null = DiviReference::FILE.
      * @param (Closure(): ?string)|null     $sourceDir null = DiviReference::sourceDir().
+     * @param (Closure(): array<string, string>)|null $signatures "sig:<symbol>" => sha256 of every Divi call in the
+     *                                                running process; null = DiviReference::currentSignatures().
      */
     public function __construct(
         private Closure $check,
@@ -42,7 +44,8 @@ final class DiviCheckCommand
         ?Closure $out = null,
         private ?Closure $themeDir = null,
         private ?string $expectedFile = null,
-        private ?Closure $sourceDir = null
+        private ?Closure $sourceDir = null,
+        private ?Closure $signatures = null
     ) {
         $this->out = $out ?? static function (string $line): void {
             \WP_CLI::line($line);
@@ -67,7 +70,8 @@ final class DiviCheckCommand
      * : Zeigt für jede geänderte nachgebaute Divi-Funktion den Unterschied zur Referenz.
      *
      * [--update-reference]
-     * : Schreibt Soll-Fingerabdrücke und Referenzquellen neu – nur wenn alle übrigen Schritte grün sind.
+     * : Schreibt die Soll-Fingerabdrücke (Nachbauten und Signaturen der Divi-Aufrufe) und die Referenzquellen neu –
+     *   nur wenn alle übrigen Schritte grün sind.
      *
      * ## EXAMPLES
      *
@@ -179,6 +183,16 @@ final class DiviCheckCommand
             return 1;
         }
 
+        try {
+            $signatures = ($this->signatures ?? DiviReference::currentSignatures(...))();
+        } catch (RuntimeException $e) {
+            ($this->out)(
+                'Referenz nicht aktualisiert – die Signatur eines Divi-Aufrufs ist nicht lesbar: ' . $e->getMessage()
+            );
+
+            return 1;
+        }
+
         try {
             // Sources first, fingerprints.json last: the fingerprints decide the release gate, so a write that
             // fails before this last one leaves the gate exactly as it was.
@@ -186,7 +200,7 @@ final class DiviCheckCommand
                 DiviReference::writeSource($id, $print->source, $sourceDir);
             }
             DiviReference::writeExpected(
-                array_map(static fn (SourceFingerprint $print): string => $print->hash, $prints),
+                array_map(static fn (SourceFingerprint $print): string => $print->hash, $prints) + $signatures,
                 $this->expectedFile
             );
         } catch (RuntimeException $e) {
@@ -197,8 +211,9 @@ final class DiviCheckCommand
         delete_transient(ReleaseResolver::CACHE);
         ReleaseGate::forget();
         ($this->out)(sprintf(
-            'Referenz aktualisiert (%d Fingerabdrücke). Smoke-Skripte laufen lassen, dann committen.',
-            count($prints)
+            'Referenz aktualisiert (%d Fingerabdrücke, %d Signaturen). Smoke-Skripte laufen lassen, dann committen.',
+            count($prints),
+            count($signatures)
         ));
 
         return 0;
diff --git a/src/Integrations/Divi/Release/DiviAdapters.php b/src/Integrations/Divi/Release/DiviAdapters.php
--- a/src/Integrations/Divi/Release/DiviAdapters.php
+++ b/src/Integrations/Divi/Release/DiviAdapters.php
@@ -42,6 +42,19 @@ final class DiviAdapters
         return $manifests;
     }
 
+    /** @return list<Call> Every Divi call of every manifest, each symbol once (the first entry wins). */
+    public static function calls(): array
+    {
+        $calls = [];
+        foreach (self::manifests() as $manifest) {
+            foreach ($manifest->calls as $call) {
+                $calls[ltrim($call->symbol, '\\')] ??= $call;
+            }
+        }
+
+        return array_values($calls);
+    }
+
     /** @return list<Mirror> */
     public static function mirrors(): array
     {
diff --git a/src/Integrations/Divi/Release/DiviReference.php b/src/Integrations/Divi/Release/DiviReference.php
--- a/src/Integrations/Divi/Release/DiviReference.php
+++ b/src/Integrations/Divi/Release/DiviReference.php
@@ -9,15 +9,22 @@ use RuntimeException;
 defined('ABSPATH') || exit;
 
 /**
- * The expected fingerprints of the mirrors (fingerprints.json, shipped with the plugin) and their reference sources
- * (tests/Fixtures/divi/reference/<id>.txt, development only – not in the plugin package). Written by
- * wp lit-mcp divi-check --update-reference.
+ * The expected fingerprints (fingerprints.json, shipped with the plugin): the source fingerprint of every mirror under
+ * its ID, and the signature fingerprint of every Divi call under "sig:<symbol>" (SignatureFingerprint) – plus the
+ * reference sources of the mirrors (tests/Fixtures/divi/reference/<id>.txt, development only – not in the plugin
+ * package). Written by wp lit-mcp divi-check --update-reference.
  */
 final class DiviReference
 {
     public const FILE = __DIR__ . '/fingerprints.json';
 
-    /** @return array<string, string> Mirror ID => sha256; [] when the file is missing or broken. */
+    /** Key prefix of the signature fingerprints; no mirror ID starts with it. */
+    public const SIGNATURE_PREFIX = 'sig:';
+
+    /**
+     * @return array<string, string> Mirror ID or "sig:<symbol>" => sha256, sorted by key; [] when the file is
+     *         missing or broken.
+     */
     public static function expected(?string $file = null): array
     {
         $file ??= self::FILE;
@@ -40,7 +47,7 @@ final class DiviReference
     }
 
     /**
-     * @param array<string, string> $hashes Mirror ID => sha256.
+     * @param array<string, string> $hashes Mirror ID or "sig:<symbol>" => sha256; written sorted by key.
      * @throws RuntimeException
      */
     public static function writeExpected(array $hashes, ?string $file = null): void
@@ -54,6 +61,34 @@ final class DiviReference
         }
     }
 
+    /** The key of $call's signature fingerprint: "sig:" and the symbol without a leading backslash. */
+    public static function signatureKey(Call $call): string
+    {
+        return self::SIGNATURE_PREFIX . ltrim($call->symbol, '\\');
+    }
+
+    /**
+     * Signature fingerprints of the calls in the running PHP process (Divi loaded).
+     *
+     * @param list<Call>|null $calls null = DiviAdapters::calls().
+     * @return array<string, string> "sig:<symbol>" => sha256, sorted by key.
+     * @throws RuntimeException When a symbol is not loaded.
+     */
+    public static function currentSignatures(?array $calls = null): array
+    {
+        $signatures = [];
+        foreach ($calls ?? DiviAdapters::calls() as $call) {
+            $hash = SignatureFingerprint::of($call);
+            if ($hash === null) {
+                throw new RuntimeException(sprintf('signature of %s: symbol is not loaded', $call->label()));
+            }
+            $signatures[self::signatureKey($call)] = $hash;
+        }
+        ksort($signatures);
+
+        return $signatures;
+    }
+
     /** tests/Fixtures/divi/reference of a development checkout; null in an installed plugin. */
     public static function sourceDir(): ?string
     {

Run: composer test -- --filter 'SignatureFingerprintTest|DiviCheckCommandTest|DiviReferenceTest' Expected: alles PASS außer DiviReferenceTest::testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource: Failed asserting that two arrays are identical. – die 29 sig:-Schlüssel fehlen noch in fingerprints.json. Das ist gewollt: Step 6 erzeugt sie.

  • Step 5: Lint

Run: composer lint – Expected: sauber.

  • Step 6: Echte Referenz für Divi 5.15.1 in DDEV erzeugen

Hinweis an den Ausführenden: Die Plugins postlos und asynclistry erzeugen unter WP-CLI Deprecation-Ausgaben, die vor der Befehlsausgabe stehen (die Smokes lassen sie aus, z. B. tests/Integration/divi-presets-assign-smoke.sh). --skip-plugins=postlos,asynclistry erreicht das, ohne sie zu deaktivieren. Voraussetzungen: DDEV läuft (cd /home/janniclas/Projekte/WordPress && ddev describe), das aktive Theme ist Divi 5.15.1 (ddev exec wp --skip-plugins=postlos,asynclistry eval 'echo LIT\MCP\Integrations\Divi\Release\DiviEnvironment::version();' → 5.15.1), der Arbeitsbaum des Plugins enthält den Code aus Steps 1–4 (DDEV bindet das Plugin-Verzeichnis ein).

cd /home/janniclas/Projekte/WordPress
ddev exec wp --skip-plugins=postlos,asynclistry user list --role=administrator --field=user_login   # hier: janniclas
ddev exec wp --skip-plugins=postlos,asynclistry --user=janniclas lit-mcp divi-check --update-reference

Der Aufruf legt für den Schreibtest eine Wegwerfseite an und löscht sie wieder (wie der Admin-Reiter). Expected: Fünf grüne Schritte (✓ …), zuletzt Referenz aktualisiert (6 Fingerabdrücke, 29 Signaturen). Smoke-Skripte laufen lassen, dann committen., Exit 0. Ist ein Schritt außer „Nachbauten“ rot, schreibt der Befehl nichts (Referenz nicht aktualisiert: …): erst die Ursache beheben.

Prüfen, dass nur die erwartete Datei sich ändert und die Werte zur Tabelle oben passen:

cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git status --short                                    # nur src/Integrations/Divi/Release/fingerprints.json geändert
git diff --stat -- src/Integrations/Divi/Release/fingerprints.json   # 29 insertions(+)
grep -c '"sig:' src/Integrations/Divi/Release/fingerprints.json       # 29
printf '%s' 'public static (): array' | sha256sum    # muss dem Wert von sig:…GlobalPreset::get_data entsprechen
grep -A0 'GlobalPreset::get_data' src/Integrations/Divi/Release/fingerprints.json

Erwartet: dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae für get_data, get_global_colors und get_global_variables (identische Signatur). Die sechs Dateien unter tests/Fixtures/divi/reference/ bleiben unverändert (die Mirror-Quellen haben sich nicht geändert). Die Datei, die der Plan-Autor aus derselben Reflection-Ausgabe errechnet hat (Hashes = sha256 der Texte in der Tabelle), sieht so aus (Auszug; maßgeblich ist die vom Befehl geschriebene Datei):

{
    "library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02",
    "preset-sync": "9586f8005b8065893c0350977a58ee9171bf715fff21ac0c84663cc512f45522",
    "sig:ET\\Builder\\Framework\\UserRole\\UserRole::can_current_user_use_visual_builder": "76d199c13adc6600c8a456225a8eb9db8037064f20eca9db597c4e45ccecf8d5",
    "sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::get_data": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae",
    "…": "… (insgesamt 6 Mirror-Schlüssel und 29 sig:-Schlüssel, nach Schlüssel sortiert)"
}

Ohne diesen Schritt hätte jede Fähigkeit nach Task 6 bei einer neuen Divi-Version „reference-missing“ (E1).

  • Step 7: Gesamtlauf, Lint, Commit

Run: composer test – Expected: PASS (jetzt auch der echte Referenztest; +25 Tests gegenüber dem Stand nach Task 4: 19 SignatureFingerprintTest, 5 DiviReferenceTest, 1 DiviCheckCommandTest). Run: composer lint – Expected: sauber.

git add src/Integrations/Divi/Release/SignatureFingerprint.php src/Integrations/Divi/Release/DiviAdapters.php \
  src/Integrations/Divi/Release/DiviReference.php src/Integrations/Divi/Release/fingerprints.json \
  src/Cli/DiviCheckCommand.php tests/Unit/Integrations/Divi/Release/SignatureFingerprintTest.php \
  tests/Unit/Integrations/Divi/Release/DiviReferenceTest.php tests/Unit/Cli/DiviCheckCommandTest.php
git commit -m "feat: Signatur-Fingerabdruck der Divi-Aufrufe (Reflection); fingerprints.json enthält sig:-Einträge, divi-check --update-reference schreibt sie; Referenz für Divi 5.15.1" \
  -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Offene Punkte

  1. Lesende Hilfsaufrufe des DesignSystemWriter tragen nur preset-write (Attribut-Mapping, get_preset_attrs, get_group_preset_default_attr, get_module_settings, Rollenrecht). Der Auftrag nannte „lesende Calls DesignRead“; das hätte lit-divi-design-system bei jeder Änderung von ModuleRegistration::get_module_settings gesperrt, obwohl das Tool sie nicht aufruft. Folge für Task 8: lit/divi-preset-get (Lese-Tool, nutzt groupsOf()) muss PresetWrite zusätzlich zu DesignRead erklären; ebenso jedes Tool, das groupsOf/presetAttrs über DiviComposition oder PresetAssignment nutzt. Wer das anders will, ergänzt DiviFeature::DesignRead an diesen vier Calls und passt die gepinnte Tabelle an.
  2. Der Signatur-Fingerabdruck ist schwach, wo Divi nichts typisiert. 14 von 29 Symbolen (u. a. alle et_*-Funktionen des Theme Builders, et_pb_is_allowed, Conversion::*) haben weder Parameter- noch Rückgabetypen; dort deckt der Hash nur Parameterzahl, Optionalität, by-ref/variadic und Statik ab. Ein geänderter Rumpf oder Rückgabewert fällt nicht auf. Nur die Mirrors (Quell-Fingerabdruck) sichern Verhalten. Das ist eine Grenze von E1, keine Lücke der Umsetzung; der Text im Admin-Tab/Guide (Tasks 10–11) sollte „automatisch freigegeben“ nicht als „Verhalten geprüft“ formulieren.
  3. Geänderte Default-Werte weggelassener Parameter bleiben unbemerkt (z. B. GlobalPreset::save_data(array, bool=), ModuleRegistration::get_default_attrs 2 von 3, remove_static_resources 3 von 7, et_pb_is_allowed 1 von 2). Mögliche Verschärfung ohne Namen: für Parameter jenseits Call::$args den Default-Wert in den Hash aufnehmen, wenn Reflection ihn als Literal liefert (isDefaultValueConstant() false). Bewusst nicht umgesetzt (Auftrag und Begründung oben); Entscheidung beim Plan-Eigner.
  4. Neuer optionaler Parameter am Ende ändert den Hash und sperrt die Fähigkeit (incompatible), obwohl der Aufruf weiter passt. Konservativ gewollt (E1: „stimmt mit der Referenz überein“). Verfeinerung möglich: nur die ersten Call::$args Parameter plus alle danach folgenden Pflichtparameter hashen (die Tests fewer und extraRequired wären dann anzupassen).
  5. Die Referenz ist an Divi 5.15.1 gebunden (wie die Mirror-Fingerabdrücke). (string) ReflectionType ist über PHP 8.1–8.5 stabil für die vorkommenden Typen (?int, array, bool, ?WP_Error, ?WP_Block_Type); eine künftige PHP-Version, die Typen anders schreibt, würde unter der Auto-Freigabe als incompatible enden (fail closed) – dann Referenz neu erzeugen. Berechnet wurde in DDEV unter PHP 8.4.
  6. et_builder_enabled_for_post_type() hat keinen Aufrufer (DiviModules::builderEnabledFor() wird nirgends benutzt). Der Eintrag bleibt (Manifest und DiviBoundaryTest verlangen Übereinstimmung mit dem Quelltext) und ist catalog; ein Aufräumen wäre ein eigener, nicht verhaltensneutraler Schritt.
  7. Geteilte Aufrufe und Tool-Ebene (Task 8): et_theme_builder_get_flat_template_settings_options und TemplateConditions laufen auch in Schreib-Tools (Validierung); isGlobal()/library()->isGlobal() wird von PageRewriter (Preset-Tools) genutzt; Compose-Tools nutzen catalog, dynamic-sources, roles und design-read. Die Adapter-Ebene taggt, wer den Aufruf tätigt; welche Fähigkeiten ein Tool insgesamt braucht, legt die Tabelle in Task 8 fest (Vereinigung über alle Adapter-Aufrufe des Tools). DiviSaver prüft nach D5 immer PageWrite und zusätzlich den Bereich; deshalb genügt page-write an den Cache-Aufrufen.
  8. Die gepinnte Tabelle (DiviFeatureAssignmentTest) muss bei jedem neuen Manifest-Eintrag (Etappen 4c–4e) mitwachsen – gewollt: jeder neue Divi-Aufruf braucht eine bewusste Fähigkeit.
  9. tests/Integration/divi-release-smoke.sh nennt im Kopfkommentar noch „Divi 5.2.1“ (veraltet); Anpassung gehört zu Task 11.

Verifiziert

  • Beide Tasks in einer Kopie des Repos (cp -a inklusive vendor, Stand e7793fd) gebaut und getestet; Kopie danach gelöscht. composer test: Ausgangslage 2624 Tests grün; nach Task 4 2637; nach Task 5 2662 Tests, 14571 Assertions, OK; composer lint sauber (die phpcs:disable-Zeile in DiviFeature.php ist nötig, sonst PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext). DiviBoundaryTest grün.
  • RED-Meldungen echt beobachtet: DiviFeatureAssignmentTest ohne getaggte Adapter (Anforderungen ohne (eindeutige) Fähigkeit: + Liste), DiviModulesTest vor Anpassung (Objekt statt String), DiviReferenceTest ohne sig:-Schlüssel (Failed asserting that two arrays are identical.).
  • Read-only gegen die laufende DDEV-Instanz (Divi 5.15.1, PHP 8.4, wp --skip-plugins=postlos,asynclistry eval-file, nur Reflection, nichts geschrieben): alle 29 Manifest-Symbole existieren; die Texte der Signatur-Tabelle stammen von dort; Hashes = sha256 dieser Texte (Gegenprobe printf … | sha256sum für public static (): array). Mit diesen Hashes in fingerprints.json der Kopie ist DiviReferenceTest grün.
  • Nicht ausgeführt: der Befehl divi-check --update-reference selbst in DDEV (er schreibt in den echten Arbeitsbaum und die Datenbank; Vorgabe: Repo unverändert lassen). Erwartete Ausgabe und Prüfschritte stehen in Step 6.
  • Divi-Aufrufe der Adapter nutzen keine benannten Argumente (Regex über alle acht Adapter, keine Treffer).

Signatur-Änderungen und -Ergänzungen (Tasks 6–7) – bitte in „Verbindliche Signaturen“ übernehmen

Alle Gerüst-Signaturen von Task 6/7 bleiben wie angegeben. Dazu kommen (und zwei Abweichungen, fett):

  1. ManifestCheck: fehlende Mirror-Referenz ist ein Problem der Art mirror, nicht reference-missing. Abweichung von der Aufgabenbeschreibung („Mirror-Referenz fehlt → reference-missing“). Grund: Heute sperrt ein Mirror ohne Referenz auch eine TESTED-Version (mirrorChecks() schlägt fehl → Incompatible). Wäre es reference-missing, würde eine freigegebene Version (die nach E1 reference-missing ignoriert) einen Mirror ungeprüft schreiben lassen – etwa bei kaputter oder fehlender fingerprints.json (DiviReference::expected() liefert dann []). Das wäre fail open. Bei unbekannter Version heißt das incompatible statt untested; auch das ist richtig: eine Admin-Freigabe könnte den Mirror ohnehin nicht freischalten, „bitte freigeben“ wäre irreführend. reference-missing gibt es damit nur für Call-Signaturen.
  2. ReleaseGate::assertFeatures(array $features, ?int $postId = null): void (neu, zusätzlich zu assertFeature()): DiviSaver braucht zwei Fähigkeiten (D5) und soll eine Ablehnung mit allen gesperrten nennen. assertFeature($f, $id) = assertFeatures([$f], $id); assertWritable($id) = assertFeatures(CompatibilityMatrix::writeFeatures(), $id).
  3. Problem: Konstanten CALL, SIGNATURE, MIRROR, CONSTANT, REFERENCE_MISSING, RESOLVER; isHard(): bool (call, mirror, constant, resolver), concerns(DiviFeature): bool (ohne Fähigkeiten = alle, fail closed), toArray(): array{label, kind, features: list<string>}, static fromArray(mixed): ?self (Cache; null bei jeder Abweichung).
  4. FeatureStatus: Konstruktor-Defaults array $problems = [], ?string $source = null; summary(): array{id, label, state, problems} (D6), toArray() = summary() + source.
  5. CompatibilityMatrix: public const MISSING; fehlende/falsch verschlüsselte Fähigkeit im Konstruktor → incompatible mit MISSING (fail closed); zusätzlich static uniform(ReleaseState, array $problems = [], ?string $source = null): self, static writeFeatures(): list<DiviFeature>, usable(list<DiviFeature>): bool, state(): ReleaseState (schlechtester Zustand), source(): ?string (gemeinsame Quelle einer voll freigegebenen Matrix, sonst null), problems(): list<string> (alle, einmal). toArray() liefert list<array{id, label, state, problems, source}> in DiviFeature::cases()-Reihenfolge.
  6. ManifestCheck::codeProblems(): list<Problem> (Calls, Signaturen, Konstanten; live) und ManifestCheck::mirrorProblems(): list<Problem> (gecacht); problems() = beide. codeChecks(), mirrorChecks(), registrationChecks(), failed(), accepts() bleiben für Berichte (CompatibilityCheck, DiviCompatibility).
  7. ReleaseResolver::AUTO_RELEASE_FILTER = 'lit_mcp_divi_auto_release'; Konstruktor unverändert.
  8. ReleaseStatus: SOURCE_AUTO = 'auto'; Konstruktor bekommt als sechsten Parameter ?CompatibilityMatrix $matrix = null (ohne Matrix: CompatibilityMatrix::uniform($state, $problems, $source) – damit bleiben die ~20 bestehenden new ReleaseStatus(...) in Tests gültig; mit Matrix in anderem Zustand: LogicException); static of(string $version, CompatibilityMatrix $matrix, ?array $release = null): self (Zustand = schlechtester, problems = alle, source = $matrix->source()); refusalFor(list<DiviFeature> $features): WP_Error (D6, LogicException, wenn alle nutzbar). refusal(), notice(), toArray() bleiben unverändert (Altnutzer DiviToolGate, DiviContentComposer, EnvironmentProbe, DiviTab bis Tasks 9/10).
  9. DiviLocked: public readonly array $features (Promotion des Gerüst-Parameters); toError() = refusalFor() mit Fähigkeiten, ohne Fähigkeiten wie heute $status->refusal().
  10. SaveScope::features(): list<DiviFeature> (D5): Page → [PageWrite], LibraryItem/GlobalItem → [PageWrite, LibraryWrite], ThemeBuilderLayout → [PageWrite, ThemeBuilderWrite].
  11. DesignArea::feature(): DiviFeature (Interface-Erweiterung): PresetArea → PresetWrite; DesignChange::apply() ruft ReleaseGate::assertFeature($area->feature()). Jede künftige DesignArea (4c Farben/Variablen, 4d Theme-Optionen) muss eine Fähigkeit nennen – D1 hat dafür heute keine (siehe Offene Punkte).
  12. Test-Hilfe tests/Support/ReleaseMatrices.php (matrix(list<DiviFeature> $locked, ReleaseState $state = Untested), status(...)) – Tasks 8–10 können sie für „nur Fähigkeit X gesperrt“ nutzen.

Annahmen an Tasks 1, 4, 5 (so in der Kopie als Minimal-Stub gebaut): DiviLocked implements ToolRefusal (Task 1); DiviFeature mit isWrite()/label() und den Labels „Seiten schreiben“, „Bibliothek schreiben“, „Presets schreiben“, „Theme Builder schreiben“ (Task 4, D1); Manifest::$constants ist list<ConstantRequirement> mit public readonly string $name und label() = 'Konstante ' . $name, und ManifestCheck::codeChecks() nutzt nach Task 4 bereits $constant->label()/$constant->name; Manifest::featuresOf(Requirement); Mirror nimmt array $features als sechsten Parameter; SignatureFingerprint::of(Call): ?string und DiviReference::signatureKey(Call): string ('sig:' . ltrim($call->symbol, '\\'), Task 5). Mit dem Entwurf part-4-5.md abgeglichen: Labels, ConstantRequirement, Manifest::$constants, featuresOf(), signatureKey() passen.


Task 6: Fähigkeiten-Matrix – Problem, FeatureStatus, CompatibilityMatrix, ManifestCheck::problems(), Resolver mit E1-Automatik und Cache D7

Files:

  • Create: src/Integrations/Divi/Release/Problem.php
  • Create: src/Integrations/Divi/Release/FeatureStatus.php
  • Create: src/Integrations/Divi/Release/CompatibilityMatrix.php
  • Modify: src/Integrations/Divi/Release/ManifestCheck.php (ganze Datei unten)
  • Modify: src/Integrations/Divi/Release/ReleaseResolver.php (ganze Datei unten)
  • Modify: src/Integrations/Divi/Release/ReleaseStatus.php (ganze Datei unten)
  • Modify: src/Integrations/Divi/Release/ReleaseGate.php (Fallback bei Resolver-Exception)
  • Modify: src/Admin/DiviTab.php (Erklärtext für SOURCE_AUTO)
  • Create: tests/Support/ReleaseMatrices.php
  • Test: tests/Unit/Integrations/Divi/Release/ProblemTest.php (neu), FeatureStatusTest.php (neu), CompatibilityMatrixTest.php (neu), ManifestCheckTest.php (erweitert), ReleaseResolverTest.php (neu geschrieben), ReleaseStatusTest.php (erweitert), ReleaseGateTest.php (ein Test erweitert), tests/Unit/Admin/DiviTabTest.php (ein Test neu)

Interfaces:

  • Consumes: DiviFeature (Task 4: cases(), value, isWrite(), label(), tryFrom()), Requirement, ConstantRequirement, Manifest::featuresOf(), Call/Mirror mit Fähigkeiten (Task 4); SignatureFingerprint::of(Call): ?string, DiviReference::signatureKey(Call), DiviReference::expected() mit Schlüsseln sig:<symbol> (Task 5); ReleaseState, ReleaseStore::find(), DiviReleases::TESTED, DiviEnvironment::version(), Plugin::VERSION, get_transient/set_transient, apply_filters.
  • Produces: Problem, FeatureStatus, CompatibilityMatrix (Signaturen oben), ManifestCheck::problems()/ codeProblems()/mirrorProblems(), ReleaseResolver::resolve() liefert ReleaseStatus::of(...) mit Matrix, ReleaseResolver::AUTO_RELEASE_FILTER, ReleaseStatus::$matrix, ReleaseStatus::of(), ReleaseStatus::SOURCE_AUTO, ReleaseStatus::writable() = alle Schreib-Fähigkeiten nutzbar.

Regeln (E1, exakt so implementiert und getestet):

Version Problem einer Fähigkeit Zustand der Fähigkeit
freigegeben (TESTED → code, ReleaseStore → database) call, mirror, constant incompatible
freigegeben nur signature/reference-missing oder keins released (Quelle code/database)
nicht freigegeben, Automatik an, Version bekannt call, mirror, constant, signature incompatible
ebenso nur reference-missing untested
ebenso keins released (Quelle auto)
nicht freigegeben, Automatik aus oder Version '' call, mirror, constant incompatible
ebenso sonst untested
Resolver wirft (in ReleaseGate::status()) – alle incompatible (CompatibilityMatrix::allIncompatible())

Gleichheit mit heute für freigegebene Versionen: heute sperren fehlgeschlagene Code-Checks (Call fehlt/Argumentzahl, Konstante) und Mirror-Checks (abweichend, unlesbar, ohne Referenz) auch eine TESTED-Version; genau diese Arten (call, constant, mirror) sperren weiter, jetzt nur die betroffenen Fähigkeiten. signature und reference-missing sind neu und ändern bei freigegebenen Versionen nichts. Eine Admin-Freigabe wird heute nur gefunden, wenn keine Probleme vorliegen – sonst Incompatible; jetzt ebenso je Fähigkeit.

Cache D7 – Entscheidung: Der Transient lit_mcp_divi_state speichert nur die Mirror-Probleme, jetzt mit Art und Fähigkeiten: ['format' => 2, 'key' => <stateKey>, 'problems' => list<Problem::toArray()>]. Code- und Signatur-Checks laufen live je Request (einmal, ReleaseGate merkt sich den Status): (a) Reflection auf ~60 Calls kostet Mikrosekunden; (b) ein Divi-Hotfix ohne neue Versionsnummer in einer nicht gespiegelten Datei ändert stateKey() nicht (nur Mtimes der Mirror-Dateien gehen ein) – ein gecachter Code-Befund bliebe einen Tag falsch; (c) ein Request, in dem Divi-Funktionen noch nicht geladen sind (früher Hook, CLI), würde sonst „Call fehlt“ für einen Tag cachen und alles sperren. Der Cache hält Probleme, nicht Zustände: Freigabe, Rücknahme und der Filter wirken sofort ohne Invalidierung. Ein Eintrag ohne format => 2 (das alte Label-Format), mit anderem Schlüssel oder mit einer unlesbaren Zeile (unbekannte Art/Fähigkeit) ist ein Miss → neu prüfen. stateKey() bleibt unverändert: die Signatur-Referenzen stehen in fingerprints.json, deren md5_file schon im Schlüssel ist; da Code-Checks live laufen, schützt der Schlüssel ohnehin nur die Mirror-Ergebnisse. D7 („EnvironmentProbe liest die gecachte Matrix, keine Neuberechnung der Mirrors je Aufruf“) ist erfüllt: EnvironmentProbe liest ReleaseGate::status(), das die Mirrors aus dem Transient nimmt.

  • Step 1: Test-Hilfe und Tests der reinen Klassen schreiben (RED)

tests/Support/ReleaseMatrices.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Support;

use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;

/** Release statuses for tests: every feature released except the named ones. */
final class ReleaseMatrices
{
    /** @param list<DiviFeature> $locked */
    public static function matrix(array $locked, ReleaseState $state = ReleaseState::Untested): CompatibilityMatrix
    {
        $features = [];
        foreach (DiviFeature::cases() as $feature) {
            $features[$feature->value] = in_array($feature, $locked, true)
                ? new FeatureStatus(
                    $feature,
                    $state,
                    $state === ReleaseState::Incompatible ? ['Seiten: et_x() – Signatur geändert'] : []
                )
                : new FeatureStatus($feature, ReleaseState::Released, [], ReleaseStatus::SOURCE_CODE);
        }

        return new CompatibilityMatrix($features);
    }

    /** @param list<DiviFeature> $locked */
    public static function status(
        array $locked,
        ReleaseState $state = ReleaseState::Untested,
        string $version = '5.3.0'
    ): ReleaseStatus {
        return ReleaseStatus::of($version, self::matrix($locked, $state));
    }
}

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

<?php

declare(strict_types=1);

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

use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Problem;
use PHPUnit\Framework\TestCase;

final class ProblemTest extends TestCase
{
    public function testOnlyCallMirrorConstantAndResolverAreHard(): void
    {
        $hard = [];
        foreach (['call', 'signature', 'mirror', 'constant', 'reference-missing', 'resolver'] as $kind) {
            $hard[$kind] = (new Problem('x', [DiviFeature::PageWrite], $kind))->isHard();
        }

        self::assertSame(
            ['call' => true, 'signature' => false, 'mirror' => true, 'constant' => true,
                'reference-missing' => false, 'resolver' => true],
            $hard
        );
    }

    public function testConcernsItsFeaturesAndWithoutFeaturesEveryFeature(): void
    {
        $problem = new Problem('x', [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite], Problem::CALL);
        self::assertTrue($problem->concerns(DiviFeature::ThemeBuilderWrite));
        self::assertFalse($problem->concerns(DiviFeature::PageRead));

        $unassigned = new Problem('x', [], Problem::CALL);
        foreach (DiviFeature::cases() as $feature) {
            self::assertTrue($unassigned->concerns($feature), $feature->value);
        }
    }

    public function testRoundTripsThroughTheCacheFormat(): void
    {
        $problem = new Problem('Seiten: Nachbau von a() – x', [DiviFeature::PageWrite], Problem::MIRROR);

        self::assertSame(
            ['label' => 'Seiten: Nachbau von a() – x', 'kind' => 'mirror', 'features' => ['page-write']],
            $problem->toArray()
        );
        self::assertEquals($problem, Problem::fromArray($problem->toArray()));
    }

    public function testFromArrayRefusesAnythingElse(): void
    {
        foreach ([
            'no array' => 'Seiten: x()',
            'no label' => ['kind' => 'call', 'features' => []],
            'unknown kind' => ['label' => 'x', 'kind' => 'other', 'features' => []],
            'unknown feature' => ['label' => 'x', 'kind' => 'call', 'features' => ['page-delete']],
            'features no list' => ['label' => 'x', 'kind' => 'call', 'features' => 'page-write'],
        ] as $case => $row) {
            self::assertNull(Problem::fromArray($row), $case);
        }
    }
}

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

<?php

declare(strict_types=1);

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

use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use PHPUnit\Framework\TestCase;

final class FeatureStatusTest extends TestCase
{
    public function testOnlyAReleasedFeatureIsUsable(): void
    {
        self::assertTrue((new FeatureStatus(DiviFeature::PageRead, ReleaseState::Released, [], 'auto'))->usable());
        self::assertFalse((new FeatureStatus(DiviFeature::PageRead, ReleaseState::Untested))->usable());
        self::assertFalse((new FeatureStatus(DiviFeature::PageRead, ReleaseState::Incompatible, ['x']))->usable());
    }

    public function testSummaryForRefusalsAndToArrayWithTheSource(): void
    {
        $status = new FeatureStatus(DiviFeature::PresetWrite, ReleaseState::Incompatible, ['Designsystem: x()']);

        self::assertSame(
            ['id' => 'preset-write', 'label' => DiviFeature::PresetWrite->label(), 'state' => 'incompatible',
                'problems' => ['Designsystem: x()']],
            $status->summary()
        );
        self::assertSame($status->summary() + ['source' => null], $status->toArray());
    }
}

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

<?php

declare(strict_types=1);

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

use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use PHPUnit\Framework\TestCase;

final class CompatibilityMatrixTest extends TestCase
{
    public function testAFeatureWithoutResultIsIncompatible(): void
    {
        $matrix = new CompatibilityMatrix([
            'page-read' => new FeatureStatus(DiviFeature::PageRead, ReleaseState::Released, [], 'code'),
            // Under the wrong key: does not count for page-write.
            'page-write' => new FeatureStatus(DiviFeature::PageRead, ReleaseState::Released, [], 'code'),
        ]);

        self::assertTrue($matrix->of(DiviFeature::PageRead)->usable());
        foreach ([DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite] as $feature) {
            $status = $matrix->of($feature);
            self::assertSame(ReleaseState::Incompatible, $status->state);
            self::assertSame([CompatibilityMatrix::MISSING], $status->problems);
        }
        self::assertCount(count(DiviFeature::cases()) - 1, $matrix->unusable());
    }

    public function testAllReleasedAndAllIncompatible(): void
    {
        $released = CompatibilityMatrix::allReleased('database');
        $broken = CompatibilityMatrix::allIncompatible('kaputt');

        foreach (DiviFeature::cases() as $feature) {
            self::assertTrue($released->of($feature)->usable());
            self::assertSame('database', $released->of($feature)->source);
            self::assertSame(ReleaseState::Incompatible, $broken->of($feature)->state);
            self::assertSame(['kaputt'], $broken->of($feature)->problems);
            self::assertNull($broken->of($feature)->source);
        }
        self::assertSame([], $released->unusable());
        self::assertSame('code', CompatibilityMatrix::allReleased()->source());
    }

    public function testTheStateIsTheWorstFeatureAndEveryProblemIsListedOnce(): void
    {
        $matrix = self::matrix([
            'page-write' => [ReleaseState::Untested, ['a']],
            'tb-write' => [ReleaseState::Incompatible, ['b', 'a']],
        ]);

        self::assertSame(ReleaseState::Incompatible, $matrix->state());
        self::assertSame(['a', 'b'], $matrix->problems());
        self::assertNull($matrix->source());
        self::assertSame(ReleaseState::Untested, self::matrix(['catalog' => [ReleaseState::Untested, []]])->state());
        self::assertSame(ReleaseState::Released, self::matrix([])->state());
    }

    public function testTheSourceOfAReleasedMatrixIsCommonOrNull(): void
    {
        self::assertSame('auto', self::matrix([])->source());

        $mixed = new CompatibilityMatrix(array_merge(
            self::features([]),
            ['roles' => new FeatureStatus(DiviFeature::Roles, ReleaseState::Released, [], 'code')]
        ));
        self::assertSame(ReleaseState::Released, $mixed->state());
        self::assertNull($mixed->source());
    }

    public function testUsableAndUnusable(): void
    {
        $matrix = self::matrix(['catalog' => [ReleaseState::Untested, []]]);

        self::assertTrue($matrix->usable([DiviFeature::PageWrite, DiviFeature::PageRead]));
        self::assertFalse($matrix->usable([DiviFeature::PageWrite, DiviFeature::Catalog]));
        self::assertTrue($matrix->usable([]));
        self::assertSame(
            [DiviFeature::Catalog],
            array_map(static fn (FeatureStatus $status): DiviFeature => $status->feature, $matrix->unusable())
        );
    }

    public function testTheWriteFeaturesAreTheWritingCases(): void
    {
        self::assertSame(
            array_values(array_filter(DiviFeature::cases(), static fn (DiviFeature $f): bool => $f->isWrite())),
            CompatibilityMatrix::writeFeatures()
        );
        self::assertContains(DiviFeature::PageWrite, CompatibilityMatrix::writeFeatures());
        self::assertNotContains(DiviFeature::PageRead, CompatibilityMatrix::writeFeatures());
    }

    public function testToArrayListsEveryFeatureInCaseOrder(): void
    {
        $rows = self::matrix(['tb-write' => [ReleaseState::Incompatible, ['b']]])->toArray();

        self::assertSame(
            array_map(static fn (DiviFeature $feature): string => $feature->value, DiviFeature::cases()),
            array_column($rows, 'id')
        );
        self::assertContains(
            ['id' => 'tb-write', 'label' => DiviFeature::ThemeBuilderWrite->label(), 'state' => 'incompatible',
                'problems' => ['b'], 'source' => null],
            $rows
        );
    }

    /**
     * @param array<string, array{ReleaseState, list<string>}> $locked
     * @return array<string, FeatureStatus>
     */
    private static function features(array $locked): array
    {
        $features = [];
        foreach (DiviFeature::cases() as $feature) {
            [$state, $problems] = $locked[$feature->value] ?? [ReleaseState::Released, []];
            $features[$feature->value] = new FeatureStatus(
                $feature,
                $state,
                $problems,
                $state === ReleaseState::Released ? 'auto' : null
            );
        }

        return $features;
    }

    /** @param array<string, array{ReleaseState, list<string>}> $locked */
    private static function matrix(array $locked): CompatibilityMatrix
    {
        return new CompatibilityMatrix(self::features($locked));
    }
}
  • Step 2: Tests laufen lassen – sie schlagen fehl

Run: php vendor/bin/phpunit --filter '/ProblemTest|FeatureStatusTest|CompatibilityMatrixTest/' Expected: FAIL – jeder Test mit Error: Class "LIT\MCP\Integrations\Divi\Release\Problem" not found bzw. …\FeatureStatus" not found / …\CompatibilityMatrix" not found.

  • Step 3: Problem, FeatureStatus, CompatibilityMatrix anlegen

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

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/**
 * One failed manifest check and the Divi features it concerns (Manifest::featuresOf()). The kind decides how the
 * resolver weighs it (E1): call, mirror and constant lock their features for every Divi version, released or not;
 * signature and reference-missing only matter for a version without release (automatic release). A problem without
 * features concerns every feature (fail closed).
 */
final class Problem
{
    public const CALL = 'call';
    public const SIGNATURE = 'signature';
    public const MIRROR = 'mirror';
    public const CONSTANT = 'constant';
    public const REFERENCE_MISSING = 'reference-missing';
    public const RESOLVER = 'resolver';

    private const KINDS = [
        self::CALL,
        self::SIGNATURE,
        self::MIRROR,
        self::CONSTANT,
        self::REFERENCE_MISSING,
        self::RESOLVER,
    ];
    private const HARD = [self::CALL, self::MIRROR, self::CONSTANT, self::RESOLVER];

    /**
     * @param string            $label    German, e.g. "Seiten: et_x()" (the labels of ManifestCheck).
     * @param list<DiviFeature> $features
     * @param string            $kind     One of the constants above.
     */
    public function __construct(
        public readonly string $label,
        public readonly array $features,
        public readonly string $kind
    ) {
    }

    /** Locks its features whatever the release (the code no longer fits Divi or a mirror differs). */
    public function isHard(): bool
    {
        return in_array($this->kind, self::HARD, true);
    }

    public function concerns(DiviFeature $feature): bool
    {
        return $this->features === [] || in_array($feature, $this->features, true);
    }

    /** @return array{label: string, kind: string, features: list<string>} For the transient. */
    public function toArray(): array
    {
        return [
            'label' => $this->label,
            'kind' => $this->kind,
            'features' => array_map(static fn (DiviFeature $feature): string => $feature->value, $this->features),
        ];
    }

    /** The problem of toArray(), or null for anything else (an unknown kind or feature included). */
    public static function fromArray(mixed $row): ?self
    {
        if (
            ! is_array($row)
            || ! is_string($row['label'] ?? null)
            || ! in_array($row['kind'] ?? null, self::KINDS, true)
            || ! is_array($row['features'] ?? null)
        ) {
            return null;
        }

        $features = [];
        foreach ($row['features'] as $value) {
            $feature = is_string($value) ? DiviFeature::tryFrom($value) : null;
            if ($feature === null) {
                return null;
            }
            $features[] = $feature;
        }

        return new self($row['label'], $features, $row['kind']);
    }
}

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

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/** The release state of one Divi feature (E1): released (with its source), untested or incompatible. */
final class FeatureStatus
{
    /**
     * @param list<string> $problems Labels of the problems behind the state (Problem::$label).
     * @param string|null  $source   For a released feature: ReleaseStatus::SOURCE_CODE, SOURCE_DATABASE or
     *                               SOURCE_AUTO; null otherwise.
     */
    public function __construct(
        public readonly DiviFeature $feature,
        public readonly ReleaseState $state,
        public readonly array $problems = [],
        public readonly ?string $source = null
    ) {
    }

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

    /** @return array{id: string, label: string, state: string, problems: list<string>} For refusals (D6). */
    public function summary(): array
    {
        return [
            'id' => $this->feature->value,
            'label' => $this->feature->label(),
            'state' => $this->state->value,
            'problems' => $this->problems,
        ];
    }

    /**
     * @return array{id: string, label: string, state: string, problems: list<string>, source: ?string}
     *         For lit-site-status and the admin tab.
     */
    public function toArray(): array
    {
        return $this->summary() + ['source' => $this->source];
    }
}

src/Integrations/Divi/Release/CompatibilityMatrix.php (nutzt ReleaseStatus::SOURCE_CODE, das es schon gibt):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/**
 * The release state of every Divi feature (E1). A feature the constructor did not get is incompatible (fail
 * closed), so of() always answers. The overall state of the matrix is its worst feature state.
 */
final class CompatibilityMatrix
{
    public const MISSING = 'Für diese Fähigkeit liegt kein Prüfergebnis vor.';

    /** @var array<string, FeatureStatus> keyed by DiviFeature value, in DiviFeature::cases() order */
    private array $features = [];

    /** @param array<string, FeatureStatus> $features keyed by DiviFeature value */
    public function __construct(array $features)
    {
        foreach (DiviFeature::cases() as $feature) {
            $status = $features[$feature->value] ?? null;
            $this->features[$feature->value] = $status instanceof FeatureStatus && $status->feature === $feature
                ? $status
                : new FeatureStatus($feature, ReleaseState::Incompatible, [self::MISSING]);
        }
    }

    public static function allReleased(string $source = ReleaseStatus::SOURCE_CODE): self
    {
        return self::uniform(ReleaseState::Released, [], $source);
    }

    public static function allIncompatible(string $problem): self
    {
        return self::uniform(ReleaseState::Incompatible, [$problem]);
    }

    /**
     * Every feature in $state – the matrix of a status built without one (ReleaseStatus' constructor).
     *
     * @param list<string> $problems
     */
    public static function uniform(ReleaseState $state, array $problems = [], ?string $source = null): self
    {
        $released = $state === ReleaseState::Released;
        $features = [];
        foreach (DiviFeature::cases() as $feature) {
            $features[$feature->value] = new FeatureStatus(
                $feature,
                $state,
                $released ? [] : $problems,
                $released ? ($source ?? ReleaseStatus::SOURCE_CODE) : null
            );
        }

        return new self($features);
    }

    /** @return list<DiviFeature> The features that write (ReleaseGate::assertWritable(), ReleaseStatus::writable()). */
    public static function writeFeatures(): array
    {
        return array_values(array_filter(
            DiviFeature::cases(),
            static fn (DiviFeature $feature): bool => $feature->isWrite()
        ));
    }

    public function of(DiviFeature $feature): FeatureStatus
    {
        return $this->features[$feature->value];
    }

    /** @param list<DiviFeature> $features */
    public function usable(array $features): bool
    {
        foreach ($features as $feature) {
            if (! $this->of($feature)->usable()) {
                return false;
            }
        }

        return true;
    }

    /** @return list<FeatureStatus> */
    public function unusable(): array
    {
        return array_values(array_filter(
            $this->features,
            static fn (FeatureStatus $status): bool => ! $status->usable()
        ));
    }

    /** Incompatible before untested before released. */
    public function state(): ReleaseState
    {
        $states = array_map(static fn (FeatureStatus $status): ReleaseState => $status->state, $this->features);

        return match (true) {
            in_array(ReleaseState::Incompatible, $states, true) => ReleaseState::Incompatible,
            in_array(ReleaseState::Untested, $states, true) => ReleaseState::Untested,
            default => ReleaseState::Released,
        };
    }

    /** The common source of a fully released matrix; null when not all released or the sources differ. */
    public function source(): ?string
    {
        $sources = array_unique(array_map(
            static fn (FeatureStatus $status): string => (string) $status->source,
            $this->features
        ));

        return $this->state() === ReleaseState::Released && count($sources) === 1 ? (string) reset($sources) : null;
    }

    /** @return list<string> Every problem label once, in feature order. */
    public function problems(): array
    {
        $problems = [];
        foreach ($this->features as $status) {
            $problems = array_merge($problems, $status->problems);
        }

        return array_values(array_unique($problems));
    }

    /** @return list<array{id: string, label: string, state: string, problems: list<string>, source: ?string}> */
    public function toArray(): array
    {
        return array_values(array_map(static fn (FeatureStatus $status): array => $status->toArray(), $this->features));
    }
}
  • Step 4: Tests laufen lassen – sie bestehen

Run: php vendor/bin/phpunit --filter '/ProblemTest|FeatureStatusTest|CompatibilityMatrixTest/' Expected: PASS (13 Tests). tests/Support/ReleaseMatrices.php braucht ReleaseStatus::of() (Step 10); bis dahin nutzt es kein Test.

  • Step 5: ManifestCheckTest erweitern (RED)

Imports ergänzen (Vorher → Nachher):

use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\ConstantRequirement;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Release\Problem;
use LIT\MCP\Integrations\Divi\Release\SignatureFingerprint;

Am Ende der Klasse (vor der schließenden }) anfügen:

    /** A manifest with one requirement per problem kind; the default feature is page-read. */
    private function featureCheck(?\Closure $current = null): ManifestCheck
    {
        $manifest = new Manifest(
            Manifest::PAGES,
            calls: [
                new Call('strlen', 1),
                new Call('str_replace', 3, [DiviFeature::PageWrite]),
                new Call('trim', 1),
                new Call('lit_mcp_missing_function', 0, [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite]),
            ],
            constants: [
                new ConstantRequirement('PHP_VERSION'),
                new ConstantRequirement('LIT_MCP_MISSING_CONSTANT', [DiviFeature::Catalog]),
            ],
            postTypes: ['lit_mcp_missing_type'],
            mirrors: [
                new Mirror('same', 'a.php', 'a', null, 'gleich'),
                new Mirror('changed', 'b.php', 'b', null, 'geändert', [DiviFeature::ThemeBuilderWrite]),
                new Mirror('no-expectation', 'd.php', 'd', null, 'ohne Soll'),
            ],
            features: [DiviFeature::PageRead]
        );
        $hashes = ['same' => 'h1', 'changed' => 'hX', 'no-expectation' => 'h4'];

        return new ManifestCheck(
            ['A' => $manifest],
            [
                'same' => 'h1',
                'changed' => 'h2',
                'sig:strlen' => (string) SignatureFingerprint::of(new Call('strlen', 1)),
                'sig:str_replace' => 'a different signature',
            ],
            $current ?? static fn (Mirror $mirror): ?string => $hashes[$mirror->id] ?? null,
            static fn (string $name): bool => false
        );
    }

    /**
     * @param list<Problem> $problems
     * @return list<array{label: string, kind: string, features: list<string>}>
     */
    private static function rows(array $problems): array
    {
        return array_map(static fn (Problem $problem): array => $problem->toArray(), $problems);
    }

    public function testCodeProblemsNameKindAndFeaturesOfEveryFailedCall(): void
    {
        self::assertSame(
            [
                ['label' => 'Seiten: str_replace() – Signatur geändert', 'kind' => 'signature',
                    'features' => ['page-write']],
                ['label' => 'Seiten: trim() – Referenz der Signatur fehlt', 'kind' => 'reference-missing',
                    'features' => ['page-read']],
                ['label' => 'Seiten: lit_mcp_missing_function()', 'kind' => 'call',
                    'features' => ['page-write', 'tb-write']],
                ['label' => 'Seiten: Konstante LIT_MCP_MISSING_CONSTANT', 'kind' => 'constant',
                    'features' => ['catalog']],
            ],
            self::rows($this->featureCheck(static function (): never {
                self::fail('code problems never read Divi files');
            })->codeProblems())
        );
    }

    public function testAMirrorWithoutReferenceIsAMirrorProblemLikeAChangedOne(): void
    {
        self::assertSame(
            [
                ['label' => 'Seiten: Nachbau von b() – geändert', 'kind' => 'mirror', 'features' => ['tb-write']],
                ['label' => 'Seiten: Nachbau von d() – ohne Soll', 'kind' => 'mirror', 'features' => ['page-read']],
            ],
            self::rows($this->featureCheck()->mirrorProblems())
        );
    }

    public function testAnUnreadableMirrorIsAMirrorProblem(): void
    {
        $problems = $this->featureCheck(static function (): never {
            throw new RuntimeException('Divi file gone');
        })->mirrorProblems();

        self::assertSame(['mirror', 'mirror', 'mirror'], array_column(self::rows($problems), 'kind'));
    }

    public function testProblemsAreCodeThenMirrorsAndRegistrationIsNone(): void
    {
        $check = $this->featureCheck();

        self::assertEquals(array_merge($check->codeProblems(), $check->mirrorProblems()), $check->problems());
        self::assertNotContains(
            'Seiten: Post-Typ lit_mcp_missing_type',
            array_column(self::rows($check->problems()), 'label')
        );
    }
  • Step 6: Test laufen lassen – er schlägt fehl

Run: php vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php Expected: FAIL – die vier neuen Tests mit Error: Call to undefined method LIT\MCP\Integrations\Divi\Release\ManifestCheck::codeProblems() bzw. …::mirrorProblems(); die fünf alten bestehen.

  • Step 7: ManifestCheck – Probleme mit Art und Fähigkeiten

src/Integrations/Divi/Release/ManifestCheck.php vollständig (Nachher; geändert gegenüber dem Stand nach Task 4: Docblock, $expected-Doc, mirrorChecks() über mirrorLabel()/matches(), failed() über passes(), neu problems(), codeProblems(), mirrorProblems(), callProblem(), matches(), expected(), mirrorLabel(), passes(); codeChecks(), registrationChecks(), accepts(), installed() unverändert):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

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

defined('ABSPATH') || exit;

/**
 * The checks of all adapter manifests (spec §4.3), as label => check for reports, and as problems with their Divi
 * features for the release matrix (problems(), E1). Code checks (calls with the argument count the adapter passes
 * and their signature, constants) and mirror checks decide the release gate; registration checks (post types,
 * taxonomies, block types) depend on the request context (stage3 finding 6) and only show in reports.
 *
 * Not final so the resolver can be tested with a mock.
 */
class ManifestCheck
{
    /** @var array<string, Manifest> */
    private array $manifests;

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

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

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

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

        return $checks;
    }

    /** @return array<string, Closure(): bool> */
    public function mirrorChecks(): array
    {
        $checks = [];
        foreach ($this->manifests as $manifest) {
            foreach ($manifest->mirrors as $mirror) {
                $checks[self::mirrorLabel($manifest, $mirror)] = fn (): bool => $this->matches($mirror);
            }
        }

        return $checks;
    }

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

        return $checks;
    }

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

        return $failed;
    }

    /** @return list<Problem> Code and mirror problems with their features – what the release matrix is built of. */
    public function problems(): array
    {
        return array_merge($this->codeProblems(), $this->mirrorProblems());
    }

    /**
     * Calls (missing or other argument count: call; no reference: reference-missing; other signature: signature)
     * and constants. Reflection only – cheap enough for every request.
     *
     * @return list<Problem>
     */
    public function codeProblems(): array
    {
        $problems = [];
        foreach ($this->manifests as $manifest) {
            foreach ($manifest->calls as $call) {
                $problem = $this->callProblem($manifest, $call);
                if ($problem !== null) {
                    $problems[] = $problem;
                }
            }
            foreach ($manifest->constants as $constant) {
                if (! defined($constant->name)) {
                    $problems[] = new Problem(
                        $manifest->area . ': ' . $constant->label(),
                        $manifest->featuresOf($constant),
                        Problem::CONSTANT
                    );
                }
            }
        }

        return $problems;
    }

    /**
     * Mirrors that differ from their reference, cannot be read or have no reference – kind mirror in every case:
     * a mirror repeats how Divi writes, so it never runs unverified, whatever the release. Reads Divi files; the
     * resolver caches the result.
     *
     * @return list<Problem>
     */
    public function mirrorProblems(): array
    {
        $problems = [];
        foreach ($this->manifests as $manifest) {
            foreach ($manifest->mirrors as $mirror) {
                if (! self::passes(fn (): bool => $this->matches($mirror))) {
                    $problems[] = new Problem(
                        self::mirrorLabel($manifest, $mirror),
                        $manifest->featuresOf($mirror),
                        Problem::MIRROR
                    );
                }
            }
        }

        return $problems;
    }

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

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

    private function callProblem(Manifest $manifest, Call $call): ?Problem
    {
        $label = $manifest->area . ': ' . $call->label();
        $features = $manifest->featuresOf($call);
        if (! self::passes(static fn (): bool => self::accepts($call))) {
            return new Problem($label, $features, Problem::CALL);
        }

        $expected = $this->expected()[DiviReference::signatureKey($call)] ?? null;
        if ($expected === null) {
            return new Problem($label . ' – Referenz der Signatur fehlt', $features, Problem::REFERENCE_MISSING);
        }

        return self::passes(static fn (): bool => SignatureFingerprint::of($call) === $expected)
            ? null
            : new Problem($label . ' – Signatur geändert', $features, Problem::SIGNATURE);
    }

    /** The mirror's reference exists and the installed source has the same fingerprint. */
    private function matches(Mirror $mirror): bool
    {
        $expected = $this->expected()[$mirror->id] ?? null;

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

    /** @return array<string, string> */
    private function expected(): array
    {
        return $this->expected ??= DiviReference::expected();
    }

    private static function mirrorLabel(Manifest $manifest, Mirror $mirror): string
    {
        return sprintf('%s: Nachbau von %s – %s', $manifest->area, $mirror->label(), $mirror->what);
    }

    /** @param callable(): bool $check A check that throws failed. */
    private static function passes(callable $check): bool
    {
        try {
            return (bool) $check();
        } catch (Throwable) {
            return false;
        }
    }

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

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

Run: php vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php tests/Unit/Integrations/Divi/DiviCompatibilityTest.php tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php Expected: PASS (ManifestCheckTest 9 Tests; die Berichts-Checks sind unverändert).

  • Step 8: Resolver-, Status-, Gate- und Tab-Tests schreiben (RED)

tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php vollständig ersetzen:

<?php

declare(strict_types=1);

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

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

final class ReleaseResolverTest extends TestCase
{
    private const UNKNOWN = '5.99.0';

    /** @var array<string, mixed> */
    private array $transients = [];

    private int $mirrorRuns = 0;

    private int $codeRuns = 0;

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

            return true;
        });
    }

    /**
     * @param list<Problem>             $code
     * @param list<Problem>             $mirrors
     * @param array<string, mixed>|null $entry
     */
    private function resolver(
        string $version,
        array $code = [],
        array $mirrors = [],
        ?array $entry = null
    ): ReleaseResolver {
        $check = Mockery::mock(ManifestCheck::class);
        $check->allows('codeProblems')->andReturnUsing(function () use ($code): array {
            ++$this->codeRuns;

            return $code;
        });
        $check->allows('mirrorProblems')->andReturnUsing(function () use ($mirrors): array {
            ++$this->mirrorRuns;

            return $mirrors;
        });
        $store = Mockery::mock(ReleaseStore::class);
        $store->allows('find')->with($version, Plugin::VERSION)->andReturn($entry);

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

    /** @param list<DiviFeature> $features */
    private static function problem(string $kind, array $features, string $label = 'Seiten: et_x()'): Problem
    {
        return new Problem($label, $features, $kind);
    }

    /** @return array<string, string> Feature value => state, for every feature. */
    private static function states(ReleaseStatus $status): array
    {
        $states = [];
        foreach (DiviFeature::cases() as $feature) {
            $states[$feature->value] = $status->matrix->of($feature)->state->value;
        }

        return $states;
    }

    /**
     * @param array<string, string> $except Feature value => state.
     * @return array<string, string>
     */
    private static function allBut(string $state, array $except = []): array
    {
        $states = [];
        foreach (DiviFeature::cases() as $feature) {
            $states[$feature->value] = $except[$feature->value] ?? $state;
        }

        return $states;
    }

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

        self::assertSame(ReleaseState::Released, $status->state);
        self::assertSame('code', $status->source);
        self::assertSame(self::allBut('released'), self::states($status));
        self::assertSame('code', $status->matrix->of(DiviFeature::PageWrite)->source);
    }

    public function testAReleaseCoversChangedSignaturesAndMissingReferences(): void
    {
        $status = $this->resolver('5.2.1', [
            self::problem(Problem::SIGNATURE, [DiviFeature::PageWrite]),
            self::problem(Problem::REFERENCE_MISSING, [DiviFeature::Catalog]),
        ])->resolve();

        self::assertSame(self::allBut('released'), self::states($status));
        self::assertTrue($status->writable());
        self::assertSame([], $status->problems);
    }

    public function testAHardProblemLocksItsFeaturesEvenForATestedVersion(): void
    {
        $status = $this->resolver('5.2.1', [
            self::problem(Problem::CALL, [DiviFeature::PageWrite], 'Seiten: et_x()'),
            self::problem(Problem::CONSTANT, [DiviFeature::Catalog], 'Katalog: Konstante X'),
        ])->resolve();

        self::assertSame(
            self::allBut('released', ['page-write' => 'incompatible', 'catalog' => 'incompatible']),
            self::states($status)
        );
        self::assertSame(['Seiten: et_x()'], $status->matrix->of(DiviFeature::PageWrite)->problems);
        self::assertSame(ReleaseState::Incompatible, $status->state);
        self::assertSame(['Seiten: et_x()', 'Katalog: Konstante X'], $status->problems);
        self::assertFalse($status->writable());
    }

    public function testAProblemWithoutFeaturesLocksEveryFeature(): void
    {
        $status = $this->resolver('5.2.1', [self::problem(Problem::CALL, [])])->resolve();

        self::assertSame(self::allBut('incompatible'), self::states($status));
    }

    /** Review focus 2: a changed mirror locks exactly its features – released version or automatic release. */
    public function testAChangedMirrorLocksExactlyItsFeatures(): void
    {
        $mirror = self::problem(
            Problem::MIRROR,
            [DiviFeature::ThemeBuilderWrite, DiviFeature::LibraryWrite],
            'Theme Builder: Nachbau von y() – z'
        );
        $expected = ['tb-write' => 'incompatible', 'library-write' => 'incompatible'];

        self::assertSame(
            self::allBut('released', $expected),
            self::states($this->resolver('5.2.1', mirrors: [$mirror])->resolve())
        );
        $automatic = $this->resolver(self::UNKNOWN, mirrors: [$mirror])->resolve();
        self::assertSame(self::allBut('released', $expected), self::states($automatic));
        self::assertSame(
            ['Theme Builder: Nachbau von y() – z'],
            $automatic->matrix->of(DiviFeature::ThemeBuilderWrite)->problems
        );
    }

    public function testAnUnknownVersionWithoutProblemsIsReleasedAutomatically(): void
    {
        $status = $this->resolver(self::UNKNOWN)->resolve();

        self::assertSame(self::allBut('released'), self::states($status));
        self::assertSame('auto', $status->source);
        self::assertSame('auto', $status->matrix->of(DiviFeature::PresetWrite)->source);
        self::assertTrue($status->writable());
    }

    /** Review focus 3: the automatic release needs every signature unchanged. */
    public function testAChangedSignatureMakesItsFeatureIncompatibleUnderAutomaticRelease(): void
    {
        $status = $this->resolver(self::UNKNOWN, [
            self::problem(Problem::SIGNATURE, [DiviFeature::PresetWrite], 'Designsystem: x() – Signatur geändert'),
        ])->resolve();

        self::assertSame(self::allBut('released', ['preset-write' => 'incompatible']), self::states($status));
        self::assertSame(
            ['Designsystem: x() – Signatur geändert'],
            $status->matrix->of(DiviFeature::PresetWrite)->problems
        );
        self::assertFalse($status->writable());
    }

    public function testAMissingReferenceMakesItsFeatureUntestedUnderAutomaticRelease(): void
    {
        $status = $this->resolver(self::UNKNOWN, [
            self::problem(Problem::REFERENCE_MISSING, [DiviFeature::Roles], 'Seiten: r() – Referenz fehlt'),
            self::problem(Problem::REFERENCE_MISSING, [DiviFeature::PageRead]),
            self::problem(Problem::SIGNATURE, [DiviFeature::PageRead]),
        ])->resolve();

        self::assertSame(
            self::allBut('released', ['roles' => 'untested', 'page-read' => 'incompatible']),
            self::states($status)
        );
        self::assertSame(
            ['Seiten: r() – Referenz fehlt'],
            $status->matrix->of(DiviFeature::Roles)->problems
        );
    }

    /** Review focus 1: unknown version with the automatic release switched off. */
    public function testWithoutAutomaticReleaseAnUnknownVersionIsUntestedUnlessSomethingHardFailed(): void
    {
        Filters\expectApplied(ReleaseResolver::AUTO_RELEASE_FILTER)->once()->with(true)->andReturn(false);

        $status = $this->resolver(self::UNKNOWN, [
            self::problem(Problem::SIGNATURE, [DiviFeature::PresetWrite]),
            self::problem(Problem::CALL, [DiviFeature::Catalog]),
        ])->resolve();

        self::assertSame(self::allBut('untested', ['catalog' => 'incompatible']), self::states($status));
        self::assertNull($status->matrix->of(DiviFeature::PageRead)->source);
    }

    public function testOnlyALiteralTrueSwitchesTheAutomaticReleaseOn(): void
    {
        Filters\expectApplied(ReleaseResolver::AUTO_RELEASE_FILTER)->once()->andReturn(1);

        self::assertSame(self::allBut('untested'), self::states($this->resolver(self::UNKNOWN)->resolve()));
    }

    public function testADatabaseReleaseReleasesEveryFeatureWithoutHardProblems(): void
    {
        $entry = ['user_id' => 1, 'released_at' => 'd'];
        $status = $this->resolver(self::UNKNOWN, [
            self::problem(Problem::SIGNATURE, [DiviFeature::PageWrite]),
            self::problem(Problem::REFERENCE_MISSING, [DiviFeature::Catalog]),
        ], [self::problem(Problem::MIRROR, [DiviFeature::LibraryWrite])], $entry)->resolve();

        self::assertSame(self::allBut('released', ['library-write' => 'incompatible']), self::states($status));
        self::assertSame('database', $status->matrix->of(DiviFeature::PageWrite)->source);
        self::assertSame($entry, $status->release);
    }

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

        self::assertSame(ReleaseState::Untested, $status->state);
        self::assertSame(self::allBut('untested'), self::states($status));
    }

    public function testCodeProblemsRunOnEveryResolveMirrorProblemsOncePerStateKey(): void
    {
        $mirrors = [self::problem(Problem::MIRROR, [DiviFeature::ThemeBuilderWrite], 'Theme Builder: Nachbau von y()')];

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

        self::assertSame(2, $this->codeRuns, 'code and signature checks are live');
        self::assertSame(1, $this->mirrorRuns, 'the second resolve reads the transient');
        self::assertSame(self::states($first), self::states($second));
        self::assertSame($first->problems, $second->problems);
        self::assertSame(
            ['format' => 2, 'key' => 'key-5.2.1', 'problems' => [
                ['label' => 'Theme Builder: Nachbau von y()', 'kind' => 'mirror', 'features' => ['tb-write']],
            ]],
            $this->transients[ReleaseResolver::CACHE]
        );

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

    /** Review focus 1: an old or broken transient is never trusted. */
    public function testAnOldOrBrokenCacheEntryIsAMiss(): void
    {
        $mirrors = [self::problem(Problem::MIRROR, [DiviFeature::PageWrite])];
        $entries = [
            'format 1' => ['key' => 'key-5.2.1', 'problems' => []],
            'other format' => ['format' => 3, 'key' => 'key-5.2.1', 'problems' => []],
            'unknown feature' => ['format' => 2, 'key' => 'key-5.2.1', 'problems' => [
                ['label' => 'x', 'kind' => 'mirror', 'features' => ['page-delete']],
            ]],
            'no list' => ['format' => 2, 'key' => 'key-5.2.1', 'problems' => 'none'],
            'garbage' => 'x',
        ];

        foreach ($entries as $case => $entry) {
            $this->transients[ReleaseResolver::CACHE] = $entry;
            $runs = $this->mirrorRuns;

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

            self::assertSame($runs + 1, $this->mirrorRuns, $case);
            self::assertFalse($status->matrix->of(DiviFeature::PageWrite)->usable(), $case);
        }
    }

    public function testTheCacheHoldsProblemsSoAReleaseCountsAtOnce(): void
    {
        Filters\expectApplied(ReleaseResolver::AUTO_RELEASE_FILTER)->andReturn(false);

        $before = $this->resolver(self::UNKNOWN)->resolve();
        $after = $this->resolver(self::UNKNOWN, entry: ['user_id' => 1, 'released_at' => 'd'])->resolve();

        self::assertSame(ReleaseState::Untested, $before->state);
        self::assertSame(ReleaseState::Released, $after->state);
        self::assertSame(1, $this->mirrorRuns);
    }
}

tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php – Imports (Vorher → Nachher):

use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;
use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;

und am Ende der Klasse anfügen:

    public function testWithoutMatrixEveryFeatureIsInTheGivenState(): void
    {
        $untested = new ReleaseStatus(ReleaseState::Untested, '5.3.0');
        $broken = new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['x']);
        $released = ReleaseStatus::released('5.2.1', ReleaseStatus::SOURCE_DATABASE);

        foreach (DiviFeature::cases() as $feature) {
            self::assertSame(ReleaseState::Untested, $untested->matrix->of($feature)->state);
            self::assertSame(['x'], $broken->matrix->of($feature)->problems);
            self::assertSame('database', $released->matrix->of($feature)->source);
        }
    }

    public function testOfTakesStateSourceAndProblemsFromTheMatrix(): void
    {
        $status = ReleaseStatus::of(
            '5.99.0',
            ReleaseMatrices::matrix([DiviFeature::Catalog], ReleaseState::Incompatible),
            ['user_id' => 1]
        );

        self::assertSame(ReleaseState::Incompatible, $status->state);
        self::assertNull($status->source);
        self::assertSame(['Seiten: et_x() – Signatur geändert'], $status->problems);
        self::assertSame(['user_id' => 1], $status->release);
        self::assertSame('auto', ReleaseStatus::of('5.99.0', CompatibilityMatrix::allReleased('auto'))->source);
    }

    public function testWritableAsksOnlyTheWritingFeatures(): void
    {
        $readLocked = ReleaseMatrices::status([DiviFeature::Catalog, DiviFeature::PageRead]);
        self::assertSame(ReleaseState::Untested, $readLocked->state);
        self::assertTrue($readLocked->writable());

        foreach (CompatibilityMatrix::writeFeatures() as $feature) {
            self::assertFalse(ReleaseMatrices::status([$feature])->writable(), $feature->value);
        }
    }

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

        new ReleaseStatus(ReleaseState::Released, '5.2.1', 'code', [], null, CompatibilityMatrix::allIncompatible('x'));
    }

tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php – Import use LIT\MCP\Integrations\Divi\Release\DiviFeature; (nach WriteFailed) ergänzen und in testAFailingResolverLocksWrites() (Vorher → Nachher):

        self::assertSame(ReleaseState::Incompatible, $status->state);
        self::assertStringContainsString('theme API gone', (string) file_get_contents($log));
        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));

tests/Unit/Admin/DiviTabTest.php – vor testADatabaseReleaseCanBeWithdrawn() einfügen:

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

        self::assertStringContainsString('Automatisch freigegeben', $html);
        self::assertStringNotContainsString('freigeben</button>', $html);
        self::assertStringNotContainsString('Freigabe zurückziehen', $html);
    }

  • Step 9: Tests laufen lassen – sie schlagen fehl

Run: php vendor/bin/phpunit --filter '/ReleaseResolverTest|ReleaseStatusTest|ReleaseGateTest|DiviTabTest/' Expected: FAIL – ReleaseResolverTest 15× (Mockery\Exception\BadMethodCallException: Received Mockery_…_ManifestCheck::codeChecks(), but no expectations were specified bzw. Error: Undefined constant …ReleaseResolver::AUTO_RELEASE_FILTER), ReleaseStatusTest 4× (u. a. Error: Call to undefined method …ReleaseStatus::of(), Failed asserting that exception of type "LogicException" is thrown), ReleaseGateTest 1× (ReleaseStatus::$matrix fehlt), DiviTabTest 1× (Error: Undefined constant …ReleaseStatus::SOURCE_AUTO).

  • Step 10: ReleaseStatus trägt die Matrix

src/Integrations/Divi/Release/ReleaseStatus.php vollständig (Nachher; neu: Klassen-Docblock, SOURCE_AUTO, $matrix, sechster Konstruktor-Parameter mit Prüfung, of(), writable() über die Matrix; refusal(), notice(), toArray(), versionLabel() unverändert):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

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

defined('ABSPATH') || exit;

/**
 * The release of the installed Divi (spec §5.2): the matrix of its features (E1) and, for callers that know no
 * features, the overall state (the worst feature state) with every problem, and the texts of spec §5.6.
 */
final class ReleaseStatus
{
    public const SOURCE_CODE = 'code';
    public const SOURCE_DATABASE = 'database';
    public const SOURCE_AUTO = 'auto';

    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.';

    public readonly CompatibilityMatrix $matrix;

    /**
     * Without $matrix every feature is in $state (CompatibilityMatrix::uniform()); with one, use of().
     *
     * @param string                    $version  Divi version, '' when unknown (Divi is not the active template).
     * @param string|null               $source   SOURCE_CODE, SOURCE_DATABASE or SOURCE_AUTO for a released version.
     * @param list<string>              $problems Failed code and mirror checks (ManifestCheck labels).
     * @param array<string, mixed>|null $release  The database entry of a SOURCE_DATABASE release.
     * @throws LogicException When $matrix is in another state than $state.
     */
    public function __construct(
        public readonly ReleaseState $state,
        public readonly string $version,
        public readonly ?string $source = null,
        public readonly array $problems = [],
        public readonly ?array $release = null,
        ?CompatibilityMatrix $matrix = null
    ) {
        if ($matrix !== null && $matrix->state() !== $state) {
            throw new LogicException('the matrix is ' . $matrix->state()->value . ', not ' . $state->value);
        }
        $this->matrix = $matrix ?? CompatibilityMatrix::uniform($state, $problems, $source);
    }

    /** @param array<string, mixed>|null $release The database entry of a SOURCE_DATABASE release. */
    public static function of(string $version, CompatibilityMatrix $matrix, ?array $release = null): self
    {
        return new self($matrix->state(), $version, $matrix->source(), $matrix->problems(), $release, $matrix);
    }

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

    /** Every writing feature is usable – for callers that know no feature (DiviToolGate, DiviContentComposer). */
    public function writable(): bool
    {
        return $this->matrix->usable(CompatibilityMatrix::writeFeatures());
    }

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

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

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

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

src/Integrations/Divi/Release/ReleaseResolver.php vollständig:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use Closure;
use LIT\MCP\Plugin;

defined('ABSPATH') || exit;

/**
 * Computes the release status (spec §5.2–§5.4) as a matrix of Divi features (E1). Code and signature checks run
 * live (Reflection, cheap; a hotfix without a new version number is seen at once), mirror checks are cached in a
 * transient under stateKey() – Divi version, plugin version, fingerprints.json (mirror and signature references) and
 * the modification times of the mirrored Divi files.
 *
 * Per feature: a hard problem (call, mirror, constant) makes it incompatible, released version or not. A released
 * version (DiviReleases::TESTED, source code; ReleaseStore, source database) releases every other feature – its
 * release covers signatures. Otherwise, with the automatic release on (filter lit_mcp_divi_auto_release, default
 * true) and a known version: a changed signature makes the feature incompatible, a missing reference untested,
 * no problem releases it (source auto). Without the automatic release every other feature is untested.
 */
final class ReleaseResolver
{
    public const CACHE = 'lit_mcp_divi_state';
    public const AUTO_RELEASE_FILTER = 'lit_mcp_divi_auto_release';

    /** Version of the transient's layout; an entry without it (the list of labels before) is a miss. */
    private const CACHE_FORMAT = 2;

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

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

        if (in_array($version, DiviReleases::TESTED, true)) {
            return ReleaseStatus::of($version, self::matrix($problems, ReleaseStatus::SOURCE_CODE, false));
        }
        $entry = $version === '' ? null : ($this->store ?? new ReleaseStore())->find($version, Plugin::VERSION);
        if ($entry !== null) {
            return ReleaseStatus::of($version, self::matrix($problems, ReleaseStatus::SOURCE_DATABASE, false), $entry);
        }

        return ReleaseStatus::of($version, self::matrix($problems, null, $version !== '' && self::automatic()));
    }

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

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

    /** Only a literal true switches the automatic release on (fail closed). */
    private static function automatic(): bool
    {
        return apply_filters(self::AUTO_RELEASE_FILTER, true) === true;
    }

    /**
     * @param list<Problem> $problems
     * @param string|null   $source    SOURCE_CODE or SOURCE_DATABASE for a released version, else null.
     * @param bool          $automatic The automatic release applies (no release, known version, filter on).
     */
    private static function matrix(array $problems, ?string $source, bool $automatic): CompatibilityMatrix
    {
        $features = [];
        foreach (DiviFeature::cases() as $feature) {
            $concerning = array_values(array_filter(
                $problems,
                static fn (Problem $problem): bool => $problem->concerns($feature)
            ));
            $features[$feature->value] = self::feature($feature, $concerning, $source, $automatic);
        }

        return new CompatibilityMatrix($features);
    }

    /** @param list<Problem> $problems The problems concerning $feature. */
    private static function feature(
        DiviFeature $feature,
        array $problems,
        ?string $source,
        bool $automatic
    ): FeatureStatus {
        $hard = array_filter($problems, static fn (Problem $problem): bool => $problem->isHard());
        if ($hard !== []) {
            return new FeatureStatus($feature, ReleaseState::Incompatible, self::labels($hard));
        }
        if ($source !== null) {
            return new FeatureStatus($feature, ReleaseState::Released, [], $source);
        }
        if (! $automatic) {
            return new FeatureStatus($feature, ReleaseState::Untested, self::labels($problems));
        }

        $changed = array_filter($problems, static fn (Problem $problem): bool => $problem->kind === Problem::SIGNATURE);
        if ($changed !== []) {
            return new FeatureStatus($feature, ReleaseState::Incompatible, self::labels($changed));
        }

        return $problems === []
            ? new FeatureStatus($feature, ReleaseState::Released, [], ReleaseStatus::SOURCE_AUTO)
            : new FeatureStatus($feature, ReleaseState::Untested, self::labels($problems));
    }

    /**
     * @param array<int, Problem> $problems
     * @return list<string>
     */
    private static function labels(array $problems): array
    {
        return array_values(array_map(static fn (Problem $problem): string => $problem->label, $problems));
    }

    /** @return list<Problem> */
    private function mirrorProblems(ManifestCheck $check, string $version): array
    {
        $key = ($this->stateKey ?? self::stateKey(...))($version);
        $cached = self::cached(get_transient(self::CACHE), $key);
        if ($cached !== null) {
            return $cached;
        }

        $problems = $check->mirrorProblems();
        set_transient(
            self::CACHE,
            [
                'format' => self::CACHE_FORMAT,
                'key' => $key,
                'problems' => array_map(static fn (Problem $problem): array => $problem->toArray(), $problems),
            ],
            DAY_IN_SECONDS
        );

        return $problems;
    }

    /** @return list<Problem>|null null = miss: none, another key, the old format or any unreadable row. */
    private static function cached(mixed $entry, string $key): ?array
    {
        if (
            ! is_array($entry)
            || ($entry['format'] ?? null) !== self::CACHE_FORMAT
            || ($entry['key'] ?? null) !== $key
            || ! is_array($entry['problems'] ?? null)
        ) {
            return null;
        }

        $problems = [];
        foreach ($entry['problems'] as $row) {
            $problem = Problem::fromArray($row);
            if ($problem === null) {
                return null;
            }
            $problems[] = $problem;
        }

        return $problems;
    }
}
  • Step 12: ReleaseGate – Resolver-Fehler sperrt alle Fähigkeiten

src/Integrations/Divi/Release/ReleaseGate.php (Vorher):

    private static ?int $probe = null;

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

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

Nachher:

    private static ?int $probe = null;

    private const UNRESOLVED = 'Der Freigabe-Zustand ließ sich nicht ermitteln (Details im PHP-Error-Log).';

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

        try {
            self::$status = (self::$resolver ?? static fn (): ReleaseStatus => (new ReleaseResolver())->resolve())();
        } catch (Throwable $e) {
            Log::failure('divi release status', $e);
            self::$status = ReleaseStatus::of('', CompatibilityMatrix::allIncompatible(self::UNRESOLVED));
        }
  • Step 13: DiviTab – Erklärung einer automatischen Freigabe

Ohne diesen Zweig fiele SOURCE_AUTO in den Datenbank-Zweig („Auf dieser Website freigegeben am … von ?“). src/Admin/DiviTab.php, explanation() (Vorher):

        if ($status->source === ReleaseStatus::SOURCE_CODE) {
            return __('Diese Version ist im Plugin als getestet hinterlegt.', 'wp-lit-mcp');
        }

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

Nachher:

        if ($status->source === ReleaseStatus::SOURCE_CODE) {
            return __('Diese Version ist im Plugin als getestet hinterlegt.', 'wp-lit-mcp');
        }
        if ($status->source === ReleaseStatus::SOURCE_AUTO) {
            return __(
                // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
                'Automatisch freigegeben: Alle Divi-Stellen, die LIT MCP nutzt, stimmen mit der geprüften Referenz überein.',
                'wp-lit-mcp'
            );
        }

        $user = get_userdata((int) ($status->release['user_id'] ?? 0));
  • Step 14: Tests laufen lassen – sie bestehen

Run: php vendor/bin/phpunit --filter '/ReleaseResolverTest|ReleaseStatusTest|ReleaseGateTest|DiviTabTest|ManifestCheckTest|ProblemTest|FeatureStatusTest|CompatibilityMatrixTest/' Expected: PASS (ReleaseResolverTest 15, ReleaseStatusTest 10, ReleaseGateTest 7, DiviTabTest 13, ManifestCheckTest 9, ProblemTest 4, FeatureStatusTest 2, CompatibilityMatrixTest 7).

  • Step 15: Gesamtlauf und Lint

Run: composer test && composer lint Expected: alle Tests grün (Stand nach Task 5 + 32 neue Tests), phpcs ohne Meldung. Bestehende Tests mit new ReleaseStatus(...) und ReleaseStatus::released(...) laufen unverändert (uniforme Matrix).

  • Step 16: Commit
git add src/Integrations/Divi/Release/Problem.php src/Integrations/Divi/Release/FeatureStatus.php \
  src/Integrations/Divi/Release/CompatibilityMatrix.php src/Integrations/Divi/Release/ManifestCheck.php \
  src/Integrations/Divi/Release/ReleaseResolver.php src/Integrations/Divi/Release/ReleaseStatus.php \
  src/Integrations/Divi/Release/ReleaseGate.php src/Admin/DiviTab.php tests/Support/ReleaseMatrices.php \
  tests/Unit/Integrations/Divi/Release/ProblemTest.php tests/Unit/Integrations/Divi/Release/FeatureStatusTest.php \
  tests/Unit/Integrations/Divi/Release/CompatibilityMatrixTest.php \
  tests/Unit/Integrations/Divi/Release/ManifestCheckTest.php \
  tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php \
  tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php \
  tests/Unit/Admin/DiviTabTest.php
git commit -m "feat: Divi-Freigabe je Fähigkeit – CompatibilityMatrix aus Problemen mit Art und Fähigkeiten (ManifestCheck::problems), automatische Freigabe unveränderter Fähigkeiten (Filter lit_mcp_divi_auto_release), Mirror-Probleme im Transient (Format 2), ReleaseStatus trägt die Matrix" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 7: Sperre je Fähigkeit in allen Adaptern – ReleaseGate::feature()/assertFeature(), DiviLocked mit Fähigkeiten, 14 Aufrufstellen (D5, D6)

Files:

  • Modify: src/Integrations/Divi/Release/ReleaseGate.php (ganze Datei unten)
  • Modify: src/Integrations/Divi/Release/DiviLocked.php (ganze Datei unten)
  • Modify: src/Integrations/Divi/Release/ReleaseStatus.php (Texte, refusalFor(), named())
  • Modify: src/Integrations/Divi/Pages/SaveScope.php (features())
  • Modify: src/Integrations/Divi/Pages/DiviSaver.php (2 Stellen)
  • Modify: src/Integrations/Divi/Pages/DiviPages.php (1 Stelle)
  • Modify: src/Integrations/Divi/Library/DiviLibraryStore.php (1 Stelle)
  • Modify: src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php (8 Stellen)
  • Modify: src/Integrations/Divi/Design/DesignSystemWriter.php (1 Stelle)
  • Modify: src/Integrations/Divi/Design/DesignArea.php, src/Integrations/Divi/Design/PresetArea.php (feature())
  • Modify: src/Integrations/Divi/Design/DesignChange.php (1 Stelle)
  • Modify: tests/Unit/TestCase.php (Standard: alle Fähigkeiten freigegeben)
  • Test: tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php (neu geschrieben), DiviLockedTest.php (neu), ReleaseGateTest.php, ReleaseStatusTest.php (erweitert), tests/Unit/Integrations/Divi/Pages/SaveScopeTest.php, tests/Unit/Integrations/Divi/Design/PresetAreaTest.php, tests/Unit/Integrations/Divi/Design/DesignChangeTest.php (erweitert)

Interfaces:

  • Consumes: Task 6 (CompatibilityMatrix, FeatureStatus::summary(), ReleaseStatus::$matrix/of()), Task 4 (DiviFeature), Task 1 (ToolRefusal, das DiviLocked schon implementiert), Errors::make().
  • Produces: ReleaseGate::feature(DiviFeature): FeatureStatus, ReleaseGate::assertFeature(DiviFeature, ?int = null), ReleaseGate::assertFeatures(list<DiviFeature>, ?int = null), ReleaseGate::assertWritable(?int = null) (= alle Schreib-Fähigkeiten), DiviLocked::__construct(ReleaseStatus $status, array $features = []) mit public readonly array $features, ReleaseStatus::refusalFor(list<DiviFeature>): WP_Error, SaveScope::features(): list<DiviFeature>, DesignArea::feature(): DiviFeature.

Aufrufstellen (im Code geprüft, Stand e7793fd – Zeilennummern können sich durch Tasks 1–6 um wenige Zeilen verschieben, die Methoden sind eindeutig):

Datei:Zeile Methode Nachher
Pages/DiviSaver.php:79 save() assertFeatures($scope->features(), $postId)
Pages/DiviSaver.php:149 saveNew() assertFeatures($scope->features(), $postId)
Pages/DiviPages.php:61 setFlags() assertFeature(DiviFeature::PageWrite, $postId)
Library/DiviLibraryStore.php:220 create() assertFeature(DiviFeature::LibraryWrite)
ThemeBuilder/DiviThemeBuilder.php:137 liveId(true) assertFeature(DiviFeature::ThemeBuilderWrite)
…:248 insertLayout(), :274 storeTemplate(), :308 attach(), :328 detach(), :343 setConditions(), :360 setEnabled(), :375 unmarkUnused() assertFeature(DiviFeature::ThemeBuilderWrite)
Design/DesignSystemWriter.php:90 savePresets() assertFeature(DiviFeature::PresetWrite)
Design/DesignChange.php:162 apply() assertFeature($area->feature())

DiviPages::setFlags() prüft nur PageWrite, auch wenn DiviSaver es für ein Theme-Builder-Layout ruft: dort hat save()/saveNew() vorher [PageWrite, ThemeBuilderWrite] geprüft (gleiche Post-ID, gleiche Probe-Ausnahme).

  • Step 1: Tests schreiben (RED)

tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php vollständig ersetzen (je Methode: gesperrt genau dann, wenn eine ihrer Fähigkeiten gesperrt ist – untested und incompatible; nur andere Fähigkeiten gesperrt → sie kommt am Tor vorbei; DesignChange prüft DesignChangeTest):

<?php

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use Throwable;
use WP_Post;

/**
 * Spec §5.5, D5: every writing adapter method refuses before its first write exactly when a feature it needs is
 * locked – and passes the gate when only other features are locked. (DesignChange: DesignChangeTest.)
 */
final class ReleaseGuardsTest extends TestCase
{
    private const WRITES = ['wp_update_post', 'wp_insert_post', 'update_post_meta', 'add_post_meta', 'delete_post_meta',
        'update_option', 'delete_option', 'wp_set_object_terms'];

    protected function setUp(): void
    {
        parent::setUp();
        // Nothing may be written while locked: every write function WordPress offers fails the test.
        foreach (self::WRITES as $write) {
            Functions\when($write)->alias(static function () use ($write): never {
                self::fail($write . ' ran although Divi is locked');
            });
        }
        Functions\when('current_user_can')->justReturn(true);
    }

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

        yield 'save' => [static fn () => (new DiviSaver())->save($post, '<!-- wp:divi/section /-->'), $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, '<!-- wp:divi/section /-->'), $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<DiviFeature> $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<DiviFeature> $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

declare(strict_types=1);

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

use LIT\MCP\Abilities\ToolRefusal;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;

final class DiviLockedTest extends TestCase
{
    public function testWithFeaturesTheErrorIsTheRefusalOfThoseFeatures(): void
    {
        $status = ReleaseMatrices::status([DiviFeature::PresetWrite], ReleaseState::Incompatible);
        $locked = new DiviLocked($status, [DiviFeature::PresetWrite]);

        self::assertInstanceOf(ToolRefusal::class, $locked);
        self::assertSame('Divi 5.3.0 is incompatible for LIT MCP – preset-write refused', $locked->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

declare(strict_types=1);

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

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

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

    public function 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:

    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():

    /** 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:

        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():

            public function feature(): DiviFeature
            {
                return DiviFeature::PresetWrite;
            }

und vor testTooLargeSnapshotRefusesTheChange():

    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:

    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:

    /**
     * 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<DiviFeature> $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:

    /**
     * "„Seiten schreiben“", "„A“ und „B“", "„A“, „B“ und „C“".
     *
     * @param non-empty-list<FeatureStatus> $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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use LIT\MCP\Abilities\ToolRefusal;
use RuntimeException;
use Throwable;
use WP_Error;

defined('ABSPATH') || exit;

/**
 * A Divi call refused because a feature it needs is not released (spec §5.5, D6); AbilityRegistrar returns
 * toError(). Without features it is the general write lock of the overall status (texts of spec §5.6).
 */
final class DiviLocked extends RuntimeException implements ToolRefusal
{
    /** @param list<DiviFeature> $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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

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

defined('ABSPATH') || exit;

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

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

    private static ?int $probe = null;

    private const UNRESOLVED = 'Der Freigabe-Zustand ließ sich nicht ermitteln (Details im PHP-Error-Log).';

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

        try {
            self::$status = (self::$resolver ?? static fn (): ReleaseStatus => (new ReleaseResolver())->resolve())();
        } catch (Throwable $e) {
            Log::failure('divi release status', $e);
            self::$status = 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<DiviFeature> $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:

use LIT\MCP\Integrations\Divi\Release\DiviFeature;
    /** @return list<DiviFeature> 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):

     * @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);
     * @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):

     * @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);
     * @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):

     * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
     */
    public static function setFlags(int $postId): void
    {
        ReleaseGate::assertWritable($postId);
     * @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():

     * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
     */
    public function create(LibraryItemSpec $spec, NewDraft $draft): int
    {
        ReleaseGate::assertWritable();
     * @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):

            ReleaseGate::assertWritable();

→

            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():

     * @throws DiviLocked
     * @throws RuntimeException When Divi refuses the inventory.
     */
    public function savePresets(array $rest, bool $mayRemove): array
    {
        ReleaseGate::assertWritable();
     * @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():

    /** 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():

    public function feature(): DiviFeature
    {
        return DiviFeature::PresetWrite;
    }

src/Integrations/Divi/Design/DesignChange.php, apply() (Vorher → Nachher):

        ReleaseGate::assertWritable();
        try {
            $snapshotId = $this->snapshots->save($area->name(), $current, $ability);
        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):

        // 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'));
        // 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
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 <Modellname> <noreply@anthropic.com>"

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<AbilityGuard>, 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<string>, 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<string>, 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<DiviFeature> ...$lists): list<DiviFeature>, ofEntry(mixed $postId, bool $write): list<DiviFeature> (was ein Ziel-Eintrag dazu fordert) und declaredBy(Ability $ability, array $input): list<DiviFeature> (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:

// src/Integrations/Divi/Release/RequiresDiviFeatures.php  (Signatur des Gerüsts)
interface RequiresDiviFeatures { /** @return list<DiviFeature> */ public function diviFeatures(array $input): array; }

// src/Abilities/DeclaresRequirements.php  (neu, Kern, ohne Divi-Import)
interface DeclaresRequirements { /** @return list<string> */ public function requirements(array $input): array; }

// src/Integrations/WordPress/Content/ContentComposer.php  (neue Port-Methode)
/** @return list<string> 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<DiviFeature>, jede einmal
    public static function ofEntry(mixed $postId, bool $write): array;          // list<DiviFeature>
    public static function declaredBy(Ability $ability, array $input): array;   // list<DiviFeature>
}

// src/Integrations/Divi/Pages/DiviWriteAbility.php
abstract protected function baseFeatures(): array;                               // list<DiviFeature>
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
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

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\WordPressIntegration;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

/**
 * The table of the Divi capabilities every tool of the groups divi, divi-library and divi-theme-builder and
 * lit/content-create declare (plan "Divi-Freigabe je Fähigkeit", Task 8). The table is the contract: a tool that
 * needs more or less than listed here changes it here, on purpose.
 */
final class DiviToolFeaturesTest extends TestCase
{
    private const PAGE = 5;
    private const LIBRARY_ENTRY = 7;
    private const LAYOUT = 9;

    /** Post ID => 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<string, array{0: string, 1: array<string, mixed>, 2: list<string>}>
     */
    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<string, Ability> 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<DiviFeature> $features
     * @return list<string> 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<string> $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] ?? ['<no row>']),
                self::values(FeatureSets::declaredBy($ability, [])),
                $name
            );
        }
    }

    /**
     * @param array<string, mixed> $input
     * @param list<string>         $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

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class FeatureSetsTest extends TestCase
{
    /** @var array<int, string> */
    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 --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
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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

defined('ABSPATH') || exit;

/**
 * A tool that says which Divi capabilities a call needs. DiviFeatureGate refuses the call before it runs while one of
 * them is not usable; the adapters still check each write themselves (ReleaseGate::assertFeature()).
 */
interface RequiresDiviFeatures
{
    /**
     * The capabilities this call needs for $input, the upper bound of what the call may reach: a capability an
     * adapter checks later must be in it, or the tool would stop in the middle of its work instead of before.
     * [] = none (a tool that never touches Divi).
     *
     * @param array<string, mixed> $input
     * @return list<DiviFeature>
     */
    public function diviFeatures(array $input): array;
}

src/Abilities/DeclaresRequirements.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Abilities;

defined('ABSPATH') || exit;

/**
 * A tool of the generic core that can reach another integration (lit/content-create with divi) and names what that
 * call needs from it – as plain IDs, so the core imports nothing from the integration. The guard of the integration
 * (DiviFeatureGate) reads the IDs; other guards ignore them.
 */
interface DeclaresRequirements
{
    /**
     * @param array<string, mixed> $input
     * @return list<string> 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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\WordPress\PostGuard;

defined('ABSPATH') || exit;

/** Helpers of RequiresDiviFeatures::diviFeatures(): joining lists and the capabilities an entry adds. */
final class FeatureSets
{
    /**
     * The capabilities $ability declares for $input: its own diviFeatures() (the Divi tools) or the IDs a core tool
     * names through DeclaresRequirements (lit/content-create with divi). Anything else declares none; an ID that is no
     * DiviFeature is ignored (the core may name capabilities of other integrations).
     *
     * @param array<string, mixed> $input
     * @return list<DiviFeature>
     */
    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<DiviFeature> ...$lists
     * @return list<DiviFeature> 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<DiviFeature>
     */
    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 --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<string>
+     */
+    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 --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<DiviFeature>
+     */
+    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:
    /**
     * 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:
    /**
     * 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:
    /**
     * 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:
    /** 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:
    /**
     * 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:
    /** 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:
    /** 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:
    /**
     * 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:
    /**
     * 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:
    /**
     * 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:
    /** 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:
    /** 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:
    /** 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:
    /**
     * 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:
    /** 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:
    /** 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:
    /** 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:
    /**
     * 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:
    /**
     * 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:
    /**
     * 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:
    /**
     * 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:
    /**
     * 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:
    /**
     * 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:
    /**
     * 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:
    /** 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:
    /**
     * 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:
    /** 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:
    /** 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
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
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 <Modellname> <noreply@anthropic.com>"

(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:

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<DiviFeature> $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

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

declare(strict_types=1);

namespace LIT\MCP\Tests\Support;

use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;

/** A release status in which single Divi capabilities are locked – for guard and tool tests. */
final class DiviFeatureFixtures
{
    /**
     * Makes ReleaseGate answer with $states; every capability not named is released. TestCase resets the gate.
     *
     * @param array<string, ReleaseState> $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

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Support\DiviFeatureFixtures;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;

final class DiviFeatureGateTest extends TestCase
{
    /**
     * @param list<DiviFeature> $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 --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<DiviFeature>|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 --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:

git rm tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php
  • Step 3: RED
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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityGuard;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;

defined('ABSPATH') || exit;

/**
 * The central release lock of the Divi tools (spec §5.5), per capability: a call is refused before it runs while one
 * of the capabilities it declares for its input is not usable – reading and writing tools alike, so a reading tool
 * answers divi_incompatible / divi_untested with the affected capability instead of failing inside Divi. A refused
 * write is logged with result "refused" (as before); a refused read changes nothing and is not. A tool that declares
 * nothing (RequiresDiviFeatures, or DeclaresRequirements for lit/content-create without divi) is left alone; the
 * adapters still check each write themselves.
 */
final class DiviFeatureGate implements AbilityGuard
{
    public function __construct(private ?ChangeLog $log = null)
    {
    }

    /**
     * The lock for the usable-ness of $features, or null when every one is usable. Public: the Divi short form of
     * lit/content-create asks the same question for its own capabilities (DiviContentComposer::refusal()).
     *
     * @param list<DiviFeature> $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<string, mixed>|null $schema
     * @return array<string, mixed>|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()):

git rm src/Integrations/Divi/Release/DiviToolGate.php
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
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
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 <Modellname> <noreply@anthropic.com>"

(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 <!-- TASKS --> 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<Ability>), static withoutTools(): self, toolsByFeature(): array<string, list<string>>, rows(CompatibilityMatrix): list<array{id, label, state, source, problems, tools}>. 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<Ability>, 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<array{id: string, label: string, state: string, source: ?string, problems: list<string>}> 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' => [<Fähigkeits-ID> => list<string>]] (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:

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

declare(strict_types=1);

namespace LIT\MCP\Tests\Support;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;

/** A tool that needs the given Divi features – for the feature overview, the tab and site status. */
final class FeatureFakeTool implements Ability, RequiresDiviFeatures
{
    /** @param list<\LIT\MCP\Integrations\Divi\Release\DiviFeature> $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

declare(strict_types=1);

namespace LIT\MCP\Tests\Support;

use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;

/** Compatibility matrices for tests: every feature released, with single features replaced. */
final class MatrixFixtures
{
    /**
     * @param list<FeatureStatus> $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

declare(strict_types=1);

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

use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Tests\Support\FeatureFakeTool;
use LIT\MCP\Tests\Support\MatrixFixtures;
use PHPUnit\Framework\TestCase;

final class FeatureOverviewTest extends TestCase
{
    private static function overview(): FeatureOverview
    {
        return new FeatureOverview(static fn (): array => [
            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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Release;

use Closure;
use LIT\MCP\Abilities\Ability;

defined('ABSPATH') || exit;

/**
 * The per-feature view shared by lit-site-status and the admin tab (E3): each row of the compatibility matrix plus
 * the tools that need the feature. Reads the matrix the release gate already holds (cached, D7) – nothing is
 * checked again here.
 */
final class FeatureOverview
{
    /** @param Closure(): list<Ability> $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-<name>, indirect ones lit/<name>. A tool that needs a feature only for certain input (content-create
     * with divi) is not listed – its refusal names the feature.
     *
     * @return array<string, list<string>> 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<array{id: string, label: string, state: string, source: ?string, problems: list<string>,
     *     tools: list<string>}>
     */
    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

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\DiviCompatibility;
use LIT\MCP\Integrations\Divi\DiviEnvironmentComponent;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\FeatureFakeTool;
use LIT\MCP\Tests\Support\MatrixFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class DiviEnvironmentComponentTest extends TestCase
{
    private function diviInstalled(?string $version): void
    {
        $theme = Mockery::mock();
        $theme->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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi;

use LIT\MCP\Integrations\Core\EnvironmentComponent;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;

defined('ABSPATH') || exit;

/**
 * Divi in lit-site-status: version, missing internals (DiviCompatibility), the release status and – per feature –
 * state, source, problems and the tools it affects (E3). The features come from the matrix the release gate
 * already holds (cached, D7), so a site-status call never recomputes the mirror fingerprints.
 */
final class DiviEnvironmentComponent implements EnvironmentComponent
{
    private FeatureOverview $overview;

    public function __construct(private ?DiviCompatibility $divi = null, ?FeatureOverview $overview = null)
    {
        $this->overview = $overview ?? FeatureOverview::withoutTools();
    }

    /**
     * @return array{id: string, active: bool, version: ?string, problems: list<string>, release: ?array<string, mixed>,
     *     features: list<array{id: string, label: string, state: string, source: ?string, problems: list<string>,
     *     tools: list<string>}>}
     */
    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 --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 --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<string, mixed> */
+    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 --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 --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<Ability>)|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 --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() <geändert>'],
+                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('<th scope="col">Betroffene Werkzeuge</th>', $html);
+        self::assertStringContainsString('<td>Seiten schreiben</td><td><strong>inkompatibel</strong></td><td>–</td>', $html);
+        self::assertStringContainsString('<td>Modulkatalog lesen</td><td><strong>ungetestet</strong></td>', $html);
+        self::assertStringContainsString('<td>Seiten lesen</td><td><strong>freigegeben</strong></td><td>Automatik (auto)</td>', $html);
+        self::assertStringContainsString('<li><code>lit-divi-insert</code></li>', $html);
+        self::assertStringContainsString('<li><code>lit/divi-presets-assign</code></li>', $html);
+    }
+
+    public function testFeatureProblemsAreEscaped(): void
+    {
+        $html = $this->rendered(self::mixedStatus(), null);
+
+        self::assertStringContainsString('Seiten: Signatur von et_pb_x() &lt;geändert&gt;', $html);
+        self::assertStringNotContainsString('<geändert>', $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('<code>lit_mcp_divi_auto_release</code>', $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 '<th scope="col">Betroffene Werkzeuge</th>'.

Ä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 <code>-Element, nicht aus Eingaben):

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 <Version> freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (only
- * for a database release). Opening the tab removes check pages a dead check left behind.
+ * 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 <Version>
+ * freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (only for a database release). Opening
+ * the tab removes check pages a dead check left behind. 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(
+            '<div class="notice notice-warning"><p>%s <a href="%s">%s</a></p></div>',
+            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 '<p>' . 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'
         ) . '</p>';
@@ -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(
             '<tr><th scope="row">%s</th><td><strong>%s</strong> – %s</td></tr>',
-            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 '<h3>' . esc_html__('Fähigkeiten', 'wp-lit-mcp') . '</h3>';
+        echo '<table class="widefat striped lit-mcp-divi-features"><thead><tr>';
+        $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 '<th scope="col">' . esc_html($heading) . '</th>';
+        }
+        echo '</tr></thead><tbody>';
+        foreach (($this->overview ?? FeatureOverview::withoutTools())->rows($status->matrix) as $row) {
+            echo '<tr><td>' . esc_html($row['label']) . '</td>';
+            echo '<td><strong>' . esc_html($this->stateLabel(ReleaseState::from($row['state']))) . '</strong></td>';
+            echo '<td>' . esc_html($this->sourceLabel($row['source'])) . '</td>';
+            echo '<td>';
+            $this->renderList($row['problems'], false);
+            echo '</td><td>';
+            $this->renderList($row['tools'], true);
+            echo '</td></tr>';
+        }
+        echo '</tbody></table>';
+        $this->renderAutomationNote();
+    }
+
+    /** @param list<string> $items */
+    private function renderList(array $items, bool $code): void
+    {
+        if ($items === []) {
+            echo '–';
+
+            return;
+        }
+
+        echo '<ul>';
+        foreach ($items as $item) {
+            echo $code ? '<li><code>' . esc_html($item) . '</code></li>' : '<li>' . esc_html($item) . '</li>';
+        }
+        echo '</ul>';
+    }
+
+    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 '<p class="description">' . 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'
+        ) . '</p>';
+        echo '<p class="description">' . 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'
+            ),
+            '<code>lit_mcp_divi_auto_release</code>'
+        ) . '</p>';
+    }
+
     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 --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 --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:

     * Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format
     * und Schreibtest auf einer Wegwerfseite. Gibt nichts frei.

Nachher:

     * 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):

// 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']);
// 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):

    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:

    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:

    /** 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):

        $divi = new DiviIntegration(
            $schema,
            $changeLog,
            $libraryWritable,
            libraryVisible: $libraryVisible,
            diviWritable: $diviWritable,
            libraryInsertable: $libraryInsertable,
            themeBuilderWritable: $themeBuilderWritable,
            themeBuilderVisible: $themeBuilderVisible
        );

Nachher:

        // 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:

        $tabs = [
            new ConnectionTab($status),
            new AccessTab($access, new ConnectionRepository()),
            new ToolsTab($registry, $policy),
            new LogTab($changeLog, $limits, $registry),
            new LimitsTab(),
            new DiviTab(log: $changeLog),
        ];
        if (is_admin()) {
            self::$settingsPage->register();
            (new AdminNotices($status))->register();
        }

Nachher:

        $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,
        ];
        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:

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 <Modellname> <noreply@anthropic.com>"

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 <Modellname> <noreply@anthropic.com>"

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/<name>, direkte lit-<name>; 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

declare(strict_types=1);

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

use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

/**
 * With a lock per feature (E1–E3) no text may say that "writing" or "Divi" as a whole is locked: only the tools of
 * the affected features refuse, every other Divi tool keeps working.
 */
final class FeatureTextsTest extends TestCase
{
    private const OLD_PHRASES = [
        'Schreibende Divi-Werkzeuge sind gesperrt',
        'Schreiben ist gesperrt',
        'Schreiben bleibt gesperrt',
        'lesende Divi-Werkzeuge funktionieren weiter',
        'Divi-Werkzeuge lesen nur',
        'bis dahin lesen',
    ];

    /** @return array<string, string> 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 --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 --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 --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):

#!/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<string>]] (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-<name>, indirect lit/<name>): $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\":\"<p>x</p>\"}]}]}")
[ "$(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":"<p>x</p>"}]}]}')
[ "$(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\":\"<p>x</p>\"}]}]}")
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 -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 -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:

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 <Modellname> <noreply@anthropic.com>"

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 <Modellname> <noreply@anthropic.com>"

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' => [<id> => list<string>]] 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.