refactor(compat): Capability-Interface, DiviFeature implementiert es

Grundlage der generischen Kompatibilitätsschicht für Fremd-Plugins mit inoffiziellen Schnittstellen (Plan docs/superpowers/plans/2026-11-06-kompatibilitaetsschicht.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 12:03:30 +01:00
co-authored by Claude Opus 5.5
parent 691231f776
commit 9beb55e228
4 changed files with 147 additions and 1 deletions
@@ -0,0 +1,79 @@
# Kompatibilitätsschicht für Fremd-Plugins (Phase 0–2)
Stand 2026-11-06, Branch `feat/compatibility-layer`. Gate 1: voller Architektur-Entwurf (Benutzerentscheidung), weil Pods künftig
komplett über den Kern laufen soll und WP Super Cache direkt folgt.
**Regeln für jeden Task:** zuerst ein fehlschlagender Test, dann die kleinste Änderung; nach jedem Task `composer test` grün und
`composer lint` ohne neue Befunde (zwei Warnungen bestehen schon: `wp-lit-mcp.php:6`, `RegisteredMetaSourceTest.php:78`).
`DiviBoundaryTest` und `CoreIndependenceTest` laufen immer mit. Ein roter Divi-Test heißt anhalten. Verschiebe-Tasks sind
mechanisch (Namespace + `use`), keine Verhaltensänderung. Keine Commits durch Umsetzungs-Agents (Gate 2 je Commit-Block).
## Entscheidungen
- Kern `src/Compatibility/` (Namespace `LIT\MCP\Compatibility`), importiert kein Divi (`CoreIndependenceTest`).
- `Capability` (`id()`, `label()`, `isWrite()`); `DiviFeature implements Capability`, `id()` = `value`.
- Kein eigenes `RequiresCapabilities`: Tools deklarieren Fähigkeits-IDs über das vorhandene `DeclaresRequirements`.
- Tier: `ReportOnly` (nie sperren), `LockOnFailure` (hartes Problem sperrt, sonst frei; `signatures:false`), `ReleasePerVersion`
(nur Divi; Matrix kommt per Closure aus `ReleaseGate::status()->matrix`). ReleaseStore, Resolver-Workflow, WriteProbe, CLI,
FeatureSets bleiben Divi-eigen.
- Gates: `CapabilityGate` überspringt IDs von `ReleasePerVersion`-Profilen, prüft IDs anderer Profile über `Compatibility::lockOf`
und sperrt IDs, die kein Profil kennt. `DiviFeatureGate` ignoriert Nicht-Divi-IDs wieder. Umstellung atomar in 1.13.
- Pods-IDs mit Präfix `pods-`, WPSC mit `wpsc-`; Divi-IDs bleiben unpräfixiert. Eindeutigkeitstest über alle Profile.
- Pods und WPSC werden `Integration`en ohne Abilities, die `ProvidesCompatibility` implementieren; leere Gruppen dürfen weder im
Tools-Tab noch in den Server-Anweisungen erscheinen.
- Admin: ein gemeinsamer Reiter „Compatibility“ (Slug `compatibility`); Divi-Freigabe wird Sektion darin
(`lit_mcp_compat_sections`). Kein Redirect vom alten Slug `divi`, `DiviTab` wird nicht umbenannt.
- WPSC: Hook `lit_mcp_changed` in `ChangeLog::record` (nur `result = ok`, eigenes try/catch); Listener nutzt `wp_cache_post_edit`
bzw. `wp_cache_clear_cache(blog)`; kein `lit/cache-clear`.
## Phase 0 – Sicherheitsfixes (erledigt: 8f3a4e7, 234fd1e, 691231f)
## Phase 1 – Kern, Pods, Reiter
| # | Ziel und Dateien | Zuerst rot | Akzeptanz | Modell |
|---|---|---|---|---|
| 1.1 | `src/Compatibility/Capability.php`; `DiviFeature implements Capability` + `id()` | `DiviFeature::PageRead instanceof Capability`, `id()==='page-read'` | Divi-Tests grün | haiku |
| 1.2 | `ReleaseState`, `SourceFingerprint`, `LineDiff` nach `src/Compatibility`; `'Compatibility'` in `CORE_PATHS` | `CoreIndependenceTest::testEveryScannedPathExists`; verschobene Tests unter neuem Namespace | kein `Integrations\Divi`-Import im Kern | haiku |
| 1.3 | `Requirement` (`features(): list<Capability>`), `Call` (+`accepts()` aus `ManifestCheck::accepts`, `signatureKey`), `ConstantRequirement`, `Mirror`, `SignatureFingerprint` verschieben; `ManifestCheck::accepts` delegiert | `CallTest` am neuen Ort; `ManifestCheckTest` Regression | Divi-Suite grün | sonnet |
| 1.4 | `Problem`, `FeatureStatus` verschieben (Capability); `Problem::fromArray(mixed, Closure(string): ?Capability)`; `ReleaseResolver::cached` übergibt `DiviFeature::tryFrom(...)`; Transient-Format bleibt | `ProblemTest` unbekannte ID → null; `ReleaseResolverTest` | Transient-Rundlauf unverändert | sonnet |
| 1.5 | `CompatibilityMatrix` generisch (`list<Capability>`), `uniform`/`allIncompatible` mit Capabilities, `fromProblems(caps, problems, ?source, automatic)` aus `ReleaseResolver` (~89-129); `SOURCE_*` auf die Matrix, Aliase in `ReleaseStatus`; Aufrufer `ReleaseGate`, `ReleaseStatus`, `ReleaseResolver`, `DiviCheckCommand`, `tests/Support` | `CompatibilityMatrixTest` mit Fake-Capability `pods-x`; `fromProblems` gegen bisherigen Resolver-Fall | `ReleaseResolverTest`, `ReleaseStatusTest` unverändert grün | sonnet |
| 1.6 | `DiviAreas` (PAGES, LIBRARY, THEME_BUILDER, DESIGN, CATALOG) aus `Manifest` | `DiviAreasTest`; `ManifestTest` | reine Umbenennung | haiku |
| 1.7 | `Manifest` (Capabilities), `GlobalRequirement` (`isset($GLOBALS[name])`, hart), `ManifestCheck` nach `src/Compatibility`, ohne Divi-Defaults, Option `signatures`; `Divi\Release\DiviManifestCheck::create()`; Aufrufer `ReleaseResolver`, `CompatibilityCheck`, `DiviCompatibility`, Tests mit `new ManifestCheck()` | `ManifestCheckTest` mit `signatures:false` ohne reference-missing; `GlobalRequirement` fehlt = hartes Problem; `DiviManifestCheckTest` | Kern ohne Divi; Divi-Release-Tests unverändert | sonnet |
| 1.8 | `Tier`, `Profile` (id, label, tier, capabilities, manifests, ?matrix-Closure, ?version-Closure, ?minVersion, aktiv-Prüfung), `Compatibility` (matrix memoisiert, `lockOf`, `assert`, `usable`, `problems`, `capability(id)`, `active`) | `CompatibilityTest` mit Fake-Profilen je Tier, Memoisierung, unbekannte ID → null, minVersion sperrt alles | rein bis auf `defined()`/`get_file_data` | sonnet |
| 1.9 | `CapabilityLocked` (RuntimeException implements ToolRefusal, `compat_incompatible`, 423, details features/problems, Fabrik für unbekannte IDs) | `CapabilityLockedTest` | analog `DiviLockedTest` | sonnet |
| 1.10 | `CapabilityGate implements AbilityGuard` (Vorlage `DiviFeatureGate`), Log `refused` nur bei Schreibtools; noch nicht verdrahtet | `CapabilityGateTest`: Pods-ID gesperrt/frei, Divi-ID ignoriert, unbekannt gesperrt | annotate/outputSchema reichen durch | sonnet |
| 1.11 | `ProvidesCompatibility`, `IntegrationRegistry::compatibilities()` (auch nicht verfügbare), `DiviProfile::profile()`, `DiviIntegration implements ProvidesCompatibility` | `IntegrationRegistryTest`, `DiviIntegrationTest` | Divi neutral | sonnet |
| 1.12 | `PodsFeature` (`pods-schema-read`, `pods-field-read`, `pods-field-write`, `pods-field-permissions`), `PodsCompatibility::profile()` (LockOnFailure, PODS_VERSION, min 2.8, Calls mit Argumentzahlen gegen Pods 3.3.9.2), `PodsIntegration` ohne Abilities | `PodsCompatibilityTest`; Registry-Test: leere Integration nicht in `grouped()`/`labels()`/Instructions | keine leere Gruppe sichtbar | sonnet |
| 1.13 | `CompatibilityComponent` (3 Zustände, Zeile je Fähigkeit); `EnvironmentProbe` ohne Pods (TEC bleibt); `Plugin`: Guards `[DiviFeatureGate, CapabilityGate]`, Komponenten per Closure aus der Registry; 0.3-Umzug (`unknownIds` entfällt, Test zieht in `CapabilityGateTest`); ID-Eindeutigkeit | `EnvironmentProbeTest`; Zwei-Gate-Test über `AbilityRegistrar::argsFor`; `PluginTest` | Reihenfolge Divi, Pods, TEC | sonnet |
| 1.14 | `PodsFieldSource` + Schema-Wahl über `usable(PodsFeature::…)`; `isAvailable()` entfällt; Feldrechte über `usable(FieldPermissions)`, sonst fail-closed; 0.2-Bedingung = `active && ! usable(SchemaRead)` | `PodsFieldSourceTest` je Fähigkeit; `PluginTest` | alle Pods-Tests grün | sonnet |
| 1.15 | `CompatibilityTab` + `CompatibilitySection` (Filter `lit_mcp_compat_sections`, lazy); Zustand (Wort + Icon) und Probleme je Fähigkeit für Nicht-`ReleasePerVersion`-Profile | `CompatibilityTabTest`: drei Zustände, Sektion eingebettet, escaped | `CoreIndependenceTest` grün | sonnet |
| 1.16 | `DiviTab implements CompatibilitySection` (`title()` „Divi“); Slug/Redirects/`requested()`/Notice auf `compatibility`; `DiviIntegration` hängt über `lit_mcp_compat_sections` ein | `DiviTabTest`, `DiviIntegrationTest` | Release/Withdraw/Orphans/Render unverändert | sonnet |
| 1.17 | Links/Texte: `DiviCheckRoute`, `ReleaseStatus:24`, `docs/guides/overview.md:96`, `divi-release-smoke.sh`, Docblock `DiviCheckCommand` | `DiviCheckRouteTest` `tab=compatibility`; `ReleaseStatusTest` „→ Compatibility“ | DDEV-Smoke grün | haiku |
| 1.18 | Reiter in `Plugin::boot` registrieren (nach `LogTab`); Pods-Smoke erweitern | `PluginTest` Tab-Liste | DDEV: `compatibility ok`, Pods ohne Probleme; hohe minVersion → Pods-Felder schreibgeschützt | sonnet |
## Phase 2 – WP Super Cache 3.1.5
| # | Ziel und Dateien | Zuerst rot | Akzeptanz | Modell |
|---|---|---|---|---|
| 2.1 | Hook `lit_mcp_changed(ability, objectType, objectId, logId)` in `ChangeLog::record`, nur bei `ok`, auch bei fehlgeschlagenem Insert; werfender Listener → `Log::failure` | `ChangeLogTest` | bestehende Fälle grün | sonnet |
| 2.2 | `SuperCacheFeature` (`wpsc-purge-post`, `wpsc-purge-all`), `SuperCacheCompatibility::profile()` (LockOnFailure, `WPCACHEHOME`, Version per `get_file_data`, `GlobalRequirement('cache_path')`), `SuperCacheIntegration` ohne Abilities | `SuperCacheCompatibilityTest` | Eindeutigkeitstest umfasst `wpsc-` | sonnet |
| 2.3 | `SuperCacheListener` an `lit_mcp_changed`: nur bei `usable()` (sonst Log-Zeile) und `$GLOBALS['cache_enabled']`; öffentlicher Posttyp außer attachment → `wp_cache_post_edit($id)`; sonst `wp_cache_clear_cache(is_multisite() ? get_current_blog_id() : 0)`; eigenes try/catch | `SuperCacheListenerTest` mit injizierten Closures | lint sauber | sonnet |
| 2.4 | Verdrahtung + Absatz in `docs/guides/overview.md` | `PluginTest` | `lit-site-status` listet WPSC | haiku |
| 2.5 | `tests/Integration/super-cache-smoke.sh` (Feld-Update leert Beitrag, Einstellung leert alles; `et_pb_layout` öffentlich?) | Smoke rot ohne 2.3 | DDEV grün | sonnet |
## Commit-Schnitt
4 `refactor(compat): Capability und DiviFeature` (1.1) · 5 `refactor(compat): reine Release-Klassen nach src/Compatibility`
(1.2–1.4) · 6 `refactor(compat): CompatibilityMatrix generisch mit fromProblems` (1.5) · 7 `refactor(compat): Manifest und
ManifestCheck generisch, DiviAreas, DiviManifestCheck` (1.6–1.7) · 8 `feat(compat): Tier, Profile, Compatibility,
CapabilityLocked, CapabilityGate` (1.8–1.10) · 9 `feat(compat): Profile aus Integrationen, Divi- und Pods-Profil` (1.11–1.12) ·
10 `feat(compat): Gates, Status und Pods über den Kern verdrahten` (1.13–1.14) · 11 `feat(admin): Reiter „Compatibility“ mit
Divi als Abschnitt` (1.15–1.18) · 12 `feat(changelog): Hook lit_mcp_changed` (2.1) · 13 `feat(cache): WP Super Cache über den
Kern anbinden` (2.2–2.4) · 14 `test(cache): Integrations-Smoke` (2.5)
## Risiken
Vergessene `use`-Zeilen in Verschiebe-Tasks (voller Testlauf je Task); Lücke bei unbekannten IDs nur, wenn `CapabilityGate`
fehlt (PluginTest prüft beide Guards); leere Integrationen verändern Registry-Ausgaben (1.12 prüft zuerst); `signatures:false`
prüft nur Existenz und Argumentzahl; `wp_cache_post_edit` ist keine dokumentierte API (Wegfall sperrt nur den Purge, sichtbar
im Reiter und in `lit-site-status`).
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/**
* One capability of an integration that a third-party plugin update may break separately. id() is unique across all
* integrations, so a bare ID is enough to find its capability.
*/
interface Capability
{
/** Stable, globally unique ID such as 'page-read' or 'pods-field-write'. */
public function id(): string;
/** Name for messages and the admin tab (German). */
public function label(): string;
/** Whether the capability changes data. */
public function isWrite(): bool;
}
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Compatibility\Capability;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions. // The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
@@ -13,7 +15,7 @@ defined('ABSPATH') || exit;
* What the plugin can do with Divi, as far as a Divi update may break it separately: every manifest entry (call, * 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. * mirror, constant) belongs to one or more features, and the release gate decides per feature.
*/ */
enum DiviFeature: string enum DiviFeature: string implements Capability
{ {
case PageRead = 'page-read'; case PageRead = 'page-read';
case PageWrite = 'page-write'; case PageWrite = 'page-write';
@@ -32,6 +34,11 @@ enum DiviFeature: string
case ThemeBuilderRead = 'tb-read'; case ThemeBuilderRead = 'tb-read';
case ThemeBuilderWrite = 'tb-write'; case ThemeBuilderWrite = 'tb-write';
public function id(): string
{
return $this->value;
}
/** Whether the feature changes data (the seven *Write cases and FontUpload). */ /** Whether the feature changes data (the seven *Write cases and FontUpload). */
public function isWrite(): bool public function isWrite(): bool
{ {
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Compatibility\Capability;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use PHPUnit\Framework\TestCase;
final class CapabilityTest extends TestCase
{
public function testDiviFeatureIsACapability(): void
{
self::assertInstanceOf(Capability::class, DiviFeature::PageRead);
}
public function testTheIdIsTheStableValueOfTheFeature(): void
{
self::assertSame('page-read', DiviFeature::PageRead->id());
self::assertSame('tb-write', DiviFeature::ThemeBuilderWrite->id());
}
public function testTheIdsAreUniqueAcrossTheDiviFeatures(): void
{
$ids = array_map(static fn (Capability $capability): string => $capability->id(), DiviFeature::cases());
self::assertCount(count(DiviFeature::cases()), array_unique($ids));
}
public function testWriteAndLabelStayAsTheyWere(): void
{
self::assertFalse(DiviFeature::PageRead->isWrite());
self::assertTrue(DiviFeature::PageWrite->isWrite());
self::assertSame('Seiten lesen', DiviFeature::PageRead->label());
}
}