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

14071 lines
657 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```php
// Task 1 – src/Abilities
interface AbilityGuard
{
/** Refusal before execute() runs, or null. */
public function refusal(Ability $ability, array $input): ?WP_Error;
/** The result, possibly with added warnings. */
public function annotate(Ability $ability, mixed $result): mixed;
/** The output schema, possibly extended (e.g. warnings). */
public function outputSchema(Ability $ability, ?array $schema): ?array;
}
interface ToolRefusal // implemented by exceptions
{
public function toError(): WP_Error;
}
// AbilityRegistrar::__construct(IntegrationRegistry $registry, ?ExposurePolicy $policy = null, array $guards = [])
// $guards: list<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):
```diff
--- a/tests/Unit/Abilities/AbilityRegistrarTest.php
+++ b/tests/Unit/Abilities/AbilityRegistrarTest.php
@@ -6,8 +6,11 @@ namespace LIT\MCP\Tests\Unit\Abilities;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
+use LIT\MCP\Abilities\AbilityGuard;
use LIT\MCP\Abilities\AbilityRegistrar;
+use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ExposurePolicy;
+use LIT\MCP\Abilities\ToolRefusal;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
@@ -20,6 +23,7 @@ use LIT\MCP\Server\RequestContext;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
+use Throwable;
use WP_Error;
final class AbilityRegistrarTest extends TestCase
@@ -56,7 +60,7 @@ final class AbilityRegistrarTest extends TestCase
$integration->allows('abilities')->andReturn([$ability]);
$registry = new IntegrationRegistry($integration);
- return new AbilityRegistrar($registry, null, new DiviToolGate($registry->groupMap(...)));
+ return new AbilityRegistrar($registry, null, [new DiviToolGate($registry->groupMap(...))]);
}
private function ability(bool $readonly = true): Mockery\MockInterface
@@ -247,6 +251,147 @@ final class AbilityRegistrarTest extends TestCase
self::assertToolError($this->executeLogged($ability), 'partial_write');
}
+ /** @param list<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:
```diff
--- a/tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php
+++ b/tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php
@@ -47,7 +47,7 @@ final class DiviToolGateTest extends TestCase
{
$gate = $this->gate(FakeChangeLog::forbidden());
- self::assertNull($gate->refusal($this->ability('lit/divi-insert', false)));
+ self::assertNull($gate->refusal($this->ability('lit/divi-insert', false), []));
self::assertSame(['ok' => true], $gate->annotate($this->ability('lit/divi-outline', true), ['ok' => true]));
}
@@ -58,10 +58,10 @@ final class DiviToolGateTest extends TestCase
$gate = $this->gate($log);
foreach (['lit/divi-insert', 'lit/library-save', 'lit/tb-create-template'] as $name) {
- self::assertToolError($gate->refusal($this->ability($name, false)), 'divi_untested', $name);
+ self::assertToolError($gate->refusal($this->ability($name, false), []), 'divi_untested', $name);
}
- self::assertNull($gate->refusal($this->ability('lit/content-create', false)), 'not a Divi tool (adapter lock)');
- self::assertNull($gate->refusal($this->ability('lit/divi-outline', true)), 'reading stays allowed');
+ self::assertNull($gate->refusal($this->ability('lit/content-create', false), []), 'not a Divi tool (adapter lock)');
+ self::assertNull($gate->refusal($this->ability('lit/divi-outline', true), []), 'reading stays allowed');
self::assertSame(['refused', 'refused', 'refused'], array_column($log->records, 'result'));
self::assertSame('Abgelehnt: Divi 5.3.0 ist nicht freigegeben.', $log->records[0]['summary']);
}
```
- [ ] **Step 2: RED**
Run: `composer test -- --filter 'AbilityRegistrarTest|DiviToolGateTest'`
Expected: FAIL, 8 Fehler in `AbilityRegistrarTest`: `testUntestedDiviRefusesAWritingDiviToolBeforeItRuns` und `testDiviReadToolsGetWarningsInTheirSchema` mit `TypeError: LIT\MCP\Abilities\AbilityRegistrar::__construct(): Argument #3 ($divi) must be of type ?LIT\MCP\Integrations\Divi\Release\DiviToolGate, array given`; die sechs neuen Guard-/Refusal-Tests mit `Error: Interface "LIT\MCP\Abilities\AbilityGuard" not found` bzw. `Error: Interface "LIT\MCP\Abilities\ToolRefusal" not found`. `DiviToolGateTest` bleibt grün (PHP nimmt überzählige Argumente an).
- [ ] **Step 3: Die beiden Schnittstellen anlegen**
`src/Abilities/AbilityGuard.php`:
```php
<?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
<?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()`:
```diff
--- 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):
```diff
--- a/src/Integrations/Divi/Release/DiviToolGate.php
+++ b/src/Integrations/Divi/Release/DiviToolGate.php
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Abilities\Ability;
+use LIT\MCP\Abilities\AbilityGuard;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
@@ -17,9 +18,9 @@ defined('ABSPATH') || exit;
* The central release lock of the Divi tools (spec §5.5): a writing tool of the groups Divi, Divi-Bibliothek or Divi
* Theme Builder is refused before it runs while Divi is not released (logged with result "refused"); a reading tool
* runs and gets the notice in warnings. Other tools that reach Divi (lit/content-create with divi) meet the adapter
- * lock (ReleaseGate::assertWritable()).
+ * lock (ReleaseGate::assertWritable()). The AbilityRegistrar receives it as an AbilityGuard from Plugin.
*/
-final class DiviToolGate
+final class DiviToolGate implements AbilityGuard
{
public const GROUPS = ['divi', LibraryAccess::GROUP, ThemeBuilderAccess::GROUP];
@@ -39,7 +40,7 @@ final class DiviToolGate
return in_array(($this->groups)()[$ability->name()] ?? '', self::GROUPS, true);
}
- public function refusal(Ability $ability): ?WP_Error
+ public function refusal(Ability $ability, array $input): ?WP_Error
{
if (! $this->covers($ability) || self::readsOnly($ability)) {
return null;
```
`src/Integrations/Divi/Release/DiviLocked.php`:
```diff
--- a/src/Integrations/Divi/Release/DiviLocked.php
+++ b/src/Integrations/Divi/Release/DiviLocked.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
+use LIT\MCP\Abilities\ToolRefusal;
use RuntimeException;
use Throwable;
use WP_Error;
@@ -11,7 +12,7 @@ use WP_Error;
defined('ABSPATH') || exit;
/** A write refused because the installed Divi is not released (spec §5.5); AbilityRegistrar returns toError(). */
-final class DiviLocked extends RuntimeException
+final class DiviLocked extends RuntimeException implements ToolRefusal
{
public function __construct(public readonly ReleaseStatus $status)
{
```
`src/Plugin.php` (Composition Root: dort steht Divi weiter, im Registrar nicht mehr):
```diff
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -144,7 +144,11 @@ final class Plugin
DiviCheckCommand::register($diviCheck);
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
- (new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register();
+ (new AbilityRegistrar(
+ $registry,
+ $policy,
+ [new DiviToolGate($registry->groupMap(...), $changeLog)]
+ ))->register();
(new ServerRegistrar(
$registry,
new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames(), $policy),
```
- [ ] **Step 6: GREEN**
Run: `composer test -- --filter 'AbilityRegistrarTest|DiviToolGateTest'`
Expected: OK (28 Tests).
- [ ] **Step 7: Gesamtlauf und Lint**
Run: `composer test`
Expected: OK, 2631 Tests (2624 + 7 neue).
Run: `composer lint`
Expected: keine Ausgabe außer dem Fortschritt, Exit 0.
- [ ] **Step 8: Commit**
```bash
git add src/Abilities/AbilityGuard.php src/Abilities/ToolRefusal.php src/Abilities/AbilityRegistrar.php \
src/Integrations/Divi/Release/DiviToolGate.php src/Integrations/Divi/Release/DiviLocked.php src/Plugin.php \
tests/Unit/Abilities/AbilityRegistrarTest.php tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php
git commit -m "refactor: AbilityRegistrar nimmt AbilityGuard-Liste und ToolRefusal statt DiviToolGate/DiviLocked; DiviToolGate implementiert AbilityGuard, DiviLocked ToolRefusal; Test mit Fake-Guard und Fake-Refusal ohne Divi" -m "Co-Authored-By: Claude <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
<?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
<?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
<?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
<?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
<?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
<?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:
```diff
--- 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'));
+ }
}
```
```diff
--- 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();
```
```diff
--- 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
```
```diff
--- 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'
+ );
}
}
```
```diff
--- a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php
+++ b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php
@@ -13,6 +13,7 @@ use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility;
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
+use LIT\MCP\Tests\Support\FakeBuilderDetector;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\SchemaFixture;
use LIT\MCP\Tests\Unit\TestCase;
@@ -46,9 +47,12 @@ final class ContentUpdateAbilityTest extends TestCase
return $schema;
}
- private function ability(PostWriter $writer, ?ChangeLog $log = null): ContentUpdateAbility
- {
- return new ContentUpdateAbility($this->schema(), $writer, $log ?? FakeChangeLog::forbidden());
+ private function ability(
+ PostWriter $writer,
+ ?ChangeLog $log = null,
+ ?PostPresenter $presenter = null
+ ): ContentUpdateAbility {
+ return new ContentUpdateAbility($this->schema(), $writer, $log ?? FakeChangeLog::forbidden(), $presenter);
}
public function testRevIsRequired(): void
@@ -110,11 +114,11 @@ final class ContentUpdateAbilityTest extends TestCase
public function testRawContentOnDiviPagesIsUnsupported(): void
{
- Functions\when('get_post_meta')->justReturn('on');
$writer = Mockery::mock(PostWriter::class);
$writer->expects('prepare')->never();
+ $presenter = new PostPresenter($this->schema(), new FakeBuilderDetector(false, [5]));
- $result = $this->ability($writer)
+ $result = $this->ability($writer, null, $presenter)
->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'content' => '<p>Neu</p>']);
self::assertToolError($result, 'unsupported');
```
```diff
--- a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php
+++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php
@@ -8,6 +8,7 @@ use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
+use LIT\MCP\Tests\Support\FakeBuilderDetector;
use LIT\MCP\Tests\Support\SchemaFixture;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
@@ -24,11 +25,11 @@ final class RevisionsListAbilityTest extends TestCase
Functions\when('get_post_meta')->justReturn('');
}
- private function ability(): RevisionsListAbility
+ private function ability(?PostPresenter $presenter = null): RevisionsListAbility
{
$schema = SchemaFixture::managed();
- return new RevisionsListAbility($schema);
+ return new RevisionsListAbility($schema, $presenter);
}
public function testListsRevisionsNewestFirstWithCursor(): void
@@ -60,11 +61,14 @@ final class RevisionsListAbilityTest extends TestCase
811 => new WP_Post(['ID' => 811, 'post_content' => '<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
```
```diff
--- a/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php
+++ b/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php
@@ -37,7 +37,7 @@ final class ContentSchemaTest extends TestCase
{
$this->stubPostTypes();
- $schema = new ContentSchema([]);
+ $schema = new ContentSchema([], null, ['et_pb_layout']);
self::assertSame(['lit_person', 'page', 'post'], $schema->postTypeNames());
self::assertTrue($schema->isManaged('page'));
@@ -80,7 +80,8 @@ final class ContentSchemaTest extends TestCase
new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable'),
new FieldDefinition('zz_hidden', 'Verborgen', 'string', 'pods', FieldDefinition::HIDDEN),
])],
- static fn (string $type): bool => $type === 'page'
+ static fn (string $type): bool => $type === 'page',
+ ['et_pb_layout']
);
$withoutFields = $schema->describe('page', false);
@@ -118,17 +119,34 @@ final class ContentSchemaTest extends TestCase
self::assertSame(['logo', 'photo'], (new ContentSchema([$source]))->mediaFieldKeys());
}
- /** Spec §3: Divi's post types come from the Katalog adapter – the list itself stays the same. */
- public function testExcludedPostTypesStayTheSame(): void
+ public function testWordPressExcludesItsOwnPostTypesAndIntegrationsAddTheirs(): void
{
+ $this->stubPostTypes();
+
+ self::assertSame(
+ ['et_pb_layout', 'lit_person', 'page', 'post'],
+ (new ContentSchema([]))->postTypeNames(),
+ 'without an integration the Divi layouts are an ordinary post type'
+ );
+ self::assertSame(
+ ['page', 'post'],
+ (new ContentSchema([], null, ['et_pb_layout', 'lit_person']))->postTypeNames()
+ );
self::assertSame(
[
'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
- 'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'et_pb_layout',
- 'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout',
- 'et_pb_canvas', '_et_pb_speculation',
+ 'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face',
],
ContentSchema::EXCLUDED
);
}
+
+ public function testTheBuilderIsNeverOnWithoutACheck(): void
+ {
+ self::assertFalse((new ContentSchema([]))->diviEnabled('page'));
+ $schema = new ContentSchema([], static fn (string $type): bool => $type === 'page');
+
+ self::assertTrue($schema->diviEnabled('page'));
+ self::assertFalse($schema->diviEnabled('post'));
+ }
}
```
- [ ] **Step 4: RED**
Run: `composer test`
Expected: FAIL mit 26 Fehlern und 2 Failures (alle in den oben genannten Tests): `Error: Interface "LIT\MCP\Integrations\WordPress\BuilderDetector" not found` (PostPresenter-, ContentUpdate-, RevisionsList-Tests), `Error: Interface "LIT\MCP\Integrations\Core\EnvironmentComponent" not found` (Probe-/CoreIntegration-Tests), `Error: Class "LIT\MCP\Integrations\Divi\DiviLogoUsage" not found` (und `DiviBuilderDetector`, `DiviEnvironmentComponent`), `Error: Call to undefined method LIT\MCP\Integrations\Divi\DiviIntegration::builderDetector()` (und `internalPostTypes()`, `builderEnabledFor()`), `Error: Call to private method LIT\MCP\Integrations\Media\MediaUsageFinder::siteUsage()`; Failures: `CoreIndependenceTest::testTheCoreUsesNoDiviClass` und `ContentSchemaTest::testWordPressExcludesItsOwnPostTypesAndIntegrationsAddTheirs` (`et_pb_layout` wird ohne Divi-Liste noch ausgeschlossen).
- [ ] **Step 5: Ports und Divi-Implementierungen anlegen**
`src/Integrations/WordPress/BuilderDetector.php`:
```php
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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):
```diff
--- a/src/Integrations/WordPress/Content/PostPresenter.php
+++ b/src/Integrations/WordPress/Content/PostPresenter.php
@@ -6,8 +6,8 @@ namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Abilities\Rev;
-use LIT\MCP\Integrations\Divi\Pages\DiviPages;
-use LIT\MCP\Integrations\Divi\Schema\DiviModules;
+use LIT\MCP\Integrations\WordPress\BuilderDetector;
+use LIT\MCP\Integrations\WordPress\NoBuilderDetector;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use WP_Post;
@@ -18,12 +18,9 @@ defined('ABSPATH') || exit;
* Output format of an entry for the content tools. Not final so abilities can be
* unit-tested with a mock.
*
- * Layering exception: diviNote()/diviHint() call DiviModules::isAvailable() (Divi
- * integration) to decide whether the Divi tools exist – the WordPress integration
- * otherwise never depends on Divi. Kept because the alternative (injecting
- * availability through every constructor that reaches PostPresenter) would spread the
- * same one-line check across content-get, content-update and revisions-list for no
- * behavioral gain; $toolsAvailable can still be passed in explicitly for tests.
+ * The builder (Divi) is known only through the BuilderDetector port: usesDivi() asks it for the builder flag
+ * of a post, diviNote()/diviHint() for whether the builder's tools exist; $toolsAvailable can still be passed in
+ * explicitly. Without a detector no builder is assumed (NoBuilderDetector).
*/
class PostPresenter
{
@@ -36,21 +33,24 @@ class PostPresenter
public const DIVI_INACTIVE_NOTE = 'Der Divi-5-Builder ist auf dieser Website nicht aktiv, daher gibt es keine '
. 'Divi-Werkzeuge; Divi-Inhalte nur im Divi-Builder bearbeiten.';
- /** $toolsAvailable defaults to whether the Divi integration is available (Divi 5 loaded). */
- public static function diviNote(?bool $toolsAvailable = null): string
+ private BuilderDetector $builders;
+
+ public function __construct(private ContentSchema $schema, ?BuilderDetector $builders = null)
{
- return ($toolsAvailable ?? DiviModules::isAvailable()) ? self::DIVI_NOTE : self::DIVI_INACTIVE_NOTE;
+ $this->builders = $builders ?? new NoBuilderDetector();
}
- public static function diviHint(?bool $toolsAvailable = null): string
+ /** $toolsAvailable defaults to whether the builder is available (Divi 5 loaded). */
+ public function diviNote(?bool $toolsAvailable = null): string
{
- return 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. '
- . self::diviNote($toolsAvailable) . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update '
- . '(direkt: lit-content-update) änderbar.';
+ return ($toolsAvailable ?? $this->builders->isAvailable()) ? self::DIVI_NOTE : self::DIVI_INACTIVE_NOTE;
}
- public function __construct(private ContentSchema $schema)
+ public function diviHint(?bool $toolsAvailable = null): string
{
+ return 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. '
+ . $this->diviNote($toolsAvailable) . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update '
+ . '(direkt: lit-content-update) änderbar.';
}
/** @return array<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):
```diff
--- 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.'
);
}
```
```diff
--- 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),
];
}
}
```
```diff
--- 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):
```diff
--- 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`):
```diff
--- 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];
}
```
```diff
--- 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`:
```diff
--- 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'),
```
```diff
--- 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):
```diff
--- 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:
```diff
--- 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:
```diff
--- 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**
```bash
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:
```php
use LIT\MCP\Integrations\Bootable;
```
```php
/** 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):
```php
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:
```php
/** 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:
```php
/** 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:
```php
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:
```php
Functions\when('add_query_arg')->alias(
static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
);
}
```
Nachher:
```php
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:
```php
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:
```php
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:
```php
ob_start();
$this->tab($report, Mockery::mock(ReleaseStore::class))->render();
```
Nachher:
```php
ob_start();
($tab ?? $this->tab($report, Mockery::mock(ReleaseStore::class)))->render();
```
Am Ende der Klasse anfügen:
```php
/** 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
<?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:
```php
/** @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:
```php
/** @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:
```php
/** @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:
```php
/**
* 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()`):
```php
foreach ($this->tabs as $tab) {
$tab->registerActions();
}
```
Nachher:
```php
foreach ($this->tabs() as $tab) {
$tab->registerActions();
}
```
Vorher (`registerSettings()`):
```php
foreach ($this->tabs as $tab) {
$tab->registerSettings();
}
```
Nachher:
```php
foreach ($this->tabs() as $tab) {
$tab->registerSettings();
}
```
Vorher (`tabSlugs()`):
```php
return array_keys($this->tabs);
```
Nachher:
```php
return array_keys($this->tabs());
```
Vorher (`currentTab()`):
```php
return isset($this->tabs[$requested]) ? $requested : (string) array_key_first($this->tabs);
```
Nachher:
```php
$tabs = $this->tabs();
return isset($tabs[$requested]) ? $requested : (string) array_key_first($tabs);
```
Vorher (`render()`):
```php
foreach ($this->tabs as $slug => $tab) {
```
Nachher:
```php
foreach ($this->tabs() as $slug => $tab) {
```
Vorher (`render()`):
```php
$this->tabs[$current]->render();
```
Nachher:
```php
$this->tabs()[$current]->render();
```
Vor `private function renderLogo(): void` einfügen:
```php
/** @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:
```php
* for a database release). Opening the tab removes check pages a dead check left behind.
*/
```
Nachher:
```php
* 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:
```php
public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw';
```
Nachher:
```php
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:
```php
add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
}
```
Nachher:
```php
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:
```php
$removed = WriteProbe::removeOrphans();
$status = ReleaseGate::status();
```
Nachher:
```php
$status = ReleaseGate::status();
```
Vorher:
```php
if ($removed !== []) {
```
Nachher:
```php
if ($this->removed !== []) {
```
Vorher:
```php
implode(', ', $removed)
```
Nachher:
```php
implode(', ', $this->removed)
```
Vor `private function version(): string` einfügen:
```php
/** 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):
```php
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:
```php
* contentComposer() gives lit/content-create the Divi short form (spec §6.2).
*/
final class DiviIntegration implements Integration
{
```
Nachher:
```php
* 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:
```php
/**
* 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:
```php
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:
```php
(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:
```php
(new ChangeLogCleanup($changeLog, $limits))->register();
$schema = self::contentSchema();
```
Vorher:
```php
// 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:
```php
// 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:
```php
new LimitsTab(),
new DiviTab(log: $changeLog),
];
```
Nachher:
```php
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**
```bash
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
<?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
<?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:
```php
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
```
Vorher:
```php
/** 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:
```php
/** 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:
```php
/** 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:
```php
// 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:
```php
// 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:
```php
$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:
```php
$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
<?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:
```php
/**
* @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:
```php
/** @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:
```php
/**
* 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:
```php
/**
* 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:
```php
$check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->contentComposer());
```
Nachher:
```php
$check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->shortForm(null));
```
Vor dem Docblock von `private function composition(): DiviComposition` („The one wiring of the composition …“)
einfügen:
```php
/**
* 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:
```php
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):
```php
$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:
```php
$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):
```php
/**
* 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**
```bash
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:**
```php
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
<?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
<?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
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
<?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
<?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
<?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
<?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
<?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
<?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
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):
```php
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):
```php
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()`:
```php
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):
```php
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()`:
```php
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):
```php
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()`:
```php
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()`:
```php
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.
```bash
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:**
```php
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
<?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
<?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
<?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
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
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).
```bash
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:
```bash
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):
```json
{
"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.
```bash
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
<?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
<?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
<?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
<?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
<?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
<?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
<?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):
```php
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;
```
```php
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:
```php
/** 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
<?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
<?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):
```php
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;
```
```php
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:
```php
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):
```php
self::assertSame(ReleaseState::Incompatible, $status->state);
self::assertStringContainsString('theme API gone', (string) file_get_contents($log));
```
```php
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:
```php
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
<?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
<?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):
```php
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:
```php
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):
```php
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:
```php
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**
```bash
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
<?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
<?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
<?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:
```php
public function testRefusalForAnUntestedFeatureNamesItAndKeepsTheOthersWorking(): void
{
$error = ReleaseMatrices::status([DiviFeature::PageWrite])
->refusalFor([DiviFeature::PageWrite, DiviFeature::PageRead]);
$payload = self::assertToolError($error, 'divi_untested');
self::assertSame(423, $error->get_error_data()['status']);
self::assertSame(
'Achtung: Divi 5.3.0 ist auf dieser Website für „Seiten schreiben“ noch nicht für LIT MCP freigegeben. '
. 'Das ist gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung ausführt '
. 'und die Version freigibt. Es wurde nichts geändert.',
$payload['message']
);
self::assertSame(
'Gesperrt: „Seiten schreiben“. Andere Divi-Werkzeuge funktionieren weiter, sofern ihre Fähigkeiten '
. 'freigegeben sind. Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben.',
$payload['hint']
);
self::assertSame(
[['id' => 'page-write', 'label' => 'Seiten schreiben', 'state' => 'untested', 'problems' => []]],
$payload['features']
);
self::assertArrayNotHasKey('problems', $payload);
}
public function testRefusalForTakesTheWorstStateAndListsEveryLockedFeature(): void
{
$matrix = ReleaseMatrices::matrix([DiviFeature::ThemeBuilderWrite], ReleaseState::Incompatible);
$features = [];
foreach (DiviFeature::cases() as $feature) {
$features[$feature->value] = $matrix->of($feature);
}
$features['library-write'] = new FeatureStatus(DiviFeature::LibraryWrite, ReleaseState::Untested);
$status = ReleaseStatus::of('5.99.0', new CompatibilityMatrix($features));
$payload = self::assertToolError(
$status->refusalFor([DiviFeature::PageWrite, DiviFeature::LibraryWrite, DiviFeature::ThemeBuilderWrite]),
'divi_incompatible'
);
self::assertSame(
'Divi 5.99.0 hat Stellen geändert, die LIT MCP für „Theme Builder schreiben“ nutzt. Das ist gesperrt, bis '
. 'LIT MCP aktualisiert wurde. Es wurde nichts geändert.',
$payload['message']
);
self::assertStringStartsWith(
'Gesperrt: „Bibliothek schreiben“ und „Theme Builder schreiben“. Andere Divi-Werkzeuge',
$payload['hint']
);
self::assertStringEndsWith('dass LIT MCP aktualisiert werden muss.', $payload['hint']);
self::assertSame(['library-write', 'tb-write'], array_column($payload['features'], 'id'));
self::assertSame(['untested', 'incompatible'], array_column($payload['features'], 'state'));
self::assertSame(['Seiten: et_x() – Signatur geändert'], $payload['problems']);
}
public function testRefusalForNamesThreeFeatures(): void
{
$locked = [DiviFeature::PageWrite, DiviFeature::LibraryWrite, DiviFeature::PresetWrite];
$payload = self::assertToolError(ReleaseMatrices::status($locked)->refusalFor($locked), 'divi_untested');
self::assertStringStartsWith(
'Gesperrt: „Seiten schreiben“, „Bibliothek schreiben“ und „Presets schreiben“.',
$payload['hint']
);
}
public function testUsableFeaturesHaveNoRefusal(): void
{
$this->expectException(LogicException::class);
ReleaseMatrices::status([DiviFeature::Catalog])->refusalFor([DiviFeature::PageWrite, DiviFeature::PageRead]);
}
```
`tests/Unit/Integrations/Divi/Pages/SaveScopeTest.php` – Import `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`
und nach `testThemeBuilderLayoutsGetFlagsAndClearEveryPage()`:
```php
/** D5: every save needs page-write, a library or Theme Builder save the feature of its area too. */
public function testFeaturesOfEachScope(): void
{
self::assertSame([DiviFeature::PageWrite], SaveScope::Page->features());
self::assertSame([DiviFeature::PageWrite, DiviFeature::LibraryWrite], SaveScope::LibraryItem->features());
self::assertSame([DiviFeature::PageWrite, DiviFeature::LibraryWrite], SaveScope::GlobalItem->features());
self::assertSame(
[DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite],
SaveScope::ThemeBuilderLayout->features()
);
}
```
`tests/Unit/Integrations/Divi/Design/PresetAreaTest.php` – Import `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`
und in `testWritesTheRestFormatAndReadsBack()` nach der `name()/label()`-Zeile:
```php
self::assertSame(DiviFeature::PresetWrite, $area->feature());
```
`tests/Unit/Integrations/Divi/Design/DesignChangeTest.php` – Imports `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`
(vor `DiviLocked`) und `use LIT\MCP\Tests\Support\ReleaseMatrices;` (nach `FakeChangeLog`); in der anonymen
`DesignArea` von `area()` nach `label()`:
```php
public function feature(): DiviFeature
{
return DiviFeature::PresetWrite;
}
```
und vor `testTooLargeSnapshotRefusesTheChange()`:
```php
public function testTheLockFollowsTheFeatureOfTheArea(): void
{
$others = array_values(array_filter(
DiviFeature::cases(),
static fn (DiviFeature $feature): bool => $feature !== DiviFeature::PresetWrite
));
ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status($others));
$stored = ['v' => 1];
$area = self::area($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('s1');
$snapshots->expects('attachLog');
$written = (new DesignChange($snapshots, new FakeChangeLog(9)))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 2])
);
self::assertTrue($written['ok'], 'every other feature locked: presets are still written');
ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::PresetWrite]));
try {
(new DesignChange(Mockery::mock(SnapshotStore::class), FakeChangeLog::forbidden()))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 3])
);
self::fail('preset-write locked must refuse');
} catch (DiviLocked $locked) {
self::assertSame([DiviFeature::PresetWrite], $locked->features);
}
}
```
- [ ] **Step 2: Tests laufen lassen – sie schlagen fehl**
Run: `composer test`
Expected: FAIL – u. a. `Error: Call to undefined method …ReleaseGate::assertFeatures()` / `::assertFeature()` /
`::feature()`, `Call to undefined method …ReleaseStatus::refusalFor()`, `Call to undefined method
…SaveScope::features()`, `Call to undefined method …PresetArea::feature()`, `Undefined property …DiviLocked::$features`;
in `ReleaseGuardsTest` scheitern die „passes the gate“-Fälle mit `refused although only other features are locked`
(heute sperrt jede Methode bei jeder gesperrten Schreib-Fähigkeit).
- [ ] **Step 3: `ReleaseStatus::refusalFor()` (D6)**
`src/Integrations/Divi/Release/ReleaseStatus.php` – nach der Konstante `NOTICE` einfügen:
```php
private const FEATURE_UNTESTED = 'Achtung: %1$s ist auf dieser Website für %2$s noch nicht für LIT MCP '
. 'freigegeben. Das ist gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung '
. 'ausführt und die Version freigibt. Es wurde nichts geändert.';
private const FEATURE_UNTESTED_HINT = 'Gesperrt: %s. Andere Divi-Werkzeuge funktionieren weiter, sofern ihre '
. 'Fähigkeiten freigegeben sind. Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben.';
private const FEATURE_INCOMPATIBLE = '%1$s hat Stellen geändert, die LIT MCP für %2$s nutzt. Das ist gesperrt, '
. 'bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.';
private const FEATURE_INCOMPATIBLE_HINT = 'Gesperrt: %s. Andere Divi-Werkzeuge funktionieren weiter, sofern ihre '
. 'Fähigkeiten freigegeben sind. Den Nutzer informieren, dass LIT MCP aktualisiert werden muss.';
```
Vor `/** The warning a reading Divi tool carries while writes are locked. */` einfügen:
```php
/**
* The error of a call refused because some of $features are locked (D6): divi_incompatible when one of them is
* incompatible, else divi_untested; HTTP 423. The message names the features in that state, the hint every
* locked one; details.features lists them (id, label, state, problems), details.problems the problems of an
* incompatible refusal as before.
*
* @param list<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:
```php
/**
* "„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
<?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
<?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:
```php
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
```
```php
/** @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):
```php
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array
{
$postId = (int) $post->ID;
ReleaseGate::assertWritable($postId);
```
```php
* @throws DiviLocked When a feature of $scope is not released (spec §5.5, D5) – before anything is written.
*/
public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array
{
$postId = (int) $post->ID;
ReleaseGate::assertFeatures($scope->features(), $postId);
```
`saveNew()` (Vorher → Nachher):
```php
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array
{
ReleaseGate::assertWritable($postId);
```
```php
* @throws DiviLocked When a feature of $scope is not released (spec §5.5, D5) – before anything is written.
*/
public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array
{
ReleaseGate::assertFeatures($scope->features(), $postId);
```
- [ ] **Step 7: Adapter auf ihre Fähigkeit umstellen**
`src/Integrations/Divi/Pages/DiviPages.php` – Import `use LIT\MCP\Integrations\Divi\Release\DiviFeature;` (nach
`DiviAdapter`) und `setFlags()` (Vorher → Nachher):
```php
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public static function setFlags(int $postId): void
{
ReleaseGate::assertWritable($postId);
```
```php
* @throws DiviLocked When page-write is not released (spec §5.5) – before anything is written.
*/
public static function setFlags(int $postId): void
{
ReleaseGate::assertFeature(DiviFeature::PageWrite, $postId);
```
`src/Integrations/Divi/Library/DiviLibraryStore.php` – Import `DiviFeature` (nach `DiviAdapter`) und `create()`:
```php
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function create(LibraryItemSpec $spec, NewDraft $draft): int
{
ReleaseGate::assertWritable();
```
```php
* @throws DiviLocked When library-write is not released (spec §5.5) – before anything is written.
*/
public function create(LibraryItemSpec $spec, NewDraft $draft): int
{
ReleaseGate::assertFeature(DiviFeature::LibraryWrite);
```
`src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php` – Import `DiviFeature` (nach `DiviAdapter`); alle **acht**
Vorkommen (`liveId`, `insertLayout`, `storeTemplate`, `attach`, `detach`, `setConditions`, `setEnabled`,
`unmarkUnused`):
```php
ReleaseGate::assertWritable();
```
→
```php
ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite);
```
(Einrückung wie vorgefunden) und in deren `@throws DiviLocked`-Zeilen `When Divi is not released (spec §5.5)` →
`When tb-write is not released (spec §5.5)` (in `liveId`: `When $create and tb-write is not released …`).
Kontrolle: `grep -c 'assertFeature(DiviFeature::ThemeBuilderWrite)' src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php`
→ `8`.
`src/Integrations/Divi/Design/DesignSystemWriter.php` – Import `DiviFeature` (nach `DiviAdapter`) und `savePresets()`:
```php
* @throws DiviLocked
* @throws RuntimeException When Divi refuses the inventory.
*/
public function savePresets(array $rest, bool $mayRemove): array
{
ReleaseGate::assertWritable();
```
```php
* @throws DiviLocked When preset-write is not released – before anything is written.
* @throws RuntimeException When Divi refuses the inventory.
*/
public function savePresets(array $rest, bool $mayRemove): array
{
ReleaseGate::assertFeature(DiviFeature::PresetWrite);
```
- [ ] **Step 8: `DesignArea::feature()` und `DesignChange`**
`src/Integrations/Divi/Design/DesignArea.php` – nach `namespace` `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`
und nach `label()`:
```php
/** The Divi feature a write of this area needs (DesignChange asks ReleaseGate::assertFeature() for it). */
public function feature(): DiviFeature;
```
`src/Integrations/Divi/Design/PresetArea.php` – gleicher Import und nach `label()`:
```php
public function feature(): DiviFeature
{
return DiviFeature::PresetWrite;
}
```
`src/Integrations/Divi/Design/DesignChange.php`, `apply()` (Vorher → Nachher):
```php
ReleaseGate::assertWritable();
try {
$snapshotId = $this->snapshots->save($area->name(), $current, $ability);
```
```php
ReleaseGate::assertFeature($area->feature());
try {
$snapshotId = $this->snapshots->save($area->name(), $current, $ability);
```
Kontrolle: `grep -rn 'assertWritable' src` → nur noch die Definition in `ReleaseGate.php` (und der Docblock in
`DiviToolGate`, der mit Task 9 verschwindet).
- [ ] **Step 9: `TestCase`-Standard: alle Fähigkeiten freigegeben**
`tests/Unit/TestCase.php` – Import `use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;` (vor `ReleaseGate`)
und (Vorher → Nachher):
```php
// Divi is released unless a test says otherwise (the release gate would ask WordPress and Divi).
ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseStatus::released('5.2.1'));
```
```php
// Every Divi feature is released unless a test says otherwise (the gate would ask WordPress and Divi).
ReleaseGate::useResolver(
static fn (): ReleaseStatus => ReleaseStatus::of('5.2.1', CompatibilityMatrix::allReleased())
);
```
**Bestehende Tests mit `ReleaseStatus`/`DiviLocked`** (grep `ReleaseStatus(`, `DiviLocked(`, `useResolver(` in
`tests/`): `AbilityRegistrarTest` (223, 234, 243), `DiviTabTest` (148–249), `DesignSystemWriterTest:41`,
`DesignChangeTest:370`, `PageRewriterTest:459`, `DiviDesignDetachAbilityTest:381/385`, `DiviToolGateTest:43`,
`WriteProbeTest:28/50/103`, `ContentCreateDiviTest:202` bleiben **unverändert** gültig: `new ReleaseStatus(Untested|
Incompatible, …)` erzeugt eine uniforme Matrix (alle Fähigkeiten gesperrt), `new DiviLocked($status)` ohne Fähigkeiten
liefert wie bisher `$status->refusal()` (Codes und Texte gleich), `assertWritable()` wirft bei uniform gesperrtem
Status mit allen Schreib-Fähigkeiten und demselben Code. Geprüft: alle grün.
- [ ] **Step 10: Tests laufen lassen – sie bestehen**
Run: `php vendor/bin/phpunit --filter '/ReleaseGuardsTest|DiviLockedTest|ReleaseGateTest|ReleaseStatusTest|SaveScopeTest|PresetAreaTest|DesignChangeTest/'`
Expected: PASS (ReleaseGuardsTest 35 = 17 Methoden × 2 + Probe, DiviLockedTest 2, ReleaseGateTest 11,
ReleaseStatusTest 14, SaveScopeTest 2, PresetAreaTest 4, DesignChangeTest 17).
- [ ] **Step 11: Gesamtlauf und Lint**
Run: `composer test && composer lint`
Expected: alle Tests grün (Stand nach Task 6 + 34), phpcs ohne Meldung.
- [ ] **Step 12: Commit**
```bash
git add src/Integrations/Divi/Release/ReleaseGate.php src/Integrations/Divi/Release/DiviLocked.php \
src/Integrations/Divi/Release/ReleaseStatus.php src/Integrations/Divi/Pages/SaveScope.php \
src/Integrations/Divi/Pages/DiviSaver.php src/Integrations/Divi/Pages/DiviPages.php \
src/Integrations/Divi/Library/DiviLibraryStore.php src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php \
src/Integrations/Divi/Design/DesignSystemWriter.php src/Integrations/Divi/Design/DesignArea.php \
src/Integrations/Divi/Design/PresetArea.php src/Integrations/Divi/Design/DesignChange.php tests/Unit/TestCase.php \
tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php tests/Unit/Integrations/Divi/Release/DiviLockedTest.php \
tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php \
tests/Unit/Integrations/Divi/Pages/SaveScopeTest.php tests/Unit/Integrations/Divi/Design/PresetAreaTest.php \
tests/Unit/Integrations/Divi/Design/DesignChangeTest.php
git commit -m "feat: Divi-Adapter sperren je Fähigkeit – ReleaseGate::feature/assertFeature(s), DiviLocked und Ablehnung nennen die betroffenen Fähigkeiten (D6), DiviSaver prüft Seiten schreiben und die Fähigkeit des Speicherbereichs (D5), DesignArea::feature; Test-Standard alle Fähigkeiten freigegeben" -m "Co-Authored-By: Claude <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:**
```php
// 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**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git status --short
grep -rn "implements ContentComposer" src tests
grep -rn "enum DiviFeature" src
grep -rn "PostGuard::id" src/Integrations/WordPress/PostGuard.php | head -2
```
Erwartet: sauberer Baum (Tasks 1–7 committed); `ContentComposer` wird von `DiviContentComposer` und der anonymen Klasse in `tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php` implementiert (Mockery-Mocks sind nicht betroffen); `enum DiviFeature` aus Task 4 ist vorhanden; `PostGuard::id()` ist public static.
- [ ] **Step 2: Tests schreiben**
Datei `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php` (vollständig). Sie ist die Tabelle als Konstante `EXPECTED` (Fähigkeiten, die ein Tool bei einer Eingabe ohne Ziel braucht) plus `inputCases()` (jede eingabeabhängige Zeile) und prüft: (a) jede Ability der Gruppen `divi`, `divi-library`, `divi-theme-builder` implementiert `RequiresDiviFeatures`, `lit/content-create` nur `DeclaresRequirements` (kein Divi-Import im Kern); (b) die Tabelle exakt; (c) keine Zeile ohne Ability und keine Ability ohne Zeile; dazu Schreib-/Lese-Plausibilität. Die Abilities kommen aus den echten Integrationen (`DiviIntegration::abilities()`, `->library()`, `->themeBuilder()`, `WordPressIntegration`), nicht aus einer Liste im Test: ein neues Tool (4c bis 4e) ohne Zeile lässt Test (c) scheitern.
```php
<?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
<?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
diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
index ea7475b..8020c92 100644
--- a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
+++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
@@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
+use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Unit\TestCase;
@@ -68,6 +69,17 @@ final class DiviContentComposerTest extends TestCase
];
}
+ public function testRequirementsNameTheCapabilitiesANewEntryNeeds(): void
+ {
+ $requirements = $this->composer()->requirements();
+ sort($requirements);
+
+ self::assertSame(['catalog', 'design-read', 'dynamic-sources', 'page-read', 'page-write'], $requirements);
+ foreach ($requirements as $id) {
+ self::assertNotNull(DiviFeature::tryFrom($id), $id);
+ }
+ }
+
public function testChecksBeforeTheEntryExistsWithDivisMapForNoEntry(): void
{
self::assertNull($this->composer()->check('page', self::sections()));
diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php
index 691e2a6..a75539c 100644
--- a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php
+++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php
@@ -94,6 +94,23 @@ final class ContentCreateDiviTest extends TestCase
);
}
+ public function testRequirementsAreEmptyWithoutDiviWhateverTheComposerNeeds(): void
+ {
+ $composer = $this->composer();
+ $composer->shouldNotReceive('requirements');
+
+ self::assertSame([], $this->ability($composer)->requirements(['post_type' => 'page', 'title' => 'Start']));
+ self::assertSame([], $this->ability(null)->requirements(self::INPUT), 'no composer: the call is refused anyway');
+ }
+
+ public function testRequirementsWithDiviAreThoseOfTheComposer(): void
+ {
+ $composer = $this->composer();
+ $composer->expects('requirements')->andReturn(['page-write', 'catalog']);
+
+ self::assertSame(['page-write', 'catalog'], $this->ability($composer)->requirements(self::INPUT));
+ }
+
public function testCreatesADiviDraftWithTreeRevOutlineAndWarnings(): void
{
$composer = $this->composer();
@@ -342,6 +359,11 @@ final class ContentCreateDiviTest extends TestCase
return null;
}
+ public function requirements(): array
+ {
+ return $this->real->requirements();
+ }
+
public function check(string $postType, array $sections): ?WP_Error
{
return $this->real->check($postType, $sections);
```
- [ ] **Step 3: RED**
```bash
vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php
vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
vendor/bin/phpunit tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php
```
Erwartet (in der Kopie so gesehen): `DiviToolFeaturesTest` 31 Fehler, zuerst `PHPUnit\Framework\InvalidArgumentException: Argument #1 of PHPUnit\Framework\Assert::assertInstanceOf() must be a class or interface name` (`RequiresDiviFeatures` fehlt), die übrigen `Class "LIT\MCP\Integrations\Divi\Release\FeatureSets" not found`; `FeatureSetsTest` 9 Fehler `Class "LIT\MCP\Integrations\Divi\Release\FeatureSets" not found`; `DiviContentComposerTest` `Call to undefined method LIT\MCP\Integrations\Divi\Pages\DiviContentComposer::requirements()`; `ContentCreateDiviTest` zwei Fehler `Call to undefined method LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility::requirements()`.
- [ ] **Step 4: Schnittstellen und `FeatureSets` anlegen**
`src/Integrations/Divi/Release/RequiresDiviFeatures.php`:
```php
<?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
<?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
<?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
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
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:
```php
/**
* Loads the entry (PageRead) and names preset labels (DesignRead). Global modules are shown by ID only, never
* resolved. A library entry or Theme Builder layout as post_id adds the Divi roles.
*/
public function diviFeatures(array $input): array
{
return FeatureSets::merge(
[DiviFeature::PageRead, DiviFeature::DesignRead],
FeatureSets::ofEntry($input['post_id'] ?? null, false)
);
}
```
##### `lit/divi-node-get` – `src/Integrations/Divi/Pages/DiviNodeGetAbility.php`
- Klassenkopf: `class DiviNodeGetAbility implements Ability` wird `class DiviNodeGetAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\FeatureSets;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/**
* Loads the entry (PageRead) and names preset labels (DesignRead); ShortformMap is pure. A library entry or
* Theme Builder layout as post_id adds the Divi roles.
*/
public function diviFeatures(array $input): array
{
return FeatureSets::merge(
[DiviFeature::PageRead, DiviFeature::DesignRead],
FeatureSets::ofEntry($input['post_id'] ?? null, false)
);
}
```
##### `lit/divi-insert` – `src/Integrations/Divi/Pages/DiviInsertAbility.php`
- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`
- Block direkt vor `protected function change(` einfügen:
```php
/**
* Loads and saves the entry (PageRead, PageWrite) and composes new nodes: Catalog, DesignRead (presets,
* variables) and DynamicSources (references are checked against the dynamic content map of the entry).
*/
protected function baseFeatures(): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
}
```
##### `lit/divi-update` – `src/Integrations/Divi/Pages/DiviUpdateAbility.php`
- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`
- Block direkt vor `protected function change(` einfügen:
```php
/** Like lit/divi-insert: the Composer checks every changed value (Catalog, DesignRead, DynamicSources). */
protected function baseFeatures(): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
}
```
##### `lit/divi-move` – `src/Integrations/Divi/Pages/DiviMoveAbility.php`
- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`
- Block direkt vor `protected function change(` einfügen:
```php
/**
* No client value enters the tree (no DynamicSources): load and save (PageRead, PageWrite), StructureRules
* need the catalog, the quality warnings of the result read the design system (DesignRead).
*/
protected function baseFeatures(): array
{
return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead];
}
```
##### `lit/divi-delete` – `src/Integrations/Divi/Pages/DiviDeleteAbility.php`
- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`
- Block direkt vor `protected function change(` einfügen:
```php
/** Like lit/divi-move: TreeEditor::remove() only, no composition of client values. */
protected function baseFeatures(): array
{
return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead];
}
```
##### `lit/divi-duplicate-node` – `src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php`
- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`
- Block direkt vor `protected function change(` einfügen:
```php
/** Like lit/divi-move: TreeEditor::duplicate() copies stored bytes, no composition of client values. */
protected function baseFeatures(): array
{
return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead];
}
```
##### `lit/divi-replace-text` – `src/Integrations/Divi/Pages/DiviReplaceTextAbility.php`
- Klassenkopf: `class DiviReplaceTextAbility implements Ability` wird `class DiviReplaceTextAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\FeatureSets;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/**
* The dry run (default) reads: PageRead, Catalog and DesignRead; the cleaner builds its reference check from the
* entry's dynamic content map (DynamicSources). A real run (dry_run: false) also saves each target, which may be a
* library entry or Theme Builder layout (PageWrite and what FeatureSets::ofEntry() adds).
*/
public function diviFeatures(array $input): array
{
$features = [
DiviFeature::PageRead,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
if (($input['dry_run'] ?? true) !== false) {
return $features;
}
$features[] = DiviFeature::PageWrite;
foreach (is_array($input['targets'] ?? null) ? $input['targets'] : [] as $target) {
$features = FeatureSets::merge(
$features,
FeatureSets::ofEntry(is_array($target) ? ($target['post_id'] ?? null) : null, true)
);
}
return $features;
}
```
##### `lit/divi-node-copy` – `src/Integrations/Divi/Pages/DiviNodeCopyAbility.php`
- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\FeatureSets;`
- Block direkt vor `protected function change(` einfügen:
```php
/**
* Like lit/divi-insert (StoredNodeGuard cleans the copied node: Catalog, DesignRead, DynamicSources); the source
* entry is only read, so a library entry or Theme Builder layout as source adds the Divi roles.
*/
protected function baseFeatures(): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
}
public function diviFeatures(array $input): array
{
return FeatureSets::merge(
parent::diviFeatures($input),
FeatureSets::ofEntry($input['source_post_id'] ?? null, false)
);
}
```
##### `lit/divi-dynamic-sources` – `src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php`
- Klassenkopf: `class DiviDynamicSourcesAbility implements Ability` wird `class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/**
* Divi's dynamic content registry (DynamicSources); listing custom fields asks the role option
* read_dynamic_content_custom_fields (Roles) and omits them without it, so a broken roles check must not pass.
*/
public function diviFeatures(array $input): array
{
return [DiviFeature::DynamicSources, DiviFeature::Roles];
}
```
#### Katalog und Designsystem
##### `lit/divi-catalog` – `src/Integrations/Divi/Schema/DiviCatalogAbility.php`
- Klassenkopf: `class DiviCatalogAbility implements Ability` wird `class DiviCatalogAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/** Only the module catalog (DiviModules::catalog()). */
public function diviFeatures(array $input): array
{
return [DiviFeature::Catalog];
}
```
##### `lit/divi-design-system` – `src/Integrations/Divi/Design/DiviDesignSystemAbility.php`
- Klassenkopf: `class DiviDesignSystemAbility implements Ability` wird `class DiviDesignSystemAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/** Only the design system reader. */
public function diviFeatures(array $input): array
{
return [DiviFeature::DesignRead];
}
```
##### `lit/divi-preset-get` – `src/Integrations/Divi/Design/DiviPresetGetAbility.php`
- Klassenkopf: `class DiviPresetGetAbility implements Ability` wird `class DiviPresetGetAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/** Divi's preset inventory (DesignRead) and the option groups of modules (Catalog); usages come from the database. */
public function diviFeatures(array $input): array
{
return [DiviFeature::DesignRead, DiviFeature::Catalog];
}
```
##### `lit/divi-presets-set` – `src/Integrations/Divi/Design/DiviPresetsSetAbility.php`
- Klassenkopf: `class DiviPresetsSetAbility implements Ability` wird `class DiviPresetsSetAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\FeatureSets;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/**
* Writes the preset inventory (PresetWrite) after reading it (DesignRead); the preset composer cleans the
* attributes (Catalog). from_node reads one node of an entry (PageRead, and the roles for a library entry).
*/
public function diviFeatures(array $input): array
{
$features = [DiviFeature::DesignRead, DiviFeature::PresetWrite, DiviFeature::Catalog];
foreach (is_array($input['items'] ?? null) ? $input['items'] : [] as $item) {
$source = is_array($item) ? ($item['from_node'] ?? null) : null;
if (is_array($source)) {
$features = FeatureSets::merge(
$features,
[DiviFeature::PageRead],
FeatureSets::ofEntry($source['post_id'] ?? null, false)
);
}
}
return $features;
}
```
##### `lit/divi-presets-delete` – `src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php`
- Klassenkopf: `class DiviPresetsDeleteAbility implements Ability` wird `class DiviPresetsDeleteAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/** Reads and writes the preset inventory only; no composition. */
public function diviFeatures(array $input): array
{
return [DiviFeature::DesignRead, DiviFeature::PresetWrite];
}
```
##### `lit/divi-design-snapshots` – `src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php`
- Klassenkopf: `class DiviDesignSnapshotsAbility implements Ability` wird `class DiviDesignSnapshotsAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/** Reads LIT MCP's own snapshot store (WordPress option); no Divi call. */
public function diviFeatures(array $input): array
{
return [];
}
```
##### `lit/divi-design-restore` – `src/Integrations/Divi/Design/DiviDesignRestoreAbility.php`
- Klassenkopf: `class DiviDesignRestoreAbility implements Ability` wird `class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/** Writes a stored snapshot back through the preset area (PresetWrite, reading it first: DesignRead). */
public function diviFeatures(array $input): array
{
return [DiviFeature::DesignRead, DiviFeature::PresetWrite];
}
```
##### `lit/divi-presets-assign` – `src/Integrations/Divi/Design/DiviPresetsAssignAbility.php`
- Klassenkopf: `class DiviPresetsAssignAbility implements Ability` wird `class DiviPresetsAssignAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/**
* Rewrites references in every post that uses the module, so the upper bound covers every kind of post the
* rewriter may save: pages (PageRead, PageWrite, Catalog, DesignRead), library entries (LibraryRead for
* isGlobal(), LibraryWrite) and Theme Builder layouts (ThemeBuilderWrite), with their roles. Stopping in the
* middle of a run would leave a partial write, so the lock comes before. DesignRights::presets() checks the
* Visual Builder role through the preset writer (PresetWrite).
*/
public function diviFeatures(array $input): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::PresetWrite,
DiviFeature::LibraryRead,
DiviFeature::LibraryWrite,
DiviFeature::ThemeBuilderWrite,
DiviFeature::Roles,
];
}
```
##### `lit/divi-design-detach` – `src/Integrations/Divi/Design/DiviDesignDetachAbility.php`
- Klassenkopf: `class DiviDesignDetachAbility implements Ability` wird `class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/**
* Like lit/divi-presets-assign (it rewrites every kind of post), plus the preset step that removes references
* nested in other presets (PresetWrite).
*/
public function diviFeatures(array $input): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::PresetWrite,
DiviFeature::LibraryRead,
DiviFeature::LibraryWrite,
DiviFeature::ThemeBuilderWrite,
DiviFeature::Roles,
];
}
```
#### Bibliothek (`src/Integrations/Divi/Library/`)
##### `lit/library-list` – `src/Integrations/Divi/Library/LibraryListAbility.php`
- Klassenkopf: `class LibraryListAbility implements Ability` wird `class LibraryListAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/**
* describe() asks Divi whether an entry is global (LibraryRead); listing needs the library role (Roles). One
* entry (id) is loaded and outlined (PageRead, DesignRead).
*/
public function diviFeatures(array $input): array
{
$features = [DiviFeature::LibraryRead, DiviFeature::Roles];
return isset($input['id'])
? array_merge($features, [DiviFeature::PageRead, DiviFeature::DesignRead])
: $features;
}
```
##### `lit/library-find-similar` – `src/Integrations/Divi/Library/LibraryFindSimilarAbility.php`
- Klassenkopf: `class LibraryFindSimilarAbility implements Ability` wird `class LibraryFindSimilarAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/**
* Scans Divi pages (PageRead) of the types that use the builder (Catalog), after the library right (Roles). A
* library entry as reference is read through LibraryAccess (Roles), no Divi call beyond that.
*/
public function diviFeatures(array $input): array
{
return [DiviFeature::PageRead, DiviFeature::Catalog, DiviFeature::Roles];
}
```
##### `lit/library-insert` – `src/Integrations/Divi/Library/LibraryInsertAbility.php`
- Der Klassenkopf bleibt (`extends DiviWriteAbility` erbt `RequiresDiviFeatures`); die Klasse muss `baseFeatures()` liefern.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`
- Block direkt vor `protected function change(` einfügen:
```php
/**
* Like lit/divi-insert (StoredNodeGuard cleans the entry's nodes: Catalog, DesignRead, DynamicSources), plus the
* library entry as source: isGlobal() is a Divi call (LibraryRead), its rights are a Divi role (Roles).
*/
protected function baseFeatures(): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
DiviFeature::LibraryRead,
DiviFeature::Roles,
];
}
```
##### `lit/library-make-global` – `src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php`
- Klassenkopf: `class LibraryMakeGlobalAbility implements Ability` wird `class LibraryMakeGlobalAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/**
* Both modes write pages and posts (PageRead, PageWrite, Catalog, DesignRead, DynamicSources) and ask Divi
* whether an entry is global (LibraryRead); the first mode also creates the global entry (LibraryWrite).
* One upper bound for both modes (the second mode would not reach LibraryWrite): the roles of the library.
*/
public function diviFeatures(array $input): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
DiviFeature::LibraryRead,
DiviFeature::LibraryWrite,
DiviFeature::Roles,
];
}
```
##### `lit/library-save` – `src/Integrations/Divi/Library/LibrarySaveAbility.php`
- Klassenkopf: `class LibrarySaveAbility implements Ability` wird `class LibrarySaveAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/**
* Reads a node of any entry (PageRead), cleans it with StoredNodeGuard (Catalog, DesignRead, DynamicSources) and
* saves a new library entry (PageWrite and LibraryWrite: DiviSaver::saveNew with the library scope, D5), after
* the library right (Roles). The source is only read: a library entry or Theme Builder layout adds Roles only.
*/
public function diviFeatures(array $input): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
DiviFeature::LibraryWrite,
DiviFeature::Roles,
];
}
```
#### Theme Builder (`src/Integrations/Divi/ThemeBuilder/`)
`TbCreateTemplateAbility` braucht kein `use` für `Template` (selber Namespace).
##### `lit/tb-list` – `src/Integrations/Divi/ThemeBuilder/TbListAbility.php`
- Klassenkopf: `class TbListAbility implements Ability` wird `class TbListAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/** Templates, layouts and conditions come from Divi's Theme Builder functions; the right is a Divi role. */
public function diviFeatures(array $input): array
{
return [DiviFeature::ThemeBuilderRead, DiviFeature::Roles];
}
```
##### `lit/tb-create-template` – `src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php`
- Klassenkopf: `class TbCreateTemplateAbility implements Ability` wird `class TbCreateTemplateAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\FeatureSets;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/**
* Creates layouts and the template (Theme Builder functions); layouts with nodes are composed and saved like a
* page (PageRead, PageWrite, Catalog, DesignRead, DynamicSources) – only when an area names nodes.
*/
public function diviFeatures(array $input): array
{
$features = [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite, DiviFeature::Roles];
foreach (Template::AREAS as $area) {
$spec = $input[$area] ?? null;
if (is_array($spec) && is_array($spec['nodes'] ?? null) && $spec['nodes'] !== []) {
return FeatureSets::merge($features, [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
]);
}
}
return $features;
}
```
##### `lit/tb-assign` – `src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php`
- Klassenkopf: `class TbAssignAbility implements Ability` wird `class TbAssignAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/** Reads and writes templates and their conditions (Theme Builder functions); the right is a Divi role. */
public function diviFeatures(array $input): array
{
return [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite, DiviFeature::Roles];
}
```
##### `lit/tb-activate` – `src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php`
- Klassenkopf: `class TbActivateAbility implements Ability` wird `class TbActivateAbility implements Ability, RequiresDiviFeatures`.
- `use`-Zeilen (alphabetisch zwischen die vorhandenen einsortieren): `use LIT\MCP\Integrations\Divi\Release\DiviFeature;`, `use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;`
- Block direkt vor `public function permission(array $input): bool|WP_Error` einfügen:
```php
/** Switches _et_enabled of a listed template (Theme Builder functions); the right is a Divi role. */
public function diviFeatures(array $input): array
{
return [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite, DiviFeature::Roles];
}
```
- [ ] **Step 8: GREEN, Lint, Gesamtlauf**
```bash
vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php
composer test
composer lint
```
Erwartet: `DiviToolFeaturesTest` OK (34 Tests), `FeatureSetsTest` OK (10 Tests), `composer test` grün (Ausgangszahl + 47 neue Tests; in der Kopie 2624 → 2671 vor Task 9), `composer lint` ohne Meldung. Prüfen, dass der Test die Tabelle wirklich erzwingt: in `DiviMoveAbility::baseFeatures()` kurz `DiviFeature::PageWrite` entfernen – `DiviToolFeaturesTest` muss scheitern (`'page-write'` fehlt in der Differenz); Änderung zurücknehmen.
- [ ] **Step 9: Commit**
```bash
git add src/Abilities/DeclaresRequirements.php \
src/Integrations/Divi/Release/RequiresDiviFeatures.php src/Integrations/Divi/Release/FeatureSets.php \
src/Integrations/WordPress/Content/ContentComposer.php src/Integrations/WordPress/Content/ContentCreateAbility.php \
src/Integrations/Divi/Pages src/Integrations/Divi/Schema/DiviCatalogAbility.php \
src/Integrations/Divi/Design src/Integrations/Divi/Library src/Integrations/Divi/ThemeBuilder \
tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php \
tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php \
tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php \
tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php
git commit -m "feat: jedes Divi-Tool und lit/content-create erklärt seine Divi-Fähigkeiten (RequiresDiviFeatures, DeclaresRequirements, FeatureSets); Tabelle Tool → Fähigkeiten als Test" -m "Co-Authored-By: Claude <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:**
```php
final class DiviFeatureGate implements AbilityGuard
{
public function __construct(?ChangeLog $log = null);
public function refusal(Ability $ability, array $input): ?WP_Error; // lockFor(FeatureSets::declaredBy($ability, $input))
public function annotate(Ability $ability, mixed $result): mixed; // unverändert (E2)
public function outputSchema(Ability $ability, ?array $schema): ?array; // + warnings für RequiresDiviFeatures
/** @param list<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**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git status --short
grep -rn "DiviToolGate" src tests docs/guides
grep -rn "new AbilityRegistrar" src tests
```
Erwartet: `DiviToolGate` kommt in `src/Plugin.php` (oder, falls Task 3 es in `DiviIntegration` verlagert hat, dort), in `DiviToolGateTest`, in `AbilityRegistrarTest` und in einem Docblock von `AbilityRegistrar` vor. **Jede** Stelle, an der heute `new DiviToolGate(...)` steht, wird `new DiviFeatureGate($changeLog)` (ohne Gruppen-Closure) – die Gate steht weiter in der `$guards`-Liste des `AbilityRegistrar`. Historische Notizen unter `docs/superpowers/notes/` nennen `DiviToolGate`; sie bleiben, Task 11 schreibt die Entscheidungsnotiz neu.
- [ ] **Step 2: Tests schreiben**
`tests/Support/DiviFeatureFixtures.php` (vollständig; braucht den benannten Parameter `matrix:` an `ReleaseStatus::__construct`, Task 6):
```php
<?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
<?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
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
diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
index 8020c92..32d7b7d 100644
--- a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
+++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
@@ -10,6 +10,8 @@ use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
+use LIT\MCP\Integrations\Divi\Release\ReleaseState;
+use LIT\MCP\Tests\Support\DiviFeatureFixtures;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Unit\TestCase;
@@ -80,6 +82,30 @@ final class DiviContentComposerTest extends TestCase
}
}
+ public function testRefusesNothingWhileItsCapabilitiesAreUsable(): void
+ {
+ DiviFeatureFixtures::useStates([
+ 'preset-write' => ReleaseState::Incompatible,
+ 'library-write' => ReleaseState::Untested,
+ 'tb-write' => ReleaseState::Incompatible,
+ ]);
+
+ self::assertNull($this->composer()->refusal(), 'capabilities of other tools do not matter');
+ }
+
+ public function testRefusesWithTheLockedCapabilitiesOfANewEntry(): void
+ {
+ DiviFeatureFixtures::useStates([
+ 'page-write' => ReleaseState::Untested,
+ 'dynamic-sources' => ReleaseState::Incompatible,
+ 'preset-write' => ReleaseState::Untested,
+ ]);
+
+ $payload = self::assertToolError($this->composer()->refusal(), 'divi_incompatible');
+
+ self::assertSame(['page-write', 'dynamic-sources'], array_column($payload['features'], 'id'));
+ }
+
public function testChecksBeforeTheEntryExistsWithDivisMapForNoEntry(): void
{
self::assertNull($this->composer()->check('page', self::sections()));
```
Den alten `tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php` löschen:
```bash
git rm tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php
```
- [ ] **Step 3: RED**
```bash
vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php
vendor/bin/phpunit tests/Unit/Abilities/AbilityRegistrarTest.php
vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
```
Erwartet (in der Kopie so gesehen): `DiviFeatureGateTest` 17 Fehler `Class "LIT\MCP\Integrations\Divi\Release\DiviFeatureGate" not found`; `AbilityRegistrarTest` 5 Fehler mit derselben Meldung (die vier Divi-Fälle plus `testDiviToolsGetWarningsInTheirSchemaOthersDoNot`); `DiviContentComposerTest` ein Fehler `Undefined array key "features"` (`testRefusesWithTheLockedCapabilitiesOfANewEntry`) und ein Fehlschlag in `testRefusesNothingWhileItsCapabilitiesAreUsable` (`Failed asserting that WP_Error Object … is null` – die alte Prüfung „global schreibbar“ lehnt bei gesperrtem `preset-write` ab).
- [ ] **Step 4: Die Gate, Verdrahtung, `DiviContentComposer::refusal()`**
`src/Integrations/Divi/Release/DiviFeatureGate.php` (vollständig):
```php
<?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()`):
```bash
git rm src/Integrations/Divi/Release/DiviToolGate.php
```
```diff
diff --git a/src/Abilities/AbilityRegistrar.php b/src/Abilities/AbilityRegistrar.php
index e94b014..e867337 100644
--- a/src/Abilities/AbilityRegistrar.php
+++ b/src/Abilities/AbilityRegistrar.php
@@ -15,7 +15,7 @@ defined('ABSPATH') || exit;
/**
* Registers all abilities of available integrations with the Abilities API and wraps them
- * with the role gate, the Divi release lock (DiviToolGate) and neutral error handling.
+ * with the role gate, the guards it is given (the Divi release lock DiviFeatureGate) and neutral error handling.
*/
final class AbilityRegistrar
{
diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php
index aac0373..13b7d67 100644
--- a/src/Integrations/Divi/Pages/DiviContentComposer.php
+++ b/src/Integrations/Divi/Pages/DiviContentComposer.php
@@ -11,7 +11,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
-use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
+use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
@@ -81,9 +81,7 @@ final class DiviContentComposer implements ContentComposer
public function refusal(): ?WP_Error
{
- $status = ReleaseGate::status();
-
- return $status->writable() ? null : $status->refusal();
+ return DiviFeatureGate::lockFor(self::FEATURES)?->toError();
}
public function check(string $postType, array $sections): ?WP_Error
diff --git a/src/Plugin.php b/src/Plugin.php
index 7df70a5..ea8e69a 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -28,7 +28,7 @@ use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
-use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
+use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
use LIT\MCP\Integrations\IntegrationRegistry;
@@ -144,7 +144,7 @@ final class Plugin
DiviCheckCommand::register($diviCheck);
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
- $guards = [new DiviToolGate($registry->groupMap(...), $changeLog)];
+ $guards = [new DiviFeatureGate($changeLog)];
(new AbilityRegistrar($registry, $policy, $guards))->register();
(new ServerRegistrar(
$registry,
```
- [ ] **Step 5: GREEN, Lint, Reste suchen**
```bash
vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php
vendor/bin/phpunit tests/Unit/Abilities/AbilityRegistrarTest.php
vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
composer test
composer lint
grep -rn "DiviToolGate" src tests
```
Erwartet: alle drei Dateien OK (17 / 21 / 9 Tests), `composer test` grün (in der Kopie 2690 Tests), `composer lint` ohne Meldung, das `grep` findet nichts mehr unter `src` und `tests`.
- [ ] **Step 6: Commit**
```bash
git add src/Integrations/Divi/Release/DiviFeatureGate.php src/Integrations/Divi/Release/DiviToolGate.php \
src/Plugin.php src/Integrations/Divi/Pages/DiviContentComposer.php src/Abilities/AbilityRegistrar.php \
tests/Support/DiviFeatureFixtures.php \
tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php \
tests/Unit/Integrations/Divi/Release/DiviToolGateTest.php \
tests/Unit/Abilities/AbilityRegistrarTest.php tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
git commit -m "feat: DiviFeatureGate ersetzt DiviToolGate – Ablehnung je Fähigkeit für Lese- und Schreib-Tools, Details nach D6, kein Hinweis mehr in warnings; DiviContentComposer::refusal prüft seine Fähigkeiten" -m "Co-Authored-By: Claude <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:
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
grep -n "matrix" src/Integrations/Divi/Release/ReleaseStatus.php
grep -rn "class DiviEnvironmentComponent\|new DiviEnvironmentComponent\|new DiviTab" src
grep -rn "notice()" src
ls src/Integrations/Divi/Release/DiviToolGate.php 2>&1 | tail -1
php -r 'require "vendor/autoload.php"; var_dump(method_exists(LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix::class, "unusable"));'
```
Expected: `ReleaseStatus` hat `$matrix`; `DiviEnvironmentComponent` (Task 2) und `new DiviTab` (`Plugin.php` oder Task-3-`boot()`) gefunden; `notice()` nur noch in `ReleaseStatus.php` selbst; `DiviToolGate.php` nicht vorhanden; `bool(true)`. Weicht etwas ab, die Annahmen oben korrigieren, bevor weitergemacht wird.
- [ ] **Step 1: Hilfsklassen und `FeatureOverview` (RED/GREEN)**
`tests/Support/FeatureFakeTool.php`:
```php
<?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
<?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
<?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
<?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
<?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
<?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
diff --git a/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php b/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php
--- a/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php
+++ b/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php
@@ -35,2 +35,26 @@ final class SiteStatusAbilityTest extends TestCase
}
+
+ public function testTheOutputSchemaDescribesTheDiviFeatures(): void
+ {
+ $schema = (new SiteStatusAbility(Mockery::mock(EnvironmentProbe::class), new Access()))->outputSchema();
+ $component = $schema['properties']['components']['items'];
+ $feature = $component['properties']['features']['items'];
+
+ self::assertSame(['id', 'active', 'version', 'problems', 'release'], $component['required']);
+ self::assertSame(
+ ['id', 'label', 'state', 'source', 'problems', 'tools'],
+ $feature['required']
+ );
+ self::assertSame(['released', 'untested', 'incompatible'], $feature['properties']['state']['enum']);
+ self::assertSame(['string', 'null'], $feature['properties']['source']['type']);
+ }
+
+ public function testTheDescriptionNamesTheFeaturesAndSaysOtherToolsKeepWorking(): void
+ {
+ $description = (new SiteStatusAbility(Mockery::mock(EnvironmentProbe::class), new Access()))->description();
+
+ self::assertStringContainsString('features je Fähigkeit', $description);
+ self::assertStringContainsString('(code, database, auto)', $description);
+ self::assertStringContainsString('alle anderen Divi-Werkzeuge laufen weiter', $description);
+ }
}
```
Run: `vendor/bin/phpunit tests/Unit/Integrations/Core/SiteStatusAbilityTest.php`
Expected (RED): `Undefined array key "items"` bzw. `'features je Fähigkeit' … does not contain`.
Änderungen an `src/Integrations/Core/SiteStatusAbility.php`:
```diff
diff --git a/src/Integrations/Core/SiteStatusAbility.php b/src/Integrations/Core/SiteStatusAbility.php
--- a/src/Integrations/Core/SiteStatusAbility.php
+++ b/src/Integrations/Core/SiteStatusAbility.php
@@ -32,6 +32,8 @@ final class SiteStatusAbility implements Ability
return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, '
. 'erkannte Integrationen (Divi, Pods, The Events Calendar). '
- . 'Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, und '
- . 'release, ob LIT MCP mit dieser Divi-Version schreiben darf (released, untested, incompatible).';
+ . 'Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, '
+ . 'release den Gesamtzustand und features je Fähigkeit (z. B. Seiten schreiben) Zustand '
+ . '(released, untested, incompatible), Quelle (code, database, auto), Probleme und betroffene Werkzeuge. '
+ . 'Werkzeuge, deren Fähigkeit nicht released ist, lehnen ab; alle anderen Divi-Werkzeuge laufen weiter.';
}
@@ -50,5 +52,5 @@ final class SiteStatusAbility implements Ability
'user' => ['type' => 'object'],
'allowed_roles' => ['type' => 'array', 'items' => ['type' => 'string']],
- 'components' => ['type' => 'array'],
+ 'components' => ['type' => 'array', 'items' => self::componentSchema()],
],
'required' => ['site', 'wordpress', 'user', 'allowed_roles', 'components'],
@@ -56,4 +58,41 @@ final class SiteStatusAbility implements Ability
}
+ /** @return array<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
diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php
--- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php
+++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php
@@ -9,2 +9,3 @@ use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\Divi\DiviIntegration;
+use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
@@ -12,2 +13,3 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Support\FakeChangeLog;
+use LIT\MCP\Tests\Support\FeatureFakeTool;
use LIT\MCP\Tests\Support\LibraryFixtures;
@@ -210,2 +212,26 @@ final class DiviIntegrationTest extends TestCase
}
+
+ public function testTheFeatureOverviewFindsTheToolsOfAllIntegrations(): void
+ {
+ $integration = new DiviIntegration(
+ Mockery::mock(ContentSchema::class),
+ FakeChangeLog::forbidden(),
+ allAbilities: static fn (): array => [
+ new FeatureFakeTool('lit/divi-insert', true, [DiviFeature::PageWrite]),
+ new FeatureFakeTool('lit/library-insert', true, [DiviFeature::PageWrite, DiviFeature::LibraryRead]),
+ ]
+ );
+
+ self::assertSame(
+ ['page-write' => ['lit-divi-insert', 'lit-library-insert'], 'library-read' => ['lit-library-insert']],
+ $integration->featureOverview()->toolsByFeature()
+ );
+ }
+
+ public function testWithoutAnAbilityListTheOverviewKnowsNoTools(): void
+ {
+ $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
+
+ self::assertSame([], $integration->featureOverview()->toolsByFeature());
+ }
}
```
Run: `vendor/bin/phpunit tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
Expected (RED): `Unknown named parameter $allAbilities`.
Änderungen an `src/Integrations/Divi/DiviIntegration.php`:
```diff
diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php
--- a/src/Integrations/Divi/DiviIntegration.php
+++ b/src/Integrations/Divi/DiviIntegration.php
@@ -48,4 +48,5 @@ use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
+use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
@@ -87,4 +88,6 @@ final class DiviIntegration implements Integration
* @param (Closure(): bool)|null $themeBuilderVisible Tools-tab gate for Theme Builder layouts in the Divi
* tools at all (group "Divi Theme Builder" not disabled); null = always.
+ * @param (Closure(): list<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
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
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
diff --git a/tests/Unit/Cli/DiviCheckCommandTest.php b/tests/Unit/Cli/DiviCheckCommandTest.php
--- a/tests/Unit/Cli/DiviCheckCommandTest.php
+++ b/tests/Unit/Cli/DiviCheckCommandTest.php
@@ -12,3 +12,10 @@ use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
+use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
+use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
+use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
+use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
+use LIT\MCP\Integrations\Divi\Release\ReleaseState;
+use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
+use LIT\MCP\Tests\Support\MatrixFixtures;
use LIT\MCP\Tests\Unit\TestCase;
@@ -54,3 +61,4 @@ final class DiviCheckCommandTest extends TestCase
bool $expectSave = false,
- bool $noSourceDir = false
+ bool $noSourceDir = false,
+ ?CompatibilityMatrix $matrix = null
): DiviCheckCommand {
@@ -77,3 +85,4 @@ final class DiviCheckCommandTest extends TestCase
$this->expectedFile,
- $noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir
+ $noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir,
+ $matrix === null ? null : static fn (): CompatibilityMatrix => $matrix
);
@@ -81,2 +90,3 @@ final class DiviCheckCommandTest extends TestCase
+
public function testNeedsAnAdministrator(): void
@@ -220,2 +230,88 @@ final class DiviCheckCommandTest extends TestCase
}
+
+ public function testPrintsTheMatrixAfterTheReport(): void
+ {
+ self::assertSame(0, $this->command()->run([]));
+
+ $schreibtest = array_search('✓ Schreibtest auf einer Wegwerfseite', $this->lines, true);
+ $header = array_search('Fähigkeiten:', $this->lines, true);
+ self::assertIsInt($schreibtest);
+ self::assertIsInt($header);
+ self::assertGreaterThan($schreibtest, $header);
+ self::assertContains('✓ Seiten schreiben – freigegeben (code)', $this->lines);
+ self::assertContains('✓ Theme Builder lesen – freigegeben (code)', $this->lines);
+ }
+
+ public function testEveryFeatureGetsOneLine(): void
+ {
+ $this->command()->run([]);
+
+ $matrixLines = array_filter($this->lines, static fn (string $line): bool => str_contains($line, ' – freigegeben'));
+ self::assertCount(count(DiviFeature::cases()), $matrixLines);
+ }
+
+ public function testAnIncompatibleFeatureFailsWithItsProblemsIndented(): void
+ {
+ $matrix = MatrixFixtures::with(DiviFeature::PageWrite, ReleaseState::Incompatible, 'auto', 'Seiten: et_pb_x() fehlt');
+
+ $code = $this->command(matrix: $matrix)->run([]);
+
+ self::assertSame(2, $code);
+ self::assertContains('✗ Seiten schreiben – inkompatibel', $this->lines);
+ self::assertContains(' Seiten: et_pb_x() fehlt', $this->lines);
+ self::assertContains('✓ Seiten lesen – freigegeben (auto)', $this->lines);
+ }
+
+ public function testAnUntestedFeatureFailsWithExitTwoAlthoughTheReportIsGreen(): void
+ {
+ $matrix = MatrixFixtures::with(DiviFeature::Catalog, ReleaseState::Untested, 'auto');
+
+ $code = $this->command(expectSave: true, matrix: $matrix)->run([]);
+
+ self::assertSame(2, $code);
+ self::assertContains('– Modulkatalog lesen – ungetestet', $this->lines);
+ }
+
+ public function testARedReportKeepsExitOneWhateverTheMatrixSays(): void
+ {
+ $red = [CheckStep::CATALOG => ['divi/text: unbekanntes Modul']];
+
+ self::assertSame(1, $this->command($red)->run([]));
+ self::assertSame(
+ 1,
+ $this->command($red, matrix: MatrixFixtures::with(DiviFeature::Catalog, ReleaseState::Incompatible, 'auto', 'x'))
+ ->run([])
+ );
+ }
+
+ public function testUpdateReferenceKeepsItsOwnExitCodeAndStillPrintsTheMatrix(): void
+ {
+ $this->writeDivi();
+ $matrix = MatrixFixtures::with(DiviFeature::PageWrite, ReleaseState::Incompatible, 'auto', 'Seiten: Nachbau geändert');
+
+ $code = $this->command([CheckStep::MIRRORS => ['geändert']], matrix: $matrix)
+ ->run(['update-reference' => true]);
+
+ self::assertSame(0, $code);
+ self::assertContains('✗ Seiten schreiben – inkompatibel', $this->lines);
+ }
+
+ public function testWithoutAnInjectedMatrixTheStateIsComputedAfterTheCheck(): void
+ {
+ $order = [];
+ Functions\when('delete_transient')->alias(static function (string $key) use (&$order): bool {
+ $order[] = 'delete:' . $key;
+
+ return true;
+ });
+ ReleaseGate::useResolver(static function () use (&$order): ReleaseStatus {
+ $order[] = 'resolve';
+
+ return ReleaseStatus::released('5.3.0');
+ });
+
+ $this->command()->run([]);
+
+ self::assertSame(['delete:' . ReleaseResolver::CACHE, 'resolve'], $order);
+ }
}
```
Run: `vendor/bin/phpunit tests/Unit/Cli`
Expected (RED): `Unknown named parameter $matrix` (in `command()`), die bestehenden Tests ebenfalls rot, solange der Konstruktor fehlt.
Änderungen an `src/Cli/DiviCheckCommand.php`:
```diff
diff --git a/src/Cli/DiviCheckCommand.php b/src/Cli/DiviCheckCommand.php
--- a/src/Cli/DiviCheckCommand.php
+++ b/src/Cli/DiviCheckCommand.php
@@ -10,4 +10,5 @@ use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
+use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
@@ -23,5 +24,12 @@ defined('ABSPATH') || exit;
/**
* wp lit-mcp divi-check (spec §7): the admin check from the command line, for a developer checking a new Divi
- * version. Never releases anything – a green version goes into DiviReleases::TESTED by hand.
+ * version. Never releases anything – a green version goes into DiviReleases::TESTED by hand. After the report it
+ * prints the compatibility matrix (E3).
+ *
+ * Exit codes: 0 = report green and every feature usable; 1 = report red (or the check could not run, or
+ * --update-reference refused); 2 = report green but a feature is not usable. The 2 is for a version that is
+ * checked but not yet released (not in DiviReleases::TESTED, automatic release off or a reference missing): the
+ * checks passed, yet LIT MCP would still refuse the affected tools. A script that only cares about "usable" tests
+ * for ≠ 0. --update-reference keeps its own code: it runs before the new reference is part of any release.
*/
final class DiviCheckCommand
@@ -36,4 +44,5 @@ final class DiviCheckCommand
* @param string|null $expectedFile null = DiviReference::FILE.
* @param (Closure(): ?string)|null $sourceDir null = DiviReference::sourceDir().
+ * @param (Closure(): CompatibilityMatrix)|null $matrix null = the release gate's matrix, computed again.
*/
public function __construct(
@@ -43,5 +52,6 @@ final class DiviCheckCommand
private ?Closure $themeDir = null,
private ?string $expectedFile = null,
- private ?Closure $sourceDir = null
+ private ?Closure $sourceDir = null,
+ private ?Closure $matrix = null
) {
$this->out = $out ?? static function (string $line): void {
@@ -60,5 +70,7 @@ final class DiviCheckCommand
/**
* Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format
- * und Schreibtest auf einer Wegwerfseite. Gibt nichts frei.
+ * und Schreibtest auf einer Wegwerfseite. Gibt danach den Zustand je Fähigkeit aus und gibt nichts frei.
+ * Exit-Code 0: Prüfung grün und alle Fähigkeiten nutzbar; 1: Prüfung rot; 2: Prüfung grün, aber mindestens
+ * eine Fähigkeit nicht nutzbar (z. B. Version noch nicht in DiviReleases::TESTED).
*
* ## OPTIONS
@@ -108,8 +120,67 @@ final class DiviCheckCommand
}
if (isset($assoc['update-reference'])) {
- return $this->updateReference($report);
+ $code = $this->updateReference($report);
+ $this->printMatrix();
+
+ return $code;
+ }
+
+ $unusable = $this->printMatrix();
+ if (! $report->green()) {
+ return 1;
+ }
+
+ return $unusable > 0 ? 2 : 0;
+ }
+
+ /**
+ * Prints one line per feature (✓ released, ✗ incompatible, – untested) with label and state, the problems
+ * indented below.
+ *
+ * @return int The number of features that are not usable.
+ */
+ private function printMatrix(): int
+ {
+ $matrix = $this->currentMatrix();
+ ($this->out)('Fähigkeiten:');
+ foreach ($matrix->toArray() as $row) {
+ ($this->out)(sprintf(
+ '%s %s – %s',
+ match ($row['state']) {
+ 'released' => '✓',
+ 'incompatible' => '✗',
+ default => '–',
+ },
+ $row['label'],
+ self::stateLabel($row['state'], $row['source'])
+ ));
+ foreach ($row['problems'] as $problem) {
+ ($this->out)(' ' . $problem);
+ }
+ }
+
+ return count($matrix->unusable());
+ }
+
+ /** The check just ran, so the state is computed again instead of read from a cache older than the report. */
+ private function currentMatrix(): CompatibilityMatrix
+ {
+ if ($this->matrix !== null) {
+ return ($this->matrix)();
}
- return $report->green() ? 0 : 1;
+ delete_transient(ReleaseResolver::CACHE);
+ ReleaseGate::forget();
+
+ return ReleaseGate::status()->matrix;
+ }
+
+ private static function stateLabel(string $state, ?string $source): string
+ {
+ return match ($state) {
+ 'released' => 'freigegeben (' . ($source ?? '?') . ')',
+ 'incompatible' => 'inkompatibel',
+ default => 'ungetestet',
+ };
}
```
Zusätzlich im Docblock von `__invoke()` (Hilfetext von WP-CLI) – Vorher:
```php
* Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format
* und Schreibtest auf einer Wegwerfseite. Gibt nichts frei.
```
Nachher:
```php
* Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format
* und Schreibtest auf einer Wegwerfseite. Gibt danach den Zustand je Fähigkeit aus und gibt nichts frei.
* Exit-Code 0: Prüfung grün und alle Fähigkeiten nutzbar; 1: Prüfung rot; 2: Prüfung grün, aber mindestens
* eine Fähigkeit nicht nutzbar (z. B. Version noch nicht in DiviReleases::TESTED).
```
Run: `vendor/bin/phpunit tests/Unit/Cli`
Expected (GREEN): `OK (16 tests, 44 assertions)`.
- [ ] **Step 6: Texte je Fähigkeit statt pauschal „gesperrt“ (Review-Punkt e)**
Die Texte des Tabs (`explanation()`: „Schreiben bleibt gesperrt“, „lesen nur“) und von `lit-site-status` sind in den Steps 2 und 4 schon umgestellt; die Guides folgen in Task 11 (dort auch der Guard-Test `FeatureTextsTest`, der `src/` und `docs/guides/` auf die alten Pauschalsätze prüft). Hier bleibt `ReleaseStatus`.
Hat Task 7/9 die Konstanten und `refusal()` schon je Fähigkeit formuliert (`grep -rn "Schreibende Divi-Werkzeuge sind gesperrt\|lesende Divi-Werkzeuge funktionieren weiter" src` ist leer), diesen Schritt überspringen und nur die Wortlaute mit den unten stehenden abgleichen. Sonst zuerst den Test anpassen (RED), dann die Konstanten.
Test: in `tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php` die Erwartungsstrings ersetzen und `testNoticesForReadingTools` löschen (die Methode `notice()` entfällt unten):
```php
// testUntestedRefusalNamesTheVersionAndTheWayOut
self::assertSame(
'Achtung: Divi 5.3.0 ist auf dieser Website noch nicht für LIT MCP freigegeben. Dieses Werkzeug braucht '
. 'Divi-Fähigkeiten, die noch nicht geprüft sind; andere Divi-Werkzeuge laufen weiter. Ein '
. 'Administrator kann unter Einstellungen → LIT MCP → Divi die Prüfung ausführen und die Version '
. 'freigeben. Es wurde nichts geändert.',
$payload['message']
);
self::assertStringContainsString('ein Werkzeug ohne die betroffene Fähigkeit nutzen', $payload['hint']);
self::assertStringContainsString('components → divi → features', $payload['hint']);
```
```php
// testIncompatibleRefusalListsTheProblems
self::assertSame(
'Divi 5.3.0 hat Stellen geändert, die LIT MCP für dieses Werkzeug nutzt; andere Divi-Werkzeuge laufen '
. 'weiter. Die betroffenen Werkzeuge bleiben gesperrt, bis LIT MCP aktualisiert wurde. Es wurde '
. 'nichts geändert.',
$payload['message']
);
```
Run: `vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php`
Expected (RED): `Failed asserting that two strings are identical` mit dem alten Text („Schreibende Divi-Werkzeuge sind gesperrt …“) als Ist-Wert.
Code, Vorher (`src/Integrations/Divi/Release/ReleaseStatus.php`):
```php
private const UNTESTED = 'Achtung: %s ist auf dieser Website noch nicht für LIT MCP freigegeben. Schreibende '
. 'Divi-Werkzeuge sind gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung '
. 'ausführt und die Version freigibt. Es wurde nichts geändert.';
private const UNTESTED_HINT = 'Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben; '
. 'lesende Divi-Werkzeuge funktionieren weiter.';
private const INCOMPATIBLE = '%s hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind '
. 'gesperrt, bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.';
private const INCOMPATIBLE_HINT = 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss; lesende '
. 'Divi-Werkzeuge funktionieren weiter.';
private const NOTICE = 'Achtung: %s ist für LIT MCP %s; Lesen funktioniert, Schreiben ist gesperrt.';
```
Nachher:
```php
private const UNTESTED = 'Achtung: %s ist auf dieser Website noch nicht für LIT MCP freigegeben. Dieses '
. 'Werkzeug braucht Divi-Fähigkeiten, die noch nicht geprüft sind; andere Divi-Werkzeuge laufen weiter. '
. 'Ein Administrator kann unter Einstellungen → LIT MCP → Divi die Prüfung ausführen und die Version '
. 'freigeben. Es wurde nichts geändert.';
private const UNTESTED_HINT = 'Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben; '
. 'bis dahin ein Werkzeug ohne die betroffene Fähigkeit nutzen. lit-site-status zeigt unter components → '
. 'divi → features, welche Fähigkeiten und Werkzeuge betroffen sind.';
private const INCOMPATIBLE = '%s hat Stellen geändert, die LIT MCP für dieses Werkzeug nutzt; andere '
. 'Divi-Werkzeuge laufen weiter. Die betroffenen Werkzeuge bleiben gesperrt, bis LIT MCP aktualisiert '
. 'wurde. Es wurde nichts geändert.';
private const INCOMPATIBLE_HINT = 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss; bis dahin '
. 'ein Werkzeug ohne die betroffene Fähigkeit nutzen. lit-site-status zeigt unter components → divi → '
. 'features, welche Fähigkeiten und Werkzeuge betroffen sind.';
```
`ReleaseStatus::notice()` hat nach Task 9 keinen Aufrufer (Step 0) und entfällt samt `NOTICE`. Vorher:
```php
/** The warning a reading Divi tool carries while writes are locked. */
public function notice(): string
{
return sprintf(
self::NOTICE,
$this->versionLabel(),
$this->state === ReleaseState::Incompatible ? 'inkompatibel' : 'ungetestet'
);
}
```
Nachher: der Block ist gelöscht. Hat Task 9 `refusal()` bereits auf eine Fähigkeitsliste umgestellt (`refusal(array $features)` o. Ä.), dieselben Wortlaute in der dortigen Textquelle verwenden.
Run: `vendor/bin/phpunit tests/Unit/Integrations/Divi/Release`
Expected (GREEN): grün.
- [ ] **Step 7: Verdrahtung in `Plugin.php`**
Vorher (`src/Plugin.php`):
```php
$divi = new DiviIntegration(
$schema,
$changeLog,
$libraryWritable,
libraryVisible: $libraryVisible,
diviWritable: $diviWritable,
libraryInsertable: $libraryInsertable,
themeBuilderWritable: $themeBuilderWritable,
themeBuilderVisible: $themeBuilderVisible
);
```
Nachher:
```php
// E3: lit-site-status and the Divi tab name the tools each feature affects; the registry exists only after
// the Divi integration, so it is asked at call time.
$allAbilities = static function () use (&$registry): array {
return $registry instanceof IntegrationRegistry ? $registry->abilities() : [];
};
$divi = new DiviIntegration(
$schema,
$changeLog,
$libraryWritable,
libraryVisible: $libraryVisible,
diviWritable: $diviWritable,
libraryInsertable: $libraryInsertable,
themeBuilderWritable: $themeBuilderWritable,
themeBuilderVisible: $themeBuilderVisible,
allAbilities: $allAbilities
);
```
Tab und Hinweis, Vorher:
```php
$tabs = [
new ConnectionTab($status),
new AccessTab($access, new ConnectionRepository()),
new ToolsTab($registry, $policy),
new LogTab($changeLog, $limits, $registry),
new LimitsTab(),
new DiviTab(log: $changeLog),
];
```
```php
if (is_admin()) {
self::$settingsPage->register();
(new AdminNotices($status))->register();
}
```
Nachher:
```php
$diviTab = new DiviTab(log: $changeLog, overview: $divi->featureOverview());
$tabs = [
new ConnectionTab($status),
new AccessTab($access, new ConnectionRepository()),
new ToolsTab($registry, $policy),
new LogTab($changeLog, $limits, $registry),
new LimitsTab(),
$diviTab,
];
```
```php
if (is_admin()) {
self::$settingsPage->register();
(new AdminNotices($status))->register();
$diviTab->registerNotice();
}
```
Hat Task 3 Tab und Komponente schon nach `DiviIntegration::boot()` verlegt (Step 0 zeigt es), dort statt in `Plugin.php`: `new DiviTab(log: …, overview: $this->featureOverview())`, `registerNotice()` im selben `is_admin()`-Zweig und `new DiviEnvironmentComponent(overview: $this->featureOverview())` an der Stelle, die Task 2 anlegt; in `Plugin.php` bleibt nur der `$allAbilities`-Block und der Parameter `allAbilities:`.
- [ ] **Step 8: Gesamtlauf und Lint**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint`
Expected: alle Tests grün, phpcs ohne Meldung (jede Zeile ≤ 120 Zeichen; die langen i18n-Texte im Tab tragen das bestehende `phpcs:ignore Generic.Files.LineLength.TooLong`).
- [ ] **Step 9: Commits**
Zwei Commits, nur diese Dateien stagen, Commit wie in den Global Constraints:
```bash
git add src/Integrations/Divi/Release/FeatureOverview.php src/Integrations/Divi/DiviEnvironmentComponent.php \
src/Integrations/Core/SiteStatusAbility.php src/Integrations/Divi/DiviIntegration.php src/Admin/DiviTab.php src/Plugin.php \
tests/Support/FeatureFakeTool.php tests/Support/MatrixFixtures.php \
tests/Unit/Integrations/Divi/Release/FeatureOverviewTest.php tests/Unit/Integrations/Divi/DiviEnvironmentComponentTest.php \
tests/Unit/Integrations/Core/SiteStatusAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php \
tests/Unit/Admin/DiviTabTest.php
git commit -m "feat: lit-site-status, Admin-Tab und Admin-Hinweis zeigen Divi-Fähigkeiten mit Zustand, Quelle, Problemen und betroffenen Werkzeugen" -m "Co-Authored-By: Claude <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
<?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
diff --git a/docs/guides/overview.md b/docs/guides/overview.md
--- a/docs/guides/overview.md
+++ b/docs/guides/overview.md
@@ -65,8 +65,22 @@ Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in ein
-## Divi-Version nicht freigegeben
+## Divi-Fähigkeit nicht freigegeben
-Antwortet ein schreibendes Divi-Werkzeug mit `divi_untested`, ist die installierte Divi-Version für LIT MCP noch nicht
-geprüft; bei `divi_incompatible` hat Divi Stellen geändert, die LIT MCP nutzt. In beiden Fällen wurde nichts
-geändert. Lesende Divi-Werkzeuge funktionieren weiter und melden den Zustand in `warnings`; `lit-site-status` zeigt
-ihn unter `components` → `divi` → `release`. Den Nutzer bitten, unter Einstellungen → LIT MCP → Divi die Prüfung
-auszuführen und die Version freizugeben (`divi_untested`) bzw. LIT MCP zu aktualisieren (`divi_incompatible`).
+LIT MCP gibt jede Divi-Fähigkeit einzeln frei, z. B. „Seiten lesen“, „Seiten schreiben“, „Presets schreiben“ oder
+„Theme Builder lesen“. Nach einem Divi-Update geschieht das automatisch, wenn alle Divi-Stellen, die eine Fähigkeit
+nutzt, unverändert sind; sonst nach einer Prüfung durch einen Administrator. Nur Werkzeuge, die eine nicht nutzbare
+Fähigkeit brauchen – lesende wie schreibende –, lehnen ab; alle anderen Divi-Werkzeuge laufen weiter, ebenso
+`lit-content-create` ohne `divi`.
+
+Antwortet ein Werkzeug mit `divi_untested`, ist eine Fähigkeit, die es braucht, für die installierte Divi-Version noch
+nicht geprüft; bei `divi_incompatible` hat Divi Stellen geändert, die LIT MCP dafür nutzt. In beiden Fällen wurde
+nichts geändert. Vorgehen:
+
+1. Den Aufruf nicht wiederholen: er scheitert, bis ein Administrator eingreift.
+2. `lit-site-status` lesen. Unter `components` → `divi` → `features` steht je Fähigkeit der Zustand (`released`,
+ `untested`, `incompatible`), die Quelle (`code`, `database`, `auto`), die Probleme (`problems`) und die betroffenen
+ Werkzeuge (`tools`).
+3. Ein anderes Werkzeug nutzen, das die betroffene Fähigkeit nicht braucht – ist z. B. nur „Seiten schreiben“
+ betroffen, funktioniert `lit-divi-outline` weiter, und der Nutzer kann die Änderung selbst im Divi-Builder machen.
+4. Den Nutzer informieren: bei `divi_untested` soll ein Administrator unter Einstellungen → LIT MCP → Divi die
+ Prüfung ausführen und die Version freigeben; bei `divi_incompatible` muss LIT MCP aktualisiert werden. Die Sperre
+ nicht über andere Werkzeuge umgehen.
```
```diff
diff --git a/docs/guides/divi-structure.md b/docs/guides/divi-structure.md
--- a/docs/guides/divi-structure.md
+++ b/docs/guides/divi-structure.md
@@ -40,2 +40,10 @@ Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert
+## Wenn ein Divi-Werkzeug ablehnt
+
+`divi_untested` oder `divi_incompatible` heißt: eine Divi-Fähigkeit, die das Werkzeug braucht (z. B. „Seiten
+schreiben“), ist für die installierte Divi-Version nicht freigegeben. Das gilt je Fähigkeit – `lit-divi-outline`
+kann weiter lesen, auch wenn `lit-divi-insert` ablehnt. `lit-site-status` nennt unter `components` → `divi` →
+`features` die betroffenen Fähigkeiten und Werkzeuge; den Nutzer informieren und nicht wiederholen (Vorgehen:
+`lit-guide` topic `overview`, Abschnitt „Divi-Fähigkeit nicht freigegeben“).
+
## Was nicht bearbeitet wird
```
```diff
diff --git a/docs/guides/workflows.md b/docs/guides/workflows.md
--- a/docs/guides/workflows.md
+++ b/docs/guides/workflows.md
@@ -24,2 +24,14 @@
+## Ein Divi-Werkzeug lehnt ab
+
+Antwortet ein Divi-Werkzeug mit `divi_untested` oder `divi_incompatible`, ist nur eine Fähigkeit gesperrt, nicht Divi
+insgesamt:
+
+1. `lit-site-status` lesen: `components` → `divi` → `features` nennt je Fähigkeit Zustand, Quelle, Probleme und die
+ betroffenen Werkzeuge.
+2. Mit Werkzeugen weiterarbeiten, deren Fähigkeit `released` ist (z. B. Seiten lesen mit `lit-divi-outline`), und den
+ gesperrten Teil auslassen.
+3. Dem Nutzer sagen, welche Fähigkeit gesperrt ist: bei `untested` muss ein Administrator unter Einstellungen → LIT
+ MCP → Divi die Prüfung ausführen und freigeben, bei `incompatible` muss LIT MCP aktualisiert werden.
+
## Bibliothek und globale Module
```
Die übrigen Guides (`divi-library`, `theme-builder`, `divi-design-system`, `divi-shortform`, `divi-attrs`, `divi-placeholders`) enthalten keine Aussage zur Freigabe; `grep -rn "freigegeben\|ungetestet\|divi_untested\|divi_incompatible\|Schreiben ist gesperrt" docs/guides` findet nach der Änderung nur noch `overview.md` (neuer Abschnitt), `divi-structure.md` (neuer Abschnitt), `workflows.md` (neuer Abschnitt) und die „gesperrt“-Sätze in `divi-library.md` (Zeile 124/125), die die Tools-Tab-Sperre meinen und bleiben.
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit tests/Unit/Guides tests/Unit/Integrations/Divi/Release/FeatureTextsTest.php`
Expected (GREEN): grün (`ToolNamingTest`: nur `lit-site-status`, `lit-divi-outline`, `lit-divi-insert`, `lit-content-create` als direkte Namen; `FeatureTextsTest` findet keinen Pauschalsatz mehr in `src/` und `docs/guides/`; setzt voraus, dass Task 10 Step 6 die `ReleaseStatus`-Texte umgestellt hat).
- [ ] **Step 2: Entscheidungsnotiz anlegen**
`docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md` (vollständig):
@@NOTE@@
- [ ] **Step 3: `divi-release-smoke.sh` erweitern**
Prüfung auf einen vorhandenen Testhook: Es gibt keinen Filter und keine Konstante, die den Release-Zustand von außen beeinflusst; das Skript nutzt seit 2026-09-30 den State-Cache (Transient `lit_mcp_divi_state`) als einzigen Hebel. Der bleibt der Weg: Ein zusätzliches Problem im Cache kann die Sperre nur **verschärfen** (nie eine Fähigkeit freigeben), ändert keine Divi-Datei und nicht `fingerprints.json`, gilt nur für diesen Schlüssel (`stateKey()`) und höchstens zehn Minuten, und das Skript löscht den Transient im EXIT-Trap. Ein neuer Filter im Produktionscode (`lit_mcp_divi_reference` o. ä.) wurde verworfen: er wäre ein zusätzlicher Weg, die Referenz und damit die Sperre zu beeinflussen, ohne dass ein Test ihn braucht.
Neue Prüfungen: (1) alle elf Fähigkeiten `released`, Quelle `code`, ohne Probleme, mit Werkzeugen (`page-write` → `lit-divi-insert`, `page-read` → `lit-divi-outline`, `preset-write` → `lit/divi-presets-…`); (2) simuliertes Problem für `page-write`: Lesen, Katalog und Design-System laufen ohne Hinweis, `lit-divi-insert` und `lit-content-create` mit `divi` werden mit `divi_incompatible` und `features` abgelehnt, `lit-content-create` ohne `divi` läuft; `lit-site-status` zeigt nur `page-write` als `incompatible` (Quelle `null`, Problemtext, Tool); Admin-Hinweis und Tab nennen Fähigkeit und Tools; (3) zurück auf freigegeben; (4) `wp lit-mcp divi-check` gibt elf freigegebene Fähigkeiten aus, rechnet frisch (ein vergifteter Cache wird nicht übernommen) und endet mit Exit 0.
Vollständiges neues Skript (ersetzt die Datei; Aufbau, `lib.sh`-Nutzung, Cleanup und die Prüfungen 1–2 und 5 des bisherigen Skripts bleiben erhalten, 3–4 sind auf Fähigkeiten umgestellt):
```bash
#!/usr/bin/env bash
# End-to-end checks of the Divi release gate (spec 2026-09-29-divi-absicherung) and its per-feature matrix
# (plan 2026-10-18-divi-faehigkeiten). Runs INSIDE the DDEV web container (Divi 5.15.1 must be the active theme):
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh
# Leaves nothing behind: pages, the check report, the check lock, the state transient and the application password
# are removed again. Divi's files and fingerprints.json are never touched: a deviation is simulated by adding a
# problem to the cached state (transient lit_mcp_divi_state, which only ever locks more, never less).
# Only runs with the plugins postlos and asynclistry deactivated (Monolog 2 / deprecation output of WP-CLI).
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
CREATED_POSTS=()
BASE=http://localhost
cleanup() {
for id in "${CREATED_POSTS[@]}"; do
wp post delete "$id" --force >/dev/null 2>&1 || true
done
wp option delete lit_mcp_divi_check_report lit_mcp_divi_check_lock >/dev/null 2>&1 || true
wp transient delete lit_mcp_divi_state >/dev/null 2>&1 || true
cleanup_app_passwords_for "$ADMIN_LOGIN"
}
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
check_pages() { wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count; }
# The ids of the eleven features (plan decision D1), sorted.
FEATURE_IDS='["catalog","design-read","dynamic-sources","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write"]'
# Adds a problem to one feature in the cached state, as if its check had failed. Needs the cache entry
# ["key" => stateKey, "features" => [feature id => list<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
bash -c '
cd /home/janniclas/Projekte/WordPress
was=""
for p in postlos asynclistry; do
if ddev wp plugin is-active "$p" >/dev/null 2>&1; then was="$was $p"; ddev wp plugin deactivate "$p"; fi
done
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh; rc=$?
for p in $was; do ddev wp plugin activate "$p"; done
exit $rc
'
```
Expected: `ok - …`-Zeilen für Manifeste, Status (elf Fähigkeiten), Admin-Prüfung (REST), anonyme Ablehnung, Lesen trotz gesperrtem `page-write`, abgelehnten Insert, abgelehntes `content-create` mit `divi`, `content-create` ohne `divi`, Status mit einer inkompatiblen Fähigkeit, Admin-Hinweis und Tab, Wiederfreigabe, CLI mit elf freigegebenen Fähigkeiten; `ALL CHECKS PASSED`; Exit-Code 0; `ddev wp plugin list --status=active --field=name` zeigt danach `postlos` und `asynclistry` wieder, wenn sie vorher aktiv waren.
Danach prüfen, dass nichts übrig blieb: `ddev wp post list --post_type=page --post_status=any --s="LIT Freigabe Smoke" --field=ID` (leer), `ddev wp transient get lit_mcp_divi_state` (kein vergifteter Eintrag: ohne Problemtext „Smoke“), `ddev wp option get lit_mcp_divi_check_lock` (leer).
Scheitert ein Schritt: erst klären, ob Skript oder Produktionscode falsch liegt. Mögliche Abweichungen: Form des State-Caches (`simulate_problem` meldet „state cache missing or in another format“ – dann die Cache-Form aus Task 6 übernehmen), Name der Fehlerdetails (`features`), Ort der Fähigkeitsnennung in `message`/`hint`, Ausgabe von `DiviTab::render()` unter WP-CLI (braucht `wp-admin/includes/template.php`). Nicht die Prüfung lockern, sondern die Ursache beheben.
- [ ] **Step 6: Gesamtlauf, Lint, weitere Smokes**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint`
Expected: alle Tests grün (Stand vor diesem Zwischenschritt 2624 plus die neuen Tests aus Tasks 1–10; Task 10 allein bringt 30, Task 11 einen), phpcs ohne Meldung.
Dann nacheinander mit denselben Plugin-Schritten wie in Step 5 (jeweils mit `run_in_background`): `divi-write-smoke.sh`, `divi-presets-smoke.sh`, `divi-presets-assign-smoke.sh` (Regression der Schreib-Tools nach Task 7/9), außerdem `divi-library-smoke.sh` und `tb-smoke.sh` (Fähigkeiten `library-*`, `tb-*`); alle enden mit `ALL CHECKS PASSED`. Das Plugin-Skript je Smoke durch den Dateinamen ersetzen:
```bash
bash -c '
cd /home/janniclas/Projekte/WordPress
was=""
for p in postlos asynclistry; do
if ddev wp plugin is-active "$p" >/dev/null 2>&1; then was="$was $p"; ddev wp plugin deactivate "$p"; fi
done
rc=0
for s in divi-write-smoke divi-presets-smoke divi-presets-assign-smoke divi-library-smoke tb-smoke; do
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/$s.sh || { rc=$?; echo "FAILED: $s"; break; }
done
for p in $was; do ddev wp plugin activate "$p"; done
exit $rc
'
```
- [ ] **Step 7: Commits**
Task 10 ist bereits committet (Step 9 dort). Hier zwei Commits, nur die genannten Dateien stagen, Commit wie in den Global Constraints:
```bash
git add docs/guides/overview.md docs/guides/divi-structure.md docs/guides/workflows.md \
docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md tests/Unit/Integrations/Divi/Release/FeatureTextsTest.php
git commit -m "docs: Guides und Entscheidungsnotiz zur Divi-Freigabe je Fähigkeit" -m "Co-Authored-By: Claude <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.