Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14071 lines
657 KiB
Markdown
14071 lines
657 KiB
Markdown
# 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() <geändert>', $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.
|
||
|