24 Commits
Author SHA1 Message Date
Jan-Niclas Loosen 3b3c0df1f1 Merge branch 'feat/fastest-cache' 2026-11-06 19:57:55 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 6147ba8087 chore(release): Version 1.2.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 19:57:55 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 bee9da3a86 feat(cache): WP Fastest Cache statt WP Super Cache
Die Cache-Integration war für WP Super Cache gebaut, eingesetzt wird aber
WP Fastest Cache. Profil (wpfc), Listener, Drosselung, lit/cache-clear und
Leeren-Probe laufen jetzt über die öffentlichen WPFC-Funktionen
wpfc_clear_post_cache_by_id/wpfc_clear_all_cache; die Probe leert nur die
Wegwerf-Seite (singleDeleteCache ohne Elternseiten) und prüft, dass WPFC
den Hook noch verarbeitet. Smoke fastest-cache-smoke.sh ersetzt den WPSC-Smoke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 19:57:55 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 e1fad3dffe chore(release): Version 1.2.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 19:10:19 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 1ac347e76f feat(cache): Tool lit/cache-clear zum gezielten Leeren des Caches
lit/cache-clear leert den Seiten-Cache von WP Super Cache für die ganze Website (scope all, Recht manage_options) oder einen Beitrag (scope post, Recht edit_post); Probelauf ohne confirm, Ausführen mit confirm: true. Das CapabilityGate sperrt das Tool, wenn WP Super Cache bricht; eine Sperre zur Laufzeit wird als Fehler gemeldet. Listener und Tool teilen die 30-s-Drosselung mit Nachlauf (SuperCachePurger); der Listener ignoriert die Einträge des Tools. Behoben vor dem Commit: die Verdrahtung des Purgers erfasste die Registry zu früh (immer gesperrt), jetzt per Referenz und mit Test über Plugin::integrations().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 19:05:21 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 d3e5d4fac0 chore(release): Version 1.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:00:32 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 7b80d861d8 Merge branch 'docs/divi-schleifen-pods-felder'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:59:38 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 f337a57f26 feat(admin): Reiter „Kompatibilität“ übersetzt und übersichtlicher
Sichtbarer Name „Kompatibilität“ (Slug compatibility bleibt) in Reiter, Hinweisen, Hilfe-Tab, CLI-Hilfe, Guides und readme. Oben eine Übersicht aller Plugins mit Gesamtzustand, Version, Zustand (Wort und Icon), Freigabe-Art in Klartext und Sprunglink; je Plugin erst Zustand und nächster Schritt, dann Aktionen, dann aufklappbare Details (offen bei Handlungsbedarf, ohne JavaScript). Tabellen mit Beschriftung und Kartenansicht bis 375 px, Buttons mit Plugin-Namen im zugänglichen Namen, Kontraste mit Reserve. Doku: Divi leert beim Prüfen den Cache einmal komplett.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:39:47 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 661d3945fd feat(cache): Verhaltensprobe und Smoke für WP Super Cache
SuperCacheProbe prüft beim Prüfen, ob wp_cache_post_edit die Cache-Datei einer Testseite entfernt; während der Probe ist nur der Einzelseiten-Purge erlaubt, der Pfad kommt aus home und es werden nur feste Dateinamen gelöscht. ProbeStep bündelt die Schrittlogik von Pods und WP Super Cache; ThrowawayPost hält Marker und Titel zentral und räumt auch private Probe-Seiten auf (WP Super Cache wie Pods beim Laden des Reiters). super-cache-smoke.sh prüft gegen das echte Plugin; lib.sh sperrt parallele Smoke-Läufe per flock, wp-config.php wird atomar wiederhergestellt. Doku: Host-Abhängigkeit des Einzel-Purges, WP-Cron für den Nachlauf, Komplett-Purge durch Divi beim Prüfen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:25:37 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 05bf890481 feat(cache): WP Super Cache über den Kompatibilitätskern anbinden
SuperCacheFeature, SuperCacheCompatibility (LockOnFailure, getestet 3.1.5, Version aus dem Plugin-Header) und SuperCacheIntegration ohne Tools. Der SuperCacheListener sammelt Änderungen über lit_mcp_changed und leert einmal am Request-Ende: einen Beitrag per wp_cache_post_edit, mehrere Beiträge oder seitenweite Änderungen per wp_cache_clear_cache (Multisite: eigener Blog). Komplett-Purges höchstens alle 30 s, mit genau einem Nachlauf per WP-Cron. Freigabe-Einträge leeren nichts. Uninstaller räumt Optionen, Transient und Cron-Hook ab; Reiter und lit-site-status zeigen das Profil automatisch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:50:35 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 c383b99e7d feat(changelog): Hook lit_mcp_changed
ChangeLog::record() löst nach jeder erfolgreichen oder teilweise gespeicherten Änderung lit_mcp_changed(ability, objectType, objectId, logId) aus, nie bei abgelehnten. Die Werte sind gekürzt wie im Protokoll; Ausnahmen von Listenern werden protokolliert und ändern das Ergebnis des Tools nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:50:12 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 1a294b7881 feat(pods): Verhaltensprobe als Prüfschritt
Beim Prüfen schreibt und liest PodsProbe auf einem Wegwerf-Entwurf ein Textfeld genau so, wie LIT MCP es tut, vergleicht die Werte und löscht den Entwurf wieder. Bevorzugt von Pods angelegte Typen; ohne Pod mit Textfeld gilt der Schritt als grün mit Hinweis „nicht geprüft“. Ein roter Schritt sperrt Pods über die Berichtssperre. Liegengebliebene Probe-Einträge räumt der Load-Hook des Reiters auf. Pods-Smoke um Probe und Bereinigung erweitert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:33:26 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 42aa4f8dc9 fix(compat): Bereinigung liegengebliebener Prüf-Einträge löschte laufende Prüfungen
Entwürfe haben in WordPress kein gültiges GMT-Datum (0000-00-00), deshalb hielt die Orphan-Bereinigung jeden markierten Prüf-Eintrag für alt und konnte eine laufende Divi-Prüfung abbrechen. Der neue Kern-Baustein ThrowawayPost setzt beim Anlegen ein echtes GMT-Datum, prüft das Alter über post_modified_gmt und kapselt Anlegen, Prüfen und Löschen (Anlegen jetzt innerhalb von try). WriteProbe nutzt ihn; Fehlermeldungen von WordPress gehen nur noch ins Error-Log. Live in DDEV belegt: frischer Eintrag bleibt, 11 Minuten alter wird gelöscht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:33:05 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 239d35c198 test(compat): Pods-Smoke für Reiter, Prüfroute und Sperre
Prüft gegen echtes Pods in DDEV: Status ohne Probleme, /pods-check speichert einen grünen Bericht im Format mit Schlüssel version, Reiter zeigt die Pods-Sektion, Freigabe nur für ungetestete Versionen und ohne Sperrwirkung, ein roter Bericht sperrt Pods-Felder bis zu seiner Entfernung. Stellt den Ausgangszustand wieder her.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:08:15 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 b40b81f585 refactor(divi): DiviTab geht im Reiter „Compatibility“ auf
Der eigene Divi-Reiter entfällt. DiviAdminExtras behält Admin-Hinweis, Orphan-Bereinigung vor der Ausgabe (lit_mcp_compat_profile_load), Hinweis zum nächsten Schritt und zur privaten Testseite (lit_mcp_compat_profile_intro), Zustandstexte mit Quelle (lit_mcp_compat_explanation), Automatik-Notiz und Visual-Builder-Hinweis (lit_mcp_compat_profile_extra) sowie die Spalten Quelle, Probleme und Betroffene Tools über die neue, gegen fehlerhafte Callbacks abgesicherte Erweiterung lit_mcp_compat_capability_columns. Texte, Doku, CSS (lit-mcp-compat-*) und Smoke auf den neuen Reiter umgestellt, tote divi_*-Hinweise entfernt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:32:29 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 85f4138b36 feat(admin): Prüfroute je Profil und Reiter „Compatibility“
Die generische CheckRoute (POST lit-mcp/v1/<id>-check, für Divi weiter /divi-check) ersetzt DiviCheckRoute: Lock je Profil mit Besitzer-Token, Bericht je Profil, Rücksprung auf den neuen Reiter und Hook lit_mcp_compat_changed (Divi lädt darauf seinen Freigabe-Status neu). Der Reiter „Compatibility“ zeigt je Plugin Zustand (Wort und Icon), Hinweis zur Stufe, Fähigkeiten, getestete und freigegebene Versionen und den letzten Bericht, mit Prüfen, Freigeben und Zurückziehen (Recht manage_options, Allowlist der Profil-ID, Nonce je Profil). Freigeben serverseitig nur mit frischem grünem Bericht. Der Load-Hook lit_mcp_compat_profile_load bereitet den Abbau des DiviTab vor, der bis dahin parallel läuft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:15:59 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 7d6a97a54e refactor(compat): Freigabe-Bausteine generisch
ReleaseStore, CheckReportStore, CheckReport, CheckStep und CompatibilityCheck liegen im Kern und arbeiten je Profil (Optionen lit_mcp_<id>_releases|_check_report; Divi behält Namen und Schlüssel divi_version). Divi hängt Katalog-, Format- und Schreibtest über Profile::extraSteps an. Ein roter Prüfbericht für den aktuellen Stand sperrt LockOnFailure-Profile bis zur nächsten grünen Prüfung; Sperrgründe nennen nur Schritt-Labels, Ausnahmen gehen ins Error-Log. Profile bekommt testedVersions, stateKey, extraSteps, manifestCheck und reportVersionKey und wird mit benannten Argumenten gebaut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:51:03 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 4bf0eedd6f docs(divi): Format der Feld-Optionen in Divi-Schleifen
Regel 4 nennt jetzt die erwarteten Werte: select_loop_meta_key = loop_<art>_meta_key_manual_custom_field_value plus loop_meta_key = Feldname, mit Beispiel für ein Pods-Feld. Dazu der Hinweis, dass Divi nur Post-Meta liest und Pods mit Tabellen-Speicherung immer leer bleibt. Anlass: Rückfrage eines MCP-Agents, dessen Schleifenfelder leer blieben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:46:23 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 c85131603b feat(compat): Profile, Gates und Pods über den Kompatibilitätskern
Neu: Tier, Profile, Compatibility, CapabilityLocked, CapabilityGate, CompatibilityComponent, ProvidesCompatibility, DiviProfile sowie PodsFeature, PodsCompatibility und PodsIntegration. Das CapabilityGate sperrt gesperrte und unbekannte Fähigkeits-IDs (übernimmt die Sperre unbekannter IDs vom DiviFeatureGate) und delegiert nur Divi ausdrücklich. Pods-Schema, -Lesen, -Schreiben und -Feldrechte laufen über usable(PodsFeature::…); Schreiben verlangt FieldRead und FieldWrite, damit ein Rollback keine Werte verliert. lit-site-status meldet Pods über die CompatibilityComponent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:26:54 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 b511ef6348 refactor(compat): Prüfbausteine nach src/Compatibility, Matrix und ManifestCheck generisch
Requirement, Call, ConstantRequirement, Mirror, Problem, FeatureStatus, CompatibilityMatrix, Manifest, ManifestCheck sowie Fingerprints und LineDiff liegen jetzt im divi-freien Kern. Neu: GlobalRequirement, CompatibilityMatrix::fromProblems(), ManifestCheck-Option signatures, DiviManifestCheck::create() und DiviAreas. Divi-Verhalten unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:03:30 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 9beb55e228 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>
2026-11-06 12:03:30 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 691231f776 fix(security): unbekannte Requirement-ID sperrt das Divi-Tool
Deklariert ein Tool über DeclaresRequirements eine ID, die keine Divi-Fähigkeit ist, wird der Aufruf mit divi_incompatible (423) gesperrt, statt ungesperrt zu laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:30:05 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 234fd1edac fix(security): Pods-Meta nur lesbar, wenn Pods aktiv, aber nicht nutzbar
Ist Pods aktiv, seine API aber nicht nutzbar, melden gesampelte und registrierte Meta-Schlüssel READONLY statt schreibbar. Sonst würden Pods-Felder (auch über Pods' register_meta-Integration) an Pods vorbei geschrieben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:30:05 +01:00
Jan-Niclas LoosenandClaude Opus 5.5 8f3a4e76eb fix(security): Pods-Feldrechte fail-closed
Fehlt pods_has_permissions() oder pods_permission(), gilt kein Pods-Feld mehr als unbeschränkt, sondern als nicht erlaubt (HIDDEN/READONLY).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:30:05 +01:00
205 changed files with 12880 additions and 2879 deletions
+92 -18
View File
@@ -132,19 +132,55 @@
vertical-align: text-bottom; vertical-align: text-bottom;
} }
/* Divi tab: the report is a plain list, the icon replaces the bullet. */ /* Tab "Kompatibilität": one white section per plugin under the overview table. */
.lit-mcp-divi-report { .lit-mcp-compat {
/* Jump links from the overview: keep the heading clear of the fixed admin bar. */
scroll-margin-top: 56px;
margin: 24px 0;
padding: 4px 20px 16px;
background: #fff;
border: 1px solid #c3c4c7;
overflow-wrap: break-word;
}
.lit-mcp-compat h3 {
margin: 1.2em 0 0.5em;
font-size: 14px;
}
.lit-mcp-compat-overview {
margin-bottom: 8px;
}
.lit-mcp-compat .notice.inline ul {
margin: 0 0 0.5em 2em;
list-style: disc;
}
.lit-mcp-compat-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.lit-mcp-compat-actions form {
margin: 0;
}
/* The report is a plain list, the icon replaces the bullet. */
.lit-mcp-compat-report {
list-style: none; list-style: none;
margin-left: 0; margin-left: 0;
} }
.lit-mcp-divi-report ul { .lit-mcp-compat-report ul {
list-style: disc; list-style: disc;
margin-left: 2em; margin-left: 2em;
} }
.lit-mcp-status-yes-alt .dashicons { .lit-mcp-status-yes-alt .dashicons {
color: #008a20; color: #007017;
} }
.lit-mcp-status-warning .dashicons { .lit-mcp-status-warning .dashicons {
@@ -155,14 +191,18 @@
color: #d63638; color: #d63638;
} }
.lit-mcp-status-minus .dashicons {
color: #50575e;
}
/* Folded help ("Wo finde ich das?") and the Divi detail table. */ /* Folded help ("Wo finde ich das?") and the Divi detail table. */
.lit-mcp-hint, .lit-mcp-hint,
.lit-mcp-divi-details { .lit-mcp-compat-details {
margin-top: 6px; margin-top: 6px;
} }
.lit-mcp-hint summary, .lit-mcp-hint summary,
.lit-mcp-divi-details summary { .lit-mcp-compat-details summary {
cursor: pointer; cursor: pointer;
} }
@@ -170,12 +210,18 @@
max-width: 40rem; max-width: 40rem;
} }
.lit-mcp-divi-details summary { .lit-mcp-compat-details summary {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
margin: 1em 0 0.5em; margin: 1em 0 0.5em;
} }
.lit-mcp-compat-details summary:focus-visible,
.lit-mcp-hint summary:focus-visible {
outline: 2px solid #2271b1;
outline-offset: 2px;
}
/* Only on phones: text that replaces a hidden column. */ /* Only on phones: text that replaces a hidden column. */
.lit-mcp-phone-only { .lit-mcp-phone-only {
display: none; display: none;
@@ -241,7 +287,7 @@
} }
.lit-mcp-hint summary, .lit-mcp-hint summary,
.lit-mcp-divi-details summary { .lit-mcp-compat-details summary {
padding: 6px 0; padding: 6px 0;
} }
} }
@@ -435,9 +481,14 @@
} }
} }
/* Divi tab: on phones each row of the feature table becomes a card named by data-label. */ /* Tab "Kompatibilität": on phones each row of the overview and feature tables becomes a card named by data-label. */
@media screen and (max-width: 782px) { @media screen and (max-width: 782px) {
.lit-mcp-divi-features thead { .lit-mcp-compat {
padding: 4px 12px 12px;
}
.lit-mcp-compat-features thead,
.lit-mcp-compat-overview thead {
position: absolute; position: absolute;
width: 1px; width: 1px;
height: 1px; height: 1px;
@@ -445,33 +496,52 @@
clip-path: inset(50%); clip-path: inset(50%);
} }
.lit-mcp-divi-features tr, .lit-mcp-compat-features tr,
.lit-mcp-divi-features td { .lit-mcp-compat-features th,
.lit-mcp-compat-features td,
.lit-mcp-compat-overview tr,
.lit-mcp-compat-overview th,
.lit-mcp-compat-overview td {
display: block; display: block;
} }
/* One card per row: a rule and room between them, the feature name first and bold. */ /* One card per row: a rule and room between them, the name first and bold. */
.lit-mcp-divi-features tr { .lit-mcp-compat-features tr,
.lit-mcp-compat-overview tr {
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid #c3c4c7; border-bottom: 1px solid #c3c4c7;
} }
.lit-mcp-divi-features td:first-child { .lit-mcp-compat-features th:first-child,
.lit-mcp-compat-overview th:first-child {
font-weight: 600; font-weight: 600;
} }
.lit-mcp-divi-features code { .lit-mcp-compat-features code {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.lit-mcp-divi-features td::before { .lit-mcp-compat-features th::before,
.lit-mcp-compat-features td::before,
.lit-mcp-compat-overview th::before,
.lit-mcp-compat-overview td::before {
content: attr(data-label) ": "; content: attr(data-label) ": ";
font-weight: 600; font-weight: 600;
} }
.lit-mcp-divi-features ul { .lit-mcp-compat-features ul {
margin: 0; margin: 0;
} }
/* Touch targets: the link in the overview and the check/release buttons. */
.lit-mcp-compat-overview a {
display: inline-block;
padding: 4px 0;
}
.lit-mcp-compat-actions .button {
min-height: 40px;
}
} }
/* Tab "Protokoll": filters in their own block above the table (core hides ".tablenav .actions" /* Tab "Protokoll": filters in their own block above the table (core hides ".tablenav .actions"
@@ -567,3 +637,7 @@
.lit-mcp-unit { .lit-mcp-unit {
margin-left: 4px; margin-left: 4px;
} }
.lit-mcp-compat-versions th {
width: 30%;
}
+14 -1
View File
@@ -96,7 +96,20 @@ Regeln und Fehlermeldungen:
unterstützten Schleife (ACF-Wiederholungsfeld oder leere Art) nennt die Meldung statt des Typs die rohe unterstützten Schleife (ACF-Wiederholungsfeld oder leere Art) nennt die Meldung statt des Typs die rohe
Abfrageart der Schleife; in solchen Schleifen ist jede `loop_*`-Option abgelehnt. Abfrageart der Schleife; in solchen Schleifen ist jede `loop_*`-Option abgelehnt.
4. Die Feld-Optionen (`loop_*_meta_key_manual_custom_field`) gibt es nur in der Vollform; für den Schlüssel gelten 4. Die Feld-Optionen (`loop_*_meta_key_manual_custom_field`) gibt es nur in der Vollform; für den Schlüssel gelten
dieselben Regeln wie bei `{{field:<meta_key>}}` (kein Schlüssel mit `_` am Anfang). dieselben Regeln wie bei `{{field:<meta_key>}}` (kein Schlüssel mit `_` am Anfang). Beide Einstellungen sind
nötig: `select_loop_meta_key` muss genau `loop_<art>_meta_key_manual_custom_field_value` sein (`<art>` = `post`,
`term` oder `user`), dann liest Divi den Feldnamen aus `loop_meta_key`. Fehlt `select_loop_meta_key` oder hat es
einen anderen Wert, bleibt die Ausgabe in Beitrags- und Term-Schleifen leer. Beispiel für das Pods-Feld `telefon`
in einer Beitrags-Schleife:
```json
{ "type": "text", "content": "$variable({\"type\":\"content\",\"value\":{\"name\":\"loop_post_meta_key_manual_custom_field\",\"settings\":{\"before\":\"\",\"after\":\"\",\"select_loop_meta_key\":\"loop_post_meta_key_manual_custom_field_value\",\"loop_meta_key\":\"telefon\"}}})$" }
```
Divi liest das Feld als Post-Meta. Pods-Felder mit Speicherung „meta“ (Standard bei Beitragstypen) funktionieren,
Beziehungs- und Dateifelder zeigen nur IDs. Bei Pods mit Tabellen-Speicherung bleibt die Ausgabe immer leer;
`lit/divi-dynamic-sources` mit `post_type` meldet das als „Pods mit Tabellen-Speicherung – Divi liest nur
Post-Meta“. Bleibt ein Feld leer, zuerst das prüfen, dann das Format.
5. Vor dem Setzen lesen: `lit/divi-dynamic-sources` mit `post_type`, `post_id` und `node` (Pfad-ID des Moduls aus 5. Vor dem Setzen lesen: `lit/divi-dynamic-sources` mit `post_type`, `post_id` und `node` (Pfad-ID des Moduls aus
`lit-divi-outline`) nennt zusätzlich die `loop_*`-Optionen, die zur Schleife dieses Knotens passen; liegt der `lit-divi-outline`) nennt zusätzlich die `loop_*`-Optionen, die zur Schleife dieses Knotens passen; liegt der
Knoten in keiner Schleife, kommt stattdessen der Hinweis „kein Schleifen-Kontext“. Ohne `post_id` und `node` Knoten in keiner Schleife, kommt stattdessen der Hinweis „kein Schleifen-Kontext“. Ohne `post_id` und `node`
+13 -1
View File
@@ -93,6 +93,18 @@ nichts geändert. Vorgehen:
Werkzeuge (`tools`). Werkzeuge (`tools`).
3. Ein anderes Werkzeug nutzen, das die betroffene Fähigkeit nicht braucht – ist z. B. nur „Seiten schreiben“ 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. 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 4. Den Nutzer informieren: bei `divi_untested` soll ein Administrator unter Einstellungen → LIT MCP → Kompatibilität die
Prüfung ausführen und die Version freigeben; bei `divi_incompatible` muss LIT MCP aktualisiert werden. Die Sperre Prüfung ausführen und die Version freigeben; bei `divi_incompatible` muss LIT MCP aktualisiert werden. Die Sperre
nicht über andere Werkzeuge umgehen. nicht über andere Werkzeuge umgehen.
5. Dasselbe gilt für Pods: Ist eine Pods-Fähigkeit (`pods-…`) nicht nutzbar, antwortet das betroffene Werkzeug mit
`compat_incompatible`; der Stand steht im selben Reiter „Kompatibilität“.
Seitenweite Änderungen (Optionen, Menüs und Menü-Positionen, Medien, Divi-Design, Theme Builder, globale
Bibliotheksmodule) leeren den Cache von WP Fastest Cache, Änderungen an einem Beitrag dessen Seite samt Startseite,
Feeds, Sitemap, Nachbarbeiträgen und Archiven seiner Begriffe (so leert WP Fastest Cache einen Beitrag). Ist der Cache
dafür gesperrt, bleibt er unverändert; der Zustand steht im Reiter „Kompatibilität“
und in `lit-site-status` (`wpfc`). Der Nachlauf-Purge, der auf ein gedrosseltes Leeren des ganzen Caches folgt, braucht
WP-Cron (oder einen System-Cron, der `wp-cron.php` aufruft). Auf Divi-Seiten leert jede Prüfung im Reiter den ganzen
Cache einmal: Divi selbst leert ihn beim Anlegen und Löschen der Testseite. Wurde etwas außerhalb von LIT MCP
geändert, leert `lit/cache-clear` den Cache auf Wunsch (`scope` `all` oder `post` mit `post_id`; erst Probelauf, dann
nach Rückfrage `confirm: true`).
+1 -1
View File
@@ -34,7 +34,7 @@ insgesamt:
2. Mit Werkzeugen weiterarbeiten, deren Fähigkeit `released` ist (z. B. Seiten lesen mit `lit-divi-outline`), und den 2. Mit Werkzeugen weiterarbeiten, deren Fähigkeit `released` ist (z. B. Seiten lesen mit `lit-divi-outline`), und den
gesperrten Teil auslassen. gesperrten Teil auslassen.
3. Dem Nutzer sagen, welche Fähigkeit gesperrt ist: bei `untested` muss ein Administrator unter Einstellungen → LIT 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. MCP → Kompatibilität die Prüfung ausführen und freigeben, bei `incompatible` muss LIT MCP aktualisiert werden.
## Bibliothek und globale Module ## Bibliothek und globale Module
@@ -0,0 +1,105 @@
# 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 |
**Erledigt 1.1–1.14:** 9beb55e, b511ef6, c851316.
**Neu-Zuschnitt 1.15–1.25 (Gate 1 vom 2026-11-06):** Prüfen und Freigeben sind für alle Profile im Reiter „Compatibility“
gleich; die Wirkung regelt das Tier (ReleasePerVersion = Pflicht, LockOnFailure = dokumentiert, Nutzung automatisch solange grün,
ReportOnly = nur Status). Optionen `lit_mcp_<id>_releases|_check_report|_check_lock` – für `divi` exakt die heutigen Namen.
**Divi behält auch das Datenformat:** Store und Bericht bekommen den Versionsschlüssel als Parameter (Divi `divi_version`, neue
Profile `version`) – keine Migration, Rollback-sicher. Profil-ID aus POST nur nach Allowlist (`Compatibility::profile()`) in
Optionsnamen.
| 1.15 | `Profile`: `testedVersions`, `stateKey`, `extraSteps` (Closure(bool $manifestClean): list<CheckStep>), `manifestCheck`; `DiviProfile` + `active`, `testedVersions=DiviReleases::TESTED`, `stateKey=ReleaseResolver::stateKey`; Pods `testedVersions=['3.3.9.2']` | `ProfileTest`, `DiviProfileTest`, `PodsCompatibilityTest` | Gates grün | sonnet (mit 1.16–1.18) |
| 1.16 | `CheckStep`, `CheckReport`, `CheckReportStore(profileId, versionKey)` nach `src/Compatibility`; Label mitgespeichert; Divi-Schrittkonstanten in `DiviCheckSteps` | `CheckReportTest` (Divi-Rundlauf unverändert, Option je ID) | Divi-Suite grün | sonnet |
| 1.17 | `ReleaseStore(profileId, versionKey)` nach `src/Compatibility` | `ReleaseStoreTest` (Divi-Format unverändert, Option je ID) | `ReleaseResolverTest` grün | sonnet |
| 1.18 | `CompatibilityCheck` generisch (Manifest, Fingerabdrücke nur mit Mirrors, dann `extraSteps`); `Compatibility::check(Profile)`; Divi liefert Katalog/Format/Schreibtest als `extraSteps` | `CompatibilityCheckTest`, `DiviCheckStepsTest` | `DiviCheckCommandTest` grün, Divi-Schrittfolge gleich | sonnet |
| 1.18a | Review-Befunde (Architektur-Review 2026-11-06): roter gespeicherter Bericht für den aktuellen Stand sperrt LockOnFailure-Fähigkeiten bis zur nächsten grünen Prüfung; gefangene Ausnahmen in `Compatibility` per `Log::failure` protokollieren; `Profile` nur mit benannten Argumenten bauen | `CompatibilityTest` (roter/grüner/veralteter/kein Bericht) | Divi unverändert | sonnet |
| 1.19 | `Admin\CheckRoute` je Profil (`/<id>-check`, Lock je Profil, Redirect `tab=compatibility`, `do_action('lit_mcp_compat_changed', $id)`; Divi invalidiert darauf `ReleaseGate`) | `CheckRouteTest`, `DiviIntegrationTest` | Lock-Tests portiert | sonnet |
| 1.20 | `Admin\CompatibilityTab` read-only (Sektion je Profil, Zustand Wort+Icon, Tier-Hinweis, Fähigkeiten, Versionen, Bericht, Hook `lit_mcp_compat_profile_extra`; inaktiv ohne `matrix()`) | `CompatibilityTabTest` inkl. Escaping | `CoreIndependenceTest` grün | sonnet |
| 1.21 | Formulare + admin-post `lit_mcp_compat_release|withdraw` (guard, Allowlist, Nonce je Profil) | `CompatibilityTabTest` (frischer Bericht, unbekanntes Profil, Nonce/403, Tier-Sichtbarkeit) | Release/Withdraw-Fälle aus `DiviTabTest` portiert | sonnet |
| 1.22 | Reiter in `Plugin::boot` (nach `LimitsTab`), Hilfe-Tab | `PluginTest`, `SettingsPageTest` | grün | sonnet |
| 1.23 | `DiviTab` → `DiviAdminExtras` (Notice, Orphans, Zusatzhinweise); `TABS_FILTER`-Eintrag entfällt | `DiviAdminExtrasTest`, `DiviIntegrationTest` | Boundary/Core grün | sonnet |
| 1.24 | Texte/CSS (`lit-mcp-divi-*` → `lit-mcp-compat-*`), `ReleaseStatus:24`, `docs/guides/overview.md`, `Uninstaller` (`lit_mcp_pods_*`), `divi-release-smoke.sh` | `ReleaseStatusTest`, `UninstallerTest` | DDEV Divi-Smoke grün | sonnet |
| 1.25 | Pods-Smoke: `/pods-check`, Freigabe schreibt `lit_mcp_pods_releases` ohne Sperrwirkung, hohe `minVersion` sperrt | Smoke rot ohne 1.19–1.21 | DDEV Pods- und Divi-Smoke grün | sonnet |
**Erledigt 1.15–1.25:** 7d6a97a, 85f4138, b40b81f, 239d35c.
| 1.26 | Pods-Verhaltensprobe als `extraSteps` (Benutzerentscheidung 2026-11-06): auf einem Wegwerf-Eintrag ein Feld über die Pods-API schreiben, lesen und alles wieder löschen; nur aus Prüfroute/CLI, nie in normalen Requests; Ausnahmen → fester Text + `Log::failure`; roter Schritt sperrt über die Berichtssperre | `PodsProbeTest` | DDEV: Pods-Smoke grün, Probe hinterlässt nichts | sonnet |
## Phase 2 – WP Super Cache 3.1.5
Zusätzlich (Benutzerentscheidung 2026-11-06): **2.6 Super-Cache-Verhaltensprobe** als `extraSteps` – eine private Testseite
cachen (bzw. Cache-Datei anlegen lassen) und per `wp_cache_post_edit` leeren, danach prüfen, dass die Datei weg ist; Testseite
wieder löschen; nur aus Prüfroute/CLI.
| # | 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) · 11a `refactor(compat): Freigabe-Bausteine
generisch` (1.15–1.18) · 11b `feat(compat): Prüfroute je Profil` (1.19) · 11c `feat(admin): Reiter „Compatibility“ für alle
Profile` (1.20–1.22) · 11d `refactor(divi): DiviTab geht im Reiter auf` (1.23–1.24) · 11e `test(compat): Smokes` (1.25) · 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`).
+20 -2
View File
@@ -4,7 +4,7 @@ Tags: mcp, ai, divi, pods, oauth
Requires at least: 6.9 Requires at least: 6.9
Tested up to: 7.1 Tested up to: 7.1
Requires PHP: 8.1 Requires PHP: 8.1
Stable tag: 1.1.0 Stable tag: 1.2.2
License: GPL-2.0-or-later License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -205,10 +205,28 @@ Weg nicht: der Wechsel auf 1.0.2 ist einmalig von Hand (ZIP hochladen), danach l
== Divi-Freigabe == == Divi-Freigabe ==
LIT MCP schreibt nur mit Divi-Versionen, die für diese Plugin-Version geprüft sind. Nach einem Divi-Update lesen die Divi-Werkzeuge weiter; schreibende Divi-Werkzeuge antworten mit „divi_untested“, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung startet und die Version freigibt. Die Prüfung legt eine Entwurfsseite an, schreibt und rendert sie und löscht sie danach endgültig. Hat Divi Stellen geändert, die LIT MCP nachbildet, ist die Version „inkompatibel“ (divi_incompatible) – dann hilft nur ein Update von LIT MCP. Für Entwickler: `wp --user=<admin> lit-mcp divi-check [--diff] [--update-reference]`. LIT MCP schreibt nur mit Divi-Versionen, die für diese Plugin-Version geprüft sind. Nach einem Divi-Update lesen die Divi-Werkzeuge weiter; schreibende Divi-Werkzeuge antworten mit „divi_untested“, bis ein Administrator unter Einstellungen → LIT MCP → Kompatibilität die Prüfung startet und die Version freigibt. Die Prüfung legt eine Entwurfsseite an, schreibt und rendert sie und löscht sie danach endgültig. Hat Divi Stellen geändert, die LIT MCP nachbildet, ist die Version „inkompatibel“ (divi_incompatible) – dann hilft nur ein Update von LIT MCP. Für Entwickler: `wp --user=<admin> lit-mcp divi-check [--diff] [--update-reference]`.
== Changelog == == Changelog ==
= 1.2.2 =
* Geändert: Die Cache-Anbindung arbeitet jetzt mit WP Fastest Cache statt WP Super Cache. Änderungen durch LIT MCP leeren den Cache von WP Fastest Cache automatisch (ein Beitrag samt Startseite, Feeds und Archiven, seitenweite Änderungen als Ganzes, höchstens alle 30 Sekunden mit Nachlauf). lit/cache-clear leert ihn auf Wunsch, auch für Entwürfe und gelöschte Beiträge. Im Reiter „Kompatibilität“ erscheint WP Fastest Cache mit einem Leeren-Test an einer Wegwerf-Seite.
* Entfernt: Die Anbindung an WP Super Cache.
= 1.2.1 =
* Neu: Tool lit/cache-clear leert den Cache von WP Super Cache gezielt – für die ganze Website (scope all, Recht manage_options) oder einen Beitrag (scope post, Recht edit_post). Ohne confirm nur ein Probelauf, ausgeführt wird erst mit confirm: true. Für Fälle, in denen etwas außerhalb von LIT MCP geändert wurde; Änderungen durch LIT MCP leeren den Cache weiterhin automatisch. Im Reiter „Tools“ erscheint dafür die Gruppe „WP Super Cache“, wenn das Plugin aktiv ist.
* Das Leeren der ganzen Website teilt sich die Drosselung mit dem automatischen Leeren (höchstens alle 30 Sekunden, mit Nachlauf). Ist WP Super Cache gesperrt (Reiter „Kompatibilität“), meldet das Tool das, statt still nichts zu tun.
= 1.2.0 =
* Neu: Reiter „Kompatibilität“ (ersetzt den Divi-Reiter) mit Übersicht aller angebundenen Plugins: Zustand, erkannte Version, Fähigkeiten, letzter Prüfbericht, Prüfen, Freigeben und Zurückziehen – auch auf dem Handy und barrierefrei. LIT MCP prüft fremde Plugins ohne offizielle Schnittstelle laufend und sperrt bei einem Bruch nur die betroffenen Werkzeuge.
* Neu: Pods läuft über diese Prüfung (ab Pods 2.8) und hat einen Lese- und Schreibtest an einem Wegwerf-Eintrag. Ein roter Test sperrt das Schreiben von Pods-Feldern, bis eine neue Prüfung grün ist.
* Neu: WP Super Cache wird nach Änderungen durch LIT MCP automatisch geleert – ein Beitrag einzeln, seitenweite Änderungen (Einstellungen, Menüs, Medien, Divi-Design, Theme Builder, globale Bibliotheksmodule) als Ganzes, höchstens alle 30 Sekunden mit einem Nachlauf per WP-Cron.
* Neu: Hook lit_mcp_changed nach jeder gespeicherten Änderung (für Erweiterungen).
* Doku: Format der Feld-Optionen in Divi-Schleifen (select_loop_meta_key, loop_meta_key) mit Beispiel für Pods-Felder.
* Sicherheit: Pods-Feldrechte sperren, wenn die Pods-Rechteprüfung fehlt; ist Pods aktiv, aber nicht nutzbar, sind Pods-Meta-Felder nur lesbar; unbekannte Fähigkeiten eines Werkzeugs sperren den Aufruf.
* Behoben: Die Bereinigung liegengebliebener Prüfseiten konnte eine gerade laufende Divi-Prüfung abbrechen.
* Hinweis: Auf Divi-Seiten leert jede Prüfung im Reiter den Seiten-Cache einmal (Divi selbst leert ihn beim Anlegen der Testseite).
= 1.1.0 = = 1.1.0 =
* Neu: Globales CSS bearbeiten (WordPress „Zusätzliches CSS“, in Divi „Eigenes CSS“) mit lit/divi-custom-css-get und lit/divi-custom-css-update: Probelauf mit Zeilen-Diff, Bestätigung, Snapshot und Wiederherstellen (lit/divi-design-restore, Bereich custom_css). Abgelehnt werden </style, <script, expression(, javascript: und neue @import-Anweisungen; fremde Hosts werden im Probelauf gemeldet. Erfordert das Recht edit_css. * Neu: Globales CSS bearbeiten (WordPress „Zusätzliches CSS“, in Divi „Eigenes CSS“) mit lit/divi-custom-css-get und lit/divi-custom-css-update: Probelauf mit Zeilen-Diff, Bestätigung, Snapshot und Wiederherstellen (lit/divi-design-restore, Bereich custom_css). Abgelehnt werden </style, <script, expression(, javascript: und neue @import-Anweisungen; fremde Hosts werden im Probelauf gemeldet. Erfordert das Recht edit_css.
* Einstellungsseite überarbeitet: Probleme der Verbindung stehen vor der Anleitung, Tools mit Suche, Filter „Nur Tools mit eigener Einstellung“, Alle ein- und ausklappen und Zurücksetzen je Gruppe; der Divi-Reiter in Klartext mit Statussymbolen; Rollenhinweise im Zugriff; Hilfe-Tab; bessere Bedienung auf dem Handy (Reiter als Raster, Tabellen als Karten, größere Tippflächen); besserer Kontrast und einheitliche Du-Anrede. * Einstellungsseite überarbeitet: Probleme der Verbindung stehen vor der Anleitung, Tools mit Suche, Filter „Nur Tools mit eigener Einstellung“, Alle ein- und ausklappen und Zurücksetzen je Gruppe; der Divi-Reiter in Klartext mit Statussymbolen; Rollenhinweise im Zugriff; Hilfe-Tab; bessere Bedienung auf dem Handy (Reiter als Raster, Tabellen als Karten, größere Tippflächen); besserer Kontrast und einheitliche Du-Anrede.
+7 -3
View File
@@ -32,10 +32,14 @@ final class AdminPostAction
} }
} }
/** Redirects to settings tab $slug with "lit_mcp_notice" = $notice and ends the request. */ /**
public function redirect(string $slug, string $notice): void * Redirects to settings tab $slug with "lit_mcp_notice" = $notice (and further query $args) and ends the request.
*
* @param array<string, string> $args
*/
public function redirect(string $slug, string $notice, array $args = []): void
{ {
wp_safe_redirect(SettingsPage::url($slug, ['lit_mcp_notice' => $notice])); wp_safe_redirect(SettingsPage::url($slug, ['lit_mcp_notice' => $notice] + $args));
($this->terminate)(); ($this->terminate)();
} }
} }
+184
View File
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use Closure;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Log;
use Throwable;
use WP_REST_Response;
defined('ABSPATH') || exit;
/**
* "Prüfung starten" of the tab "Compatibility" (spec §6.1): POST /wp-json/lit-mcp/v1/<profile>-check, one route per
* profile. A REST request on purpose – not admin-post – so the check runs in the same context as MCP calls (Divi
* renders its blocks in REST requests, lit-page-render-summary). The form sends the wp_rest nonce as _wpnonce
* (cookie authentication); only manage_options. The profile comes from the route, which exists only for the
* profiles of Compatibility; handle() looks the ID up again, so an ID never reaches an option name unchecked.
* One check per profile at a time: an atomic "INSERT IGNORE" on the options table (lit_mcp_<id>_check_lock) is the
* lock – add_option() reads then writes (wp-includes/option.php) and is not atomic, so two simultaneous requests
* could both "acquire" it. The value is "<time>:<token>". A lock older than LOCK_TTL is a dead
* check's, taken over with a conditional UPDATE (compare-and-swap on the value just read) so only one of several
* concurrent takers wins; the end of a check deletes the row only while it still holds its own token, so a check
* that outlived its lock does not free the taker's. A check that throws still releases the lock (finally) and
* redirects with compat_check_failed (logged, not an unhandled 500).
* Always answers 303 back to the tab "Compatibility" – every check, whatever the profile, lands there – and
* announces a stored report with the action lit_mcp_compat_changed($id); a listener that throws is logged and does
* not change the answer.
*/
final class CheckRoute
{
public const NAMESPACE = 'lit-mcp/v1';
public const CHANGED_ACTION = 'lit_mcp_compat_changed';
private const LOCK_TTL = 600;
/** @var Closure(Profile, int): CheckReport */
private Closure $check;
/** @var Closure(Profile): CheckReportStore */
private Closure $reports;
/**
* @param (Closure(Profile, int): CheckReport)|null $check Default: Compatibility::check().
* @param (Closure(Profile): CheckReportStore)|null $reports Default: CheckReportStore::forProfile().
*/
public function __construct(private Compatibility $compatibility, ?Closure $check = null, ?Closure $reports = null)
{
$this->check = $check ?? $compatibility->check(...);
$this->reports = $reports ?? CheckReportStore::forProfile(...);
}
public function register(): void
{
add_action('rest_api_init', [$this, 'registerRoutes']);
}
public function registerRoutes(): void
{
foreach ($this->compatibility->profiles() as $profile) {
register_rest_route(self::NAMESPACE, self::route($profile->id), [
'methods' => 'POST',
'callback' => fn (): WP_REST_Response => $this->handle($profile->id),
'permission_callback' => static fn (): bool => current_user_can('manage_options'),
]);
}
}
/** The URL of the profile's route; the caller passes an ID of a known profile. */
public static function url(string $profileId): string
{
return rest_url(self::NAMESPACE . self::route($profileId));
}
public function handle(string $profileId): WP_REST_Response
{
$profile = $this->compatibility->profile($profileId);
if ($profile === null) {
return new WP_REST_Response(null, 404);
}
$notice = 'compat_check_running';
$token = self::lock($profile->id);
if ($token !== null) {
try {
($this->reports)($profile)->save(($this->check)($profile, get_current_user_id()));
$notice = 'compat_checked';
} catch (Throwable $e) {
Log::failure($profile->id . ' check', $e);
$notice = 'compat_check_failed';
} finally {
self::release($profile->id, $token);
}
if ($notice === 'compat_checked') {
// The report is stored: a listener that throws is logged, it does not turn the success into a 500.
try {
do_action(self::CHANGED_ACTION, $profile->id);
} catch (Throwable $e) {
Log::failure($profile->id . ' ' . self::CHANGED_ACTION, $e);
}
}
}
return new WP_REST_Response(
null,
303,
['Location' => SettingsPage::url(
CompatibilityTab::SLUG,
['lit_mcp_notice' => $notice, 'lit_mcp_profile' => $profile->id]
)]
);
}
private static function route(string $profileId): string
{
return '/' . $profileId . '-check';
}
private static function lockOption(string $profileId): string
{
return 'lit_mcp_' . $profileId . '_check_lock';
}
/** @return string|null The lock value "<time>:<token>" now held by this request (its owner token), null = busy. */
private static function lock(string $profileId): ?string
{
global $wpdb;
$value = time() . ':' . bin2hex(random_bytes(4));
if (self::insertLock($wpdb, $profileId, $value)) {
return $value;
}
// get_var(), not get_option(): the row was written with raw SQL, bypassing the options cache.
$since = $wpdb->get_var($wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
self::lockOption($profileId)
));
if ($since === null) {
// The lock was released between our failed insert and this read; try once more.
return self::insertLock($wpdb, $profileId, $value) ? $value : null;
}
if (time() - (int) $since < self::LOCK_TTL) {
return null;
}
$taken = $wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
$value,
self::lockOption($profileId),
$since
));
return (int) $taken === 1 ? $value : null;
}
/** Compare-and-delete: a check that outlived its lock (taken over meanwhile) must not free the taker's. */
private static function release(string $profileId, string $token): void
{
global $wpdb;
$wpdb->query($wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
self::lockOption($profileId),
$token
));
}
/** INSERT IGNORE: 1 row affected = the lock did not exist and is now ours; 0 = someone else holds it. */
private static function insertLock(\wpdb $wpdb, string $profileId, string $value): bool
{
$inserted = $wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
self::lockOption($profileId),
$value
));
return (int) $inserted === 1;
}
}
+738
View File
@@ -0,0 +1,738 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use Closure;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\Capability;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Log;
use LIT\MCP\Plugin;
use Throwable;
defined('ABSPATH') || exit;
/**
* Tab "Kompatibilität" (slug "compatibility"): an overview table of all third-party plugins of
* Compatibility::profiles() (version, state as word and icon – never colour alone –, kind of release, link to the
* section), then one section per active plugin: state and next step first, then the actions ("Prüfung starten",
* Admin\CheckRoute, release, withdrawal) and, folded, the details (capabilities, tested and released versions, the
* latest check report). A profile that is not active is one overview row and never asks for its matrix. Everything
* that comes from a third-party plugin (version, problem texts, step labels) is escaped on output. Profiles hook in:
* lit_mcp_compat_profile_load($id) runs before any output (see loadProfiles()),
* lit_mcp_compat_profile_intro($id) opens the section below the state notice (next step, notices),
* lit_mcp_compat_profile_extra($id) closes it, and the filter lit_mcp_compat_explanation($text, $id, ReleaseState)
* replaces the generic sentence next to the overall state.
*/
final class CompatibilityTab implements AdminTab
{
public const SLUG = 'compatibility';
public const EXTRA_ACTION = 'lit_mcp_compat_profile_extra';
public const LOAD_ACTION = 'lit_mcp_compat_profile_load';
public const INTRO_ACTION = 'lit_mcp_compat_profile_intro';
public const EXPLANATION_FILTER = 'lit_mcp_compat_explanation';
/**
* Filter lit_mcp_compat_capability_columns(list, $profileId): a profile adds columns to its capability table.
* Each column is array{heading: string, cell: Closure(Capability, CompatibilityMatrix): list<string>,
* code?: bool}; the tab escapes the cell items (code: shown as <code>) and prints a dash for none. Columns
* without a string heading or a Closure cell are dropped; a cell that throws (logged) or does not return a
* list<string> shows a dash. The columns apply to exactly one table: a profile may cache per call (the cell
* Closures see the matrix of that table only).
*/
public const COLUMNS_FILTER = 'lit_mcp_compat_capability_columns';
/** admin-post actions; the nonce action of a request is the action plus "_<profile ID>". */
public const RELEASE_ACTION = 'lit_mcp_compat_release';
public const WITHDRAW_ACTION = 'lit_mcp_compat_withdraw';
private const ABILITY_PREFIX = 'lit/';
private const ABILITY_SUFFIX = '-release';
/** @var Closure(Profile): CheckReportStore */
private Closure $reports;
/** @var Closure(Profile): ReleaseStore */
private Closure $releases;
/**
* @param (Closure(Profile): CheckReportStore)|null $reports Default: CheckReportStore::forProfile().
* @param (Closure(Profile): ReleaseStore)|null $releases Default: ReleaseStore::forProfile().
*/
public function __construct(
private Compatibility $compatibility,
private ?ChangeLog $log = null,
private ?AdminPostAction $action = null,
?Closure $reports = null,
?Closure $releases = null
) {
$this->reports = $reports ?? CheckReportStore::forProfile(...);
$this->releases = $releases ?? ReleaseStore::forProfile(...);
}
/** Ability name of the release and withdraw entries in the change log: lit/<profile>-release. */
public static function releaseAbility(string $profileId): string
{
return self::ABILITY_PREFIX . $profileId . self::ABILITY_SUFFIX;
}
/** Whether the ability names such an entry (any profile). */
public static function isReleaseAbility(string $ability): bool
{
return strlen($ability) > strlen(self::ABILITY_PREFIX . self::ABILITY_SUFFIX)
&& str_starts_with($ability, self::ABILITY_PREFIX)
&& str_ends_with($ability, self::ABILITY_SUFFIX);
}
public function slug(): string
{
return self::SLUG;
}
public function label(): string
{
return __('Kompatibilität', 'wp-lit-mcp');
}
public function registerSettings(): void
{
}
public function registerActions(): void
{
add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']);
add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'loadProfiles']);
}
/**
* load-<page> of the settings page, before any output: on this tab and for manage_options announces each profile
* with lit_mcp_compat_profile_load($id), so a profile can do its housekeeping (and prepare a notice for
* its extra section) before render(). Nothing else is needed from the AdminTab contract: a tab hooks load-<page>
* itself in registerActions(), which SettingsPage already calls for every tab.
*/
public function loadProfiles(): void
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
$tab = is_string($_GET['tab'] ?? null) ? sanitize_key(wp_unslash($_GET['tab'])) : '';
if ($tab !== self::SLUG || ! current_user_can('manage_options')) {
return;
}
foreach ($this->compatibility->profiles() as $profile) {
do_action(self::LOAD_ACTION, $profile->id);
}
}
/**
* Releases the installed version of the posted profile: only as offered by the tab (see releasable()) and only
* after a fresh green report. The profile ID is looked up in Compatibility first (nothing else reaches option
* names, ability names or the redirect); the nonce belongs to that profile.
*/
public function release(): void
{
$action = $this->action ?? new AdminPostAction();
$action->guard();
$profile = $this->postedProfile();
check_admin_referer(self::RELEASE_ACTION . '_' . $profile->id);
$version = $this->compatibility->version($profile) ?? '';
$report = ($this->reports)($profile)->latest();
if (
$report === null
|| ! $this->releasable($profile, $version)
|| ! $report->freshFor($version, Plugin::VERSION, $this->stateKey($profile, $version))
) {
$action->redirect(self::SLUG, 'compat_release_refused', ['lit_mcp_profile' => $profile->id]);
return;
}
($this->releases)($profile)->add($version, Plugin::VERSION, get_current_user_id(), $report->id);
$this->log?->record(
self::releaseAbility($profile->id),
$profile->id,
$version,
sprintf(
'%s %s für LIT MCP %s freigegeben (Prüfbericht %s).',
$profile->label,
$version,
Plugin::VERSION,
$report->id
)
);
do_action(CheckRoute::CHANGED_ACTION, $profile->id);
$action->redirect(self::SLUG, 'compat_released', ['lit_mcp_profile' => $profile->id]);
}
public function withdraw(): void
{
$action = $this->action ?? new AdminPostAction();
$action->guard();
$profile = $this->postedProfile();
check_admin_referer(self::WITHDRAW_ACTION . '_' . $profile->id);
$version = $this->compatibility->version($profile) ?? '';
if (! ($this->releases)($profile)->withdraw($version, Plugin::VERSION)) {
$action->redirect(self::SLUG, 'compat_withdraw_failed', ['lit_mcp_profile' => $profile->id]);
return;
}
$this->log?->record(
self::releaseAbility($profile->id),
$profile->id,
$version,
sprintf('Freigabe von %s %s zurückgezogen.', $profile->label, $version)
);
do_action(CheckRoute::CHANGED_ACTION, $profile->id);
$action->redirect(self::SLUG, 'compat_withdrawn', ['lit_mcp_profile' => $profile->id]);
}
/** The profile named by the POST field "profile"; anything Compatibility does not know ends the request (400). */
private function postedProfile(): Profile
{
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- only a lookup key; the nonce is checked with it.
$id = is_string($_POST['profile'] ?? null) ? sanitize_key(wp_unslash($_POST['profile'])) : '';
$profile = $this->compatibility->profile($id);
if ($profile === null) {
wp_die(esc_html__('Unbekanntes Profil.', 'wp-lit-mcp'), '', ['response' => 400]);
}
return $profile;
}
/**
* What the tab offers: an active, versioned profile that releases at all (not ReportOnly), whose version the
* plugin does not list as tested, that has no release yet and – ReleasePerVersion – is still untested.
*/
private function releasable(Profile $profile, string $version): bool
{
return $version !== ''
&& $profile->tier !== Tier::ReportOnly
&& $this->compatibility->active($profile)
&& ! in_array($version, $profile->testedVersions, true)
&& ($this->releases)($profile)->find($version, Plugin::VERSION) === null
&& ($profile->tier !== Tier::ReleasePerVersion
|| $this->compatibility->matrix($profile)->state() === ReleaseState::Untested);
}
private function stateKey(Profile $profile, string $version): string
{
return $profile->stateKey === null ? '' : ($profile->stateKey)($version);
}
public function render(): void
{
$profiles = $this->compatibility->profiles();
$this->renderOverview($profiles);
foreach ($profiles as $profile) {
if ($this->compatibility->active($profile)) {
$this->renderProfile($profile);
}
}
}
/**
* The whole page at a glance: one sentence on whether anything needs attention, then one row per plugin. Rows of
* active plugins link to their section; an inactive plugin is one short row.
*
* @param list<Profile> $profiles
*/
private function renderOverview(array $profiles): void
{
$active = array_filter($profiles, fn (Profile $profile): bool => $this->compatibility->active($profile));
$attention = count(array_filter(
$active,
fn (Profile $profile): bool => $this->compatibility->matrix($profile)->state() !== ReleaseState::Released
));
echo '<p>' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Hier siehst du, ob LIT MCP zu deinen Plugins passt. Was nicht passt, sperrt LIT MCP; alles andere läuft weiter.',
'wp-lit-mcp'
) . '</p>';
echo '<h2>' . esc_html__('Übersicht', 'wp-lit-mcp') . '</h2>';
[$class, $text] = match (true) {
$active === [] => ['info', __('Kein unterstütztes Plugin ist aktiv. Es gibt nichts zu tun.', 'wp-lit-mcp')],
$attention === 0 => [
'success',
__('Alles in Ordnung: Alle aktiven Plugins sind freigegeben.', 'wp-lit-mcp'),
],
default => ['warning', sprintf(
/* translators: %d: number of plugins */
_n(
'%d aktives Plugin braucht deine Aufmerksamkeit.',
'%d aktive Plugins brauchen deine Aufmerksamkeit.',
$attention,
'wp-lit-mcp'
),
$attention
)],
};
printf('<div class="notice notice-%s inline"><p>%s</p></div>', esc_attr($class), esc_html($text));
$headings = [
__('Plugin', 'wp-lit-mcp'),
__('Version', 'wp-lit-mcp'),
__('Zustand', 'wp-lit-mcp'),
__('Freigabe', 'wp-lit-mcp'),
__('Details', 'wp-lit-mcp'),
];
echo '<table class="widefat striped lit-mcp-compat-overview"><caption class="screen-reader-text">'
. esc_html__('Alle unterstützten Plugins und ihr Zustand', 'wp-lit-mcp') . '</caption><thead><tr>';
foreach ($headings as $heading) {
echo '<th scope="col">' . esc_html($heading) . '</th>';
}
echo '</tr></thead><tbody>';
foreach ($profiles as $profile) {
$this->renderOverviewRow($profile, $headings);
}
echo '</tbody></table>';
}
/** @param list<string> $headings */
private function renderOverviewRow(Profile $profile, array $headings): void
{
printf(
'<tr><th scope="row" data-label="%s">%s</th>',
esc_attr($headings[0]),
esc_html($profile->label)
);
if (! $this->compatibility->active($profile)) {
$none = '<span aria-hidden="true">–</span><span class="screen-reader-text">'
. esc_html__('keine Angabe', 'wp-lit-mcp') . '</span>';
printf(
'<td data-label="%1$s">%5$s</td><td data-label="%2$s">%6$s</td><td data-label="%3$s">%5$s</td>'
. '<td data-label="%4$s">%5$s</td></tr>',
esc_attr($headings[1]),
esc_attr($headings[2]),
esc_attr($headings[3]),
esc_attr($headings[4]),
$none, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above.
'<span class="lit-mcp-state lit-mcp-status-minus"><span class="dashicons dashicons-minus"'
. ' aria-hidden="true"></span> <strong>' . esc_html__('nicht aktiv', 'wp-lit-mcp')
. '</strong></span>'
);
return;
}
printf(
'<td data-label="%1$s">%5$s</td><td data-label="%2$s">%6$s</td><td data-label="%3$s">%7$s</td>'
. '<td data-label="%4$s"><a href="#lit-mcp-compat-%8$s">%9$s<span class="screen-reader-text">'
. ' %10$s</span></a></td></tr>',
esc_attr($headings[1]),
esc_attr($headings[2]),
esc_attr($headings[3]),
esc_attr($headings[4]),
esc_html($this->compatibility->version($profile) ?? __('nicht erkannt', 'wp-lit-mcp')),
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in stateBadge().
self::stateBadge($this->compatibility->matrix($profile)->state()),
esc_html(self::tierHint($profile->tier)),
esc_attr($profile->id),
esc_html__('Details', 'wp-lit-mcp'),
esc_html(sprintf(
/* translators: %s: plugin name */
__('zu %s', 'wp-lit-mcp'),
$profile->label
))
);
}
private function renderProfile(Profile $profile): void
{
$matrix = $this->compatibility->matrix($profile);
$problems = $this->compatibility->problems($profile);
$state = $matrix->state();
printf(
'<section class="lit-mcp-compat" id="lit-mcp-compat-%1$s" aria-labelledby="lit-mcp-compat-%1$s-title">',
esc_attr($profile->id)
);
printf('<h2 id="lit-mcp-compat-%s-title">%s</h2>', esc_attr($profile->id), esc_html($profile->label));
// 1. State and next step.
printf(
'<div class="notice notice-%s inline"><p>%s %s</p>',
esc_attr(self::noticeClass($state)),
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in stateBadge().
self::stateBadge($state),
esc_html($this->explanation($profile, $state))
);
if ($problems !== []) {
self::renderList($problems);
}
echo '</div>';
printf(
'<p class="description">%s</p>',
esc_html(sprintf(
/* translators: 1: plugin version, 2: how releases work for this plugin */
__('Installiert: Version %1$s. %2$s', 'wp-lit-mcp'),
$this->compatibility->version($profile) ?? __('nicht erkannt', 'wp-lit-mcp'),
self::tierExplanation($profile->tier)
))
);
do_action(self::INTRO_ACTION, $profile->id);
// 2. Actions.
$report = ($this->reports)($profile)->latest();
$this->renderForms($profile, $report);
// 3. Details, folded while everything is fine.
printf(
'<details class="lit-mcp-compat-details"%s><summary>%s'
. '<span class="screen-reader-text"> %s</span></summary>',
$state === ReleaseState::Released ? '' : ' open',
esc_html__('Details', 'wp-lit-mcp'),
esc_html(sprintf(
/* translators: %s: plugin name */
__('zu %s', 'wp-lit-mcp'),
$profile->label
))
);
$this->renderCapabilities($profile, $matrix);
$this->renderVersions($profile);
if ($report !== null) {
$this->renderReport($profile, $report);
}
echo '</details>';
do_action(self::EXTRA_ACTION, $profile->id);
echo '</section>';
}
private static function noticeClass(ReleaseState $state): string
{
return match ($state) {
ReleaseState::Released => 'success',
ReleaseState::Untested => 'warning',
ReleaseState::Incompatible => 'error',
};
}
private function renderVersions(Profile $profile): void
{
$rows = [
__('Im Plugin getestet', 'wp-lit-mcp') => $profile->testedVersions === []
? '–' : esc_html(implode(', ', $profile->testedVersions)),
__('Von dir freigegeben', 'wp-lit-mcp') => esc_html($this->releasedVersions($profile)) ?: '–',
];
echo '<h3>' . esc_html__('Versionen', 'wp-lit-mcp') . '</h3>';
echo '<table class="widefat striped lit-mcp-compat-versions"><caption class="screen-reader-text">'
. esc_html(sprintf(
/* translators: %s: plugin name */
__('Getestete und freigegebene Versionen von %s', 'wp-lit-mcp'),
$profile->label
)) . '</caption><tbody>';
foreach ($rows as $heading => $value) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $value is escaped above.
printf('<tr><th scope="row">%s</th><td>%s</td></tr>', esc_html($heading), $value);
}
echo '</tbody></table>';
}
private function renderCapabilities(Profile $profile, CompatibilityMatrix $matrix): void
{
$extra = $this->extraColumns($profile);
$headings = [__('Fähigkeit', 'wp-lit-mcp'), __('Zustand', 'wp-lit-mcp')];
foreach ($extra as $column) {
$headings[] = $column['heading'];
}
echo '<h3>' . esc_html__('Fähigkeiten', 'wp-lit-mcp') . '</h3>';
echo '<table class="widefat striped lit-mcp-compat-features"><caption class="screen-reader-text">'
. esc_html(sprintf(
/* translators: %s: plugin name */
__('Fähigkeiten von %s', 'wp-lit-mcp'),
$profile->label
))
. '</caption><thead><tr>';
foreach ($headings as $heading) {
echo '<th scope="col">' . esc_html($heading) . '</th>';
}
echo '</tr></thead><tbody>';
foreach ($profile->capabilities as $capability) {
// data-label: on phones each row becomes a card and the cell names itself.
printf(
'<tr><th scope="row" data-label="%s">%s</th><td data-label="%s">%s</td>',
esc_attr($headings[0]),
esc_html($capability->label()),
esc_attr($headings[1]),
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in stateBadge().
self::stateBadge($matrix->of($capability)->state)
);
foreach ($extra as $index => $column) {
printf('<td data-label="%s">', esc_attr($headings[$index + 2]));
self::renderList(
self::cellItems($profile, $column, $capability, $matrix),
($column['code'] ?? false) === true
);
echo '</td>';
}
echo '</tr>';
}
echo '</tbody></table>';
}
/** @return list<array{heading: string, cell: Closure, code?: bool}> */
private function extraColumns(Profile $profile): array
{
$columns = apply_filters(self::COLUMNS_FILTER, [], $profile->id);
return is_array($columns)
? array_values(array_filter(
$columns,
static fn (mixed $column): bool => is_array($column)
&& is_string($column['heading'] ?? null)
&& ($column['cell'] ?? null) instanceof Closure
))
: [];
}
/**
* What one cell shows: its list, or nothing (a dash) when the profile's callback throws or returns anything but a
* list of strings – a third party's column must not break the table.
*
* @param array{heading: string, cell: Closure} $column
* @return list<string>
*/
private static function cellItems(
Profile $profile,
array $column,
Capability $capability,
CompatibilityMatrix $matrix
): array {
try {
$items = ($column['cell'])($capability, $matrix);
} catch (Throwable $e) {
Log::failure(sprintf('%s capability column "%s"', $profile->id, $column['heading']), $e);
return [];
}
return is_array($items) && array_is_list($items) && array_filter($items, 'is_string') === $items ? $items : [];
}
private function renderForms(Profile $profile, ?CheckReport $report): void
{
$version = $this->compatibility->version($profile) ?? '';
$fresh = $report !== null && $report->freshFor($version, Plugin::VERSION, $this->stateKey($profile, $version));
$releasable = $this->releasable($profile, $version);
echo '<h3>' . esc_html__('Aktionen', 'wp-lit-mcp') . '</h3><div class="lit-mcp-compat-actions">';
printf('<form method="post" action="%s">', esc_url(CheckRoute::url($profile->id)));
printf('<input type="hidden" name="_wpnonce" value="%s">', esc_attr(wp_create_nonce('wp_rest')));
// The check is the one thing to do while a release is due without a usable report.
submit_button(
__('Prüfung starten', 'wp-lit-mcp'),
$releasable && ! $fresh ? 'primary' : 'secondary',
'lit-mcp-compat-check-' . $profile->id,
false,
['aria-label' => self::namedFor($profile, __('Prüfung starten', 'wp-lit-mcp'))]
);
echo '</form>';
if ($releasable && $fresh) {
$this->actionForm(
$profile,
self::RELEASE_ACTION,
'release',
/* translators: 1: plugin name, 2: version */
sprintf(__('%1$s %2$s freigeben', 'wp-lit-mcp'), $profile->label, $version),
'primary'
);
}
// A release applies whatever the overall state: one incompatible capability must not hide the way back.
if ($version !== '' && ($this->releases)($profile)->find($version, Plugin::VERSION) !== null) {
$this->actionForm(
$profile,
self::WITHDRAW_ACTION,
'withdraw',
__('Freigabe zurückziehen', 'wp-lit-mcp'),
'secondary',
true
);
}
echo '</div>';
echo '<p class="description">' . esc_html__('Die Prüfung dauert einige Sekunden.', 'wp-lit-mcp') . '</p>';
if ($releasable && ! $fresh) {
echo '<p>' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Freigeben geht erst nach einer vollständig grünen Prüfung dieser Version. Führe die Prüfung erneut aus.',
'wp-lit-mcp'
) . '</p>';
}
}
/** "<visible text>: <profile>" – the accessible name starts with the visible text (WCAG 2.5.3). */
private static function namedFor(Profile $profile, string $label): string
{
return $label . ': ' . $profile->label;
}
/**
* @param string $kind Short name of the action; with the profile ID it makes the button's name/id unique
* on a page that shows several profiles.
* @param bool $nameIt The label does not name the profile: add it to the accessible name.
*/
private function actionForm(
Profile $profile,
string $action,
string $kind,
string $label,
string $type,
bool $nameIt = false
): void {
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
printf('<input type="hidden" name="action" value="%s">', esc_attr($action));
printf('<input type="hidden" name="profile" value="%s">', esc_attr($profile->id));
wp_nonce_field($action . '_' . $profile->id);
submit_button(
$label,
$type,
'lit-mcp-compat-' . $kind . '-' . $profile->id,
false,
$nameIt ? ['aria-label' => self::namedFor($profile, $label)] : []
);
echo '</form>';
}
private function renderReport(Profile $profile, CheckReport $report): void
{
printf(
'<h3>%s</h3><p>%s</p><ul class="lit-mcp-compat-report">',
esc_html__('Letzte Prüfung', 'wp-lit-mcp'),
esc_html(sprintf(
/* translators: 1: plugin version, 2: LIT MCP version, 3: date */
__('Geprüft: Version %1$s mit LIT MCP %2$s, %3$s', 'wp-lit-mcp'),
$report->version,
$report->pluginVersion,
date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt))
))
);
foreach ($report->steps as $step) {
[$icon, $word] = match (true) {
$step->skipped => ['minus', __('übersprungen', 'wp-lit-mcp')],
$step->green() => ['yes-alt', __('bestanden', 'wp-lit-mcp')],
default => ['dismiss', __('fehlgeschlagen', 'wp-lit-mcp')],
};
printf(
'<li class="lit-mcp-state lit-mcp-status-%1$s"><span class="dashicons dashicons-%1$s"'
. ' aria-hidden="true"></span> <strong>%2$s</strong>: %3$s',
esc_attr($icon),
esc_html($word),
esc_html($profile->stepLabel === null ? $step->label() : ($profile->stepLabel)($step))
);
if ($step->skipped) {
echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')';
}
if ($step->problems !== []) {
self::renderList($step->problems);
}
echo '</li>';
}
echo '</ul>';
}
/** @param list<string> $items */
private static function renderList(array $items, bool $code = false): 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 releasedVersions(Profile $profile): string
{
return implode(', ', array_map(
static fn (array $entry): string => sprintf(
/* translators: 1: plugin version, 2: LIT MCP version */
__('%1$s (LIT MCP %2$s)', 'wp-lit-mcp'),
$entry[$profile->reportVersionKey],
$entry['plugin_version']
),
($this->releases)($profile)->entries()
));
}
/** Same coloured icons as the status list on the tab "Verbindung"; the word stays, colour is never alone. */
private static function stateBadge(ReleaseState $state): string
{
[$icon, $word] = match ($state) {
ReleaseState::Released => ['yes-alt', __('freigegeben', 'wp-lit-mcp')],
ReleaseState::Untested => ['warning', __('ungetestet', 'wp-lit-mcp')],
ReleaseState::Incompatible => ['dismiss', __('inkompatibel', 'wp-lit-mcp')],
};
return sprintf(
'<span class="lit-mcp-state lit-mcp-status-%1$s"><span class="dashicons dashicons-%1$s"'
. ' aria-hidden="true"></span> <strong>%2$s</strong></span>',
$icon,
esc_html($word)
);
}
private function explanation(Profile $profile, ReleaseState $state): string
{
$generic = self::genericExplanation($state);
$text = apply_filters(self::EXPLANATION_FILTER, $generic, $profile->id, $state);
return is_string($text) ? $text : $generic;
}
private static function genericExplanation(ReleaseState $state): string
{
return match ($state) {
ReleaseState::Released => __('Alle Fähigkeiten sind nutzbar.', 'wp-lit-mcp'),
ReleaseState::Untested => __(
'Diese Version ist nicht geprüft. Die ungetesteten Fähigkeiten sind gesperrt, bis du sie freigibst.',
'wp-lit-mcp'
),
ReleaseState::Incompatible => __(
'Die betroffenen Fähigkeiten bleiben gesperrt, bis die Ursache behoben ist; die anderen laufen weiter.',
'wp-lit-mcp'
),
};
}
/** Short and in plain words: the tier's name never reaches the screen. */
private static function tierHint(Tier $tier): string
{
return match ($tier) {
Tier::ReleasePerVersion => __('Freigabe pro Version nötig', 'wp-lit-mcp'),
Tier::LockOnFailure => __('Sperrt automatisch bei Problemen', 'wp-lit-mcp'),
Tier::ReportOnly => __('Nur Anzeige', 'wp-lit-mcp'),
};
}
private static function tierExplanation(Tier $tier): string
{
return match ($tier) {
Tier::ReleasePerVersion => __(
'Jede neue Version musst du einmal freigeben, bevor LIT MCP damit schreibt.',
'wp-lit-mcp'
),
Tier::LockOnFailure => __(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'LIT MCP sperrt betroffene Fähigkeiten selbst, sobald die Prüfung Probleme findet. Eine Freigabe hält nur fest, dass du diese Version geprüft hast.',
'wp-lit-mcp'
),
Tier::ReportOnly => __('Probleme werden nur angezeigt; nichts wird gesperrt.', 'wp-lit-mcp'),
};
}
}
+37 -18
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace LIT\MCP\Admin; namespace LIT\MCP\Admin;
use Closure;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
@@ -30,12 +32,15 @@ final class SettingsPage
* @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none). * @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 $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS).
* @param string $version Asset version for cache busting. * @param string $version Asset version for cache busting.
* @param (Closure(string): ?string)|null $profileLabel Label of a compatibility profile by ID, null = unknown;
* names the profile in the compat_* notices.
*/ */
public function __construct( public function __construct(
private array $baseTabs, private array $baseTabs,
private string $logoUrl = '', private string $logoUrl = '',
private string $assetsUrl = '', private string $assetsUrl = '',
private string $version = '' private string $version = '',
private ?Closure $profileLabel = null
) { ) {
} }
@@ -74,6 +79,7 @@ final class SettingsPage
public function removableQueryArgs(array $args): array public function removableQueryArgs(array $args): array
{ {
$args[] = 'lit_mcp_notice'; $args[] = 'lit_mcp_notice';
$args[] = 'lit_mcp_profile';
return $args; return $args;
} }
@@ -107,6 +113,10 @@ final class SettingsPage
'Tab „Protokoll“: jede Änderung des Assistenten mit Benutzer, Ergebnis und Zeitpunkt.', 'Tab „Protokoll“: jede Änderung des Assistenten mit Benutzer, Ergebnis und Zeitpunkt.',
'wp-lit-mcp' 'wp-lit-mcp'
), ),
__('Passt es zu Divi, Pods & Co.?', 'wp-lit-mcp') => __(
'Tab „Kompatibilität“: Fremd-Plugins prüfen, Versionen freigeben und sehen, was gesperrt ist.',
'wp-lit-mcp'
),
]; ];
$content = ''; $content = '';
foreach ($items as $title => $text) { foreach ($items as $title => $text) {
@@ -163,7 +173,7 @@ final class SettingsPage
public function currentTab(): string public function currentTab(): string
{ {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
$requested = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : ''; $requested = is_string($_GET['tab'] ?? null) ? sanitize_key(wp_unslash($_GET['tab'])) : '';
$tabs = $this->tabs(); $tabs = $this->tabs();
@@ -236,39 +246,48 @@ final class SettingsPage
); );
} }
/** "<Label>: " for a compat_* notice naming a known profile; the ID from the address bar only looks the label up. */
private function noticeProfile(string $key): string
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
$id = is_string($_GET['lit_mcp_profile'] ?? null) ? sanitize_key(wp_unslash($_GET['lit_mcp_profile'])) : '';
$label = str_starts_with($key, 'compat_') && $id !== '' && $this->profileLabel !== null
? ($this->profileLabel)($id)
: null;
return $label === null ? '' : $label . ': ';
}
private function renderNotice(): void private function renderNotice(): void
{ {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
$key = isset($_GET['lit_mcp_notice']) ? sanitize_key(wp_unslash((string) $_GET['lit_mcp_notice'])) : ''; $key = is_string($_GET['lit_mcp_notice'] ?? null) ? sanitize_key(wp_unslash($_GET['lit_mcp_notice'])) : '';
$notice = match ($key) { $notice = match ($key) {
'revoked' => ['success', __('Verbindung widerrufen.', 'wp-lit-mcp')], 'revoked' => ['success', __('Verbindung widerrufen.', 'wp-lit-mcp')],
'revoke_failed' => ['error', __('Die Verbindung konnte nicht widerrufen werden.', 'wp-lit-mcp')], 'revoke_failed' => ['error', __('Die Verbindung konnte nicht widerrufen werden.', 'wp-lit-mcp')],
'cleared' => ['success', __('Protokoll geleert.', 'wp-lit-mcp')], 'cleared' => ['success', __('Protokoll geleert.', 'wp-lit-mcp')],
'divi_checked' => ['success', __('Divi-Prüfung abgeschlossen; das Ergebnis steht unten.', 'wp-lit-mcp')], 'compat_checked' => ['success', __('Prüfung abgeschlossen; das Ergebnis steht unten.', 'wp-lit-mcp')],
'divi_check_running' => [ 'compat_check_running' => [
'warning', 'warning',
__('Es läuft bereits eine Divi-Prüfung; bitte in ein paar Minuten erneut versuchen.', 'wp-lit-mcp'), __('Es läuft bereits eine Prüfung; bitte in ein paar Minuten erneut versuchen.', 'wp-lit-mcp'),
], ],
'divi_check_failed' => [ 'compat_check_failed' => [
'error', 'error',
__('Die Divi-Prüfung ist abgebrochen; Details stehen im PHP-Error-Log.', 'wp-lit-mcp'), __('Die Prüfung ist abgebrochen; Details stehen im PHP-Error-Log.', 'wp-lit-mcp'),
], ],
'divi_released' => ['success', __('Die Divi-Version ist für LIT MCP freigegeben.', 'wp-lit-mcp')], 'compat_released' => ['success', __('Die Version ist für LIT MCP freigegeben.', 'wp-lit-mcp')],
'divi_release_refused' => [ 'compat_release_refused' => [
'error', 'error',
__( __(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Freigabe nicht möglich: Der letzte Prüfbericht ist nicht grün oder passt nicht mehr zur installierten Divi- oder Plugin-Version. Bitte die Prüfung erneut ausführen.', 'Freigabe nicht möglich: Der letzte Prüfbericht ist nicht grün oder passt nicht mehr zur installierten Version oder zur Plugin-Version. Bitte die Prüfung erneut ausführen.',
'wp-lit-mcp' 'wp-lit-mcp'
), ),
], ],
'divi_withdrawn' => [ 'compat_withdrawn' => ['success', __('Freigabe zurückgezogen.', 'wp-lit-mcp')],
'success', 'compat_withdraw_failed' => [
__('Freigabe zurückgezogen; schreibende Divi-Tools sind wieder gesperrt.', 'wp-lit-mcp'),
],
'divi_withdraw_failed' => [
'error', 'error',
__('Für die installierte Divi-Version gibt es auf dieser Website keine Freigabe.', 'wp-lit-mcp'), __('Für die installierte Version gibt es auf dieser Website keine Freigabe.', 'wp-lit-mcp'),
], ],
default => null, default => null,
}; };
@@ -277,7 +296,7 @@ final class SettingsPage
printf( printf(
'<div class="notice notice-%s is-dismissible"><p>%s</p></div>', '<div class="notice notice-%s is-dismissible"><p>%s</p></div>',
esc_attr($notice[0]), esc_attr($notice[0]),
esc_html($notice[1]) esc_html($this->noticeProfile($key) . $notice[1])
); );
} }
} }
+36 -5
View File
@@ -18,6 +18,9 @@ class ChangeLog
{ {
public const TABLE = 'lit_mcp_log'; public const TABLE = 'lit_mcp_log';
/** Fires after every write that changed something (result ok or partial), see announce(). */
public const CHANGED_ACTION = 'lit_mcp_changed';
public function __construct(private ?\wpdb $db = null) public function __construct(private ?\wpdb $db = null)
{ {
} }
@@ -42,15 +45,19 @@ class ChangeLog
string $result = 'ok' string $result = 'ok'
): ?int { ): ?int {
$wpdb = $this->db(); $wpdb = $this->db();
// Shortened once: the row and the announcement carry the same values.
$ability = mb_substr($ability, 0, 100);
$objectType = mb_substr($objectType, 0, 50);
$objectId = mb_substr($objectId, 0, 191);
$inserted = $wpdb->insert( $inserted = $wpdb->insert(
$this->tableName(), $this->tableName(),
[ [
'created_at' => current_time('mysql', true), 'created_at' => current_time('mysql', true),
'user_id' => get_current_user_id(), 'user_id' => get_current_user_id(),
'client' => $this->sanitizedClientName(), 'client' => $this->sanitizedClientName(),
'ability' => mb_substr($ability, 0, 100), 'ability' => $ability,
'object_type' => mb_substr($objectType, 0, 50), 'object_type' => $objectType,
'object_id' => mb_substr($objectId, 0, 191), 'object_id' => $objectId,
'summary' => mb_substr($summary, 0, 500), 'summary' => mb_substr($summary, 0, 500),
'revision_id' => $revisionId, 'revision_id' => $revisionId,
'snapshot_id' => $snapshotId, 'snapshot_id' => $snapshotId,
@@ -59,12 +66,36 @@ class ChangeLog
['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s'] ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s']
); );
$logId = null;
if ($inserted === false) { if ($inserted === false) {
Log::line(sprintf('change log insert failed for %s %s:%s', $ability, $objectType, $objectId)); Log::line(sprintf('change log insert failed for %s %s:%s', $ability, $objectType, $objectId));
return null; } else {
$logId = (int) $wpdb->insert_id;
} }
return (int) $wpdb->insert_id; if ($result !== 'refused') {
self::announce($ability, $objectType, $objectId, $logId);
}
return $logId;
}
/**
* do_action('lit_mcp_changed', ability, objectType, objectId, logId): a write happened (result ok or partial,
* never refused), even if its log row did not (logId null).
*
* ability, objectType and objectId are shortened like the log row (100/50/191 characters). objectId is an
* identifier or name from the tool input (post ID, option name, menu location ...): untrusted text, validate
* before use. A listener that throws is caught and logged, the caller must not see a failed write; exit and
* wp_die in a listener are not caught and end the request.
*/
private static function announce(string $ability, string $objectType, string $objectId, ?int $logId): void
{
try {
do_action(self::CHANGED_ACTION, $ability, $objectType, $objectId, $logId);
} catch (\Throwable $e) {
Log::failure(self::CHANGED_ACTION, $e);
}
} }
/** /**
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use ReflectionFunction;
use ReflectionMethod;
defined('ABSPATH') || exit;
/**
* One function or static method of a third-party plugin or theme that an adapter calls (spec §4.1), with the number
* of arguments the plugin passes. $args null: only its existence matters (a function the third-party code itself
* needs, e.g. et_() inside store_template in the Divi integration).
*/
final class Call implements Requirement
{
/** Key prefix of the signature fingerprints in the reference; no mirror ID starts with it. */
public const SIGNATURE_PREFIX = 'sig:';
/**
* @param string $symbol "function_name" or "Fully\Qualified\Class::method".
* @param list<Capability> $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;
}
/** The key of this call's signature fingerprint in the reference: "sig:" and the symbol without a leading backslash. */
public function signatureKey(): string
{
return self::SIGNATURE_PREFIX . ltrim($this->symbol, '\\');
}
public function member(): string
{
return str_contains($this->symbol, '::') ? explode('::', $this->symbol, 2)[1] : $this->symbol;
}
/** The function or method exists and takes the number of arguments the adapter passes. */
public function accepts(): bool
{
$class = $this->className();
if ($class === null) {
if (! function_exists($this->member())) {
return false;
}
$reflection = new ReflectionFunction($this->member());
} else {
if (! method_exists($class, $this->member())) {
return false;
}
$reflection = new ReflectionMethod($class, $this->member());
}
return $this->args === null
|| ($reflection->getNumberOfRequiredParameters() <= $this->args
&& ($reflection->isVariadic() || $reflection->getNumberOfParameters() >= $this->args));
}
public function features(): array
{
return $this->features;
}
public function label(): string
{
$class = $this->className();
return ($class === null ? '' : SourceFingerprint::shortName($class) . '::') . $this->member() . '()';
}
}
+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;
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityGuard;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
defined('ABSPATH') || exit;
/**
* The lock of all non-Divi capabilities: a call is refused before it runs while one of the capabilities it declares
* (DeclaresRequirements) is locked, and also while a declared ID is unknown to every profile. IDs of
* profiles named in $delegated are left to the guard that checks them itself. A refused write is logged with result
* "refused"; a refused read changes nothing and is not.
*/
final class CapabilityGate implements AbilityGuard
{
/** @param list<string> $delegated IDs of the profiles another guard checks (skipped here), e.g. 'divi'. */
public function __construct(
private Compatibility $compat,
private ?ChangeLog $log = null,
private array $delegated = []
) {
}
public function refusal(Ability $ability, array $input): ?WP_Error
{
$capabilities = [];
$unknown = [];
foreach ($ability instanceof DeclaresRequirements ? $ability->requirements($input) : [] as $id) {
$capability = $this->compat->capability($id);
if ($capability === null) {
$unknown[] = $id;
} elseif (! in_array($this->compat->owner($capability)?->id, $this->delegated, true)) {
$capabilities[] = $capability;
}
}
if ($unknown !== []) {
return $this->refuse(
$ability,
CapabilityLocked::unknown($unknown),
implode(', ', $unknown),
'unbekannte Fähigkeit ' . implode(', ', $unknown)
);
}
$lock = $this->compat->lockOf($capabilities);
if ($lock === null) {
return null;
}
$labels = implode(', ', array_map(static fn (FeatureStatus $s): string => $s->feature->label(), $lock->locked));
return $this->refuse(
$ability,
$lock,
implode(', ', array_unique(array_map(
fn (FeatureStatus $s): string => $this->compat->owner($s->feature)?->id ?? '',
$lock->locked
))),
sprintf('%s – %s gesperrt', implode(', ', $lock->plugins), $labels)
);
}
private function refuse(Ability $ability, CapabilityLocked $lock, string $objectId, string $reason): WP_Error
{
if (($ability->annotations()['readonly'] ?? false) !== true) {
$this->log?->record(
$ability->name(),
'compat',
$objectId,
'Abgelehnt: ' . $reason . '.',
result: 'refused'
);
}
return $lock->toError();
}
/** A refusal is the whole answer: there is no result to annotate. */
public function annotate(Ability $ability, mixed $result): mixed
{
return $result;
}
public function outputSchema(Ability $ability, ?array $schema): ?array
{
return $schema;
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolRefusal;
use LogicException;
use RuntimeException;
use WP_Error;
defined('ABSPATH') || exit;
/**
* A call refused because a capability it needs is locked, or because no profile knows a capability it declares;
* AbilityRegistrar returns toError().
*/
final class CapabilityLocked extends RuntimeException implements ToolRefusal
{
/**
* @param list<FeatureStatus> $locked The locked capabilities the refused call needs.
* @param list<string> $plugins Labels of the plugins they belong to.
* @param list<string> $unknown Declared IDs no profile knows.
*/
private function __construct(
public readonly array $locked,
public readonly array $plugins,
public readonly array $unknown
) {
parent::__construct(sprintf(
'%s locked – %s refused',
implode(', ', $plugins) ?: 'unknown capability',
implode(', ', $unknown ?: array_map(static fn (FeatureStatus $s): string => $s->feature->id(), $locked))
));
}
/**
* @param list<FeatureStatus> $locked
* @param list<Profile> $profiles The profiles the locked capabilities belong to.
* @throws LogicException When $locked is empty – a lock always names what it locks.
*/
public static function of(array $locked, array $profiles): self
{
if ($locked === []) {
throw new LogicException('a capability lock needs the locked capabilities');
}
return new self($locked, array_map(static fn (Profile $profile): string => $profile->label, $profiles), []);
}
/**
* @param list<string> $ids
* @throws LogicException When $ids is empty.
*/
public static function unknown(array $ids): self
{
if ($ids === []) {
throw new LogicException('an unknown-capability lock needs the IDs');
}
return new self([], [], $ids);
}
public function toError(): WP_Error
{
if ($this->unknown !== []) {
return Errors::make(
'compat_incompatible',
sprintf(
'Das Werkzeug verlangt unbekannte Fähigkeiten (%s). Das ist gesperrt. Es wurde nichts geändert.',
implode(', ', $this->unknown)
),
'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss.',
423,
['unknown' => $this->unknown]
);
}
$named = implode(', ', array_map(
static fn (FeatureStatus $status): string => '„' . $status->feature->label() . '“',
$this->locked
));
$plugins = implode(', ', $this->plugins);
return Errors::make(
'compat_incompatible',
sprintf(
'%s ist auf dieser Website nicht mit LIT MCP kompatibel: %s ist gesperrt. Es wurde nichts geändert.',
$plugins,
$named
),
sprintf(
'Gesperrt: %s. Andere Werkzeuge funktionieren weiter. '
. 'Den Nutzer informieren, dass %s geprüft werden muss.',
$named,
$plugins
),
423,
[
'features' => array_map(static fn (FeatureStatus $status): array => $status->summary(), $this->locked),
'problems' => array_values(array_unique(array_merge(
...array_map(static fn (FeatureStatus $status): array => $status->problems, $this->locked)
))),
]
);
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -14,7 +14,7 @@ final class CheckReport
public readonly string $id, public readonly string $id,
public readonly string $createdAt, public readonly string $createdAt,
public readonly int $userId, public readonly int $userId,
public readonly string $diviVersion, public readonly string $version,
public readonly string $pluginVersion, public readonly string $pluginVersion,
public readonly string $stateKey, public readonly string $stateKey,
public readonly array $steps public readonly array $steps
@@ -35,11 +35,16 @@ final class CheckReport
return true; return true;
} }
/** Green and made for exactly this Divi version, plugin version and Divi files (ReleaseResolver::stateKey()). */ /** Green and made for exactly this version, plugin version and state (Profile::$stateKey). */
public function freshFor(string $diviVersion, string $pluginVersion, string $stateKey): bool public function freshFor(string $version, string $pluginVersion, string $stateKey): bool
{ {
return $this->green() return $this->green() && $this->currentFor($version, $pluginVersion, $stateKey);
&& $this->diviVersion === $diviVersion }
/** Made for exactly this state, green or not. */
public function currentFor(string $version, string $pluginVersion, string $stateKey): bool
{
return $this->version === $version
&& $this->pluginVersion === $pluginVersion && $this->pluginVersion === $pluginVersion
&& $this->stateKey === $stateKey; && $this->stateKey === $stateKey;
} }
@@ -55,26 +60,29 @@ final class CheckReport
return null; return null;
} }
/** @return array<string, mixed> */ /**
public function toArray(): array * @param string $versionKey Array key of the version; Divi keeps 'divi_version' so stored reports stay as they are.
* @return array<string, mixed>
*/
public function toArray(string $versionKey = 'version'): array
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'created_at' => $this->createdAt, 'created_at' => $this->createdAt,
'user_id' => $this->userId, 'user_id' => $this->userId,
'divi_version' => $this->diviVersion, $versionKey => $this->version,
'plugin_version' => $this->pluginVersion, 'plugin_version' => $this->pluginVersion,
'state_key' => $this->stateKey, 'state_key' => $this->stateKey,
'steps' => array_map(static fn (CheckStep $step): array => $step->toArray(), $this->steps), 'steps' => array_map(static fn (CheckStep $step): array => $step->toArray(), $this->steps),
]; ];
} }
public static function fromArray(mixed $data): ?self public static function fromArray(mixed $data, string $versionKey = 'version'): ?self
{ {
if (! is_array($data)) { if (! is_array($data)) {
return null; return null;
} }
foreach (['id', 'created_at', 'divi_version', 'plugin_version', 'state_key'] as $key) { foreach (['id', 'created_at', $versionKey, 'plugin_version', 'state_key'] as $key) {
if (! is_string($data[$key] ?? null)) { if (! is_string($data[$key] ?? null)) {
return null; return null;
} }
@@ -96,7 +104,7 @@ final class CheckReport
$data['id'], $data['id'],
$data['created_at'], $data['created_at'],
$data['user_id'], $data['user_id'],
$data['divi_version'], $data[$versionKey],
$data['plugin_version'], $data['plugin_version'],
$data['state_key'], $data['state_key'],
$steps $steps
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/**
* The latest admin check report of one profile (spec §6.3), option lit_mcp_<profile>_check_report. Not final so the
* tab can be tested with a mock.
*/
class CheckReportStore
{
/** @param string $versionKey Array key of the version in the stored report (Divi: 'divi_version'). */
public function __construct(private string $profileId, private string $versionKey = 'version')
{
}
public static function forProfile(Profile $profile): self
{
return new self($profile->id, $profile->reportVersionKey);
}
public function latest(): ?CheckReport
{
return CheckReport::fromArray(get_option($this->option(), null), $this->versionKey);
}
public function save(CheckReport $report): void
{
update_option($this->option(), $report->toArray($this->versionKey), false);
}
private function option(): string
{
return 'lit_mcp_' . $this->profileId . '_check_report';
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/**
* One step of the admin check (spec §6.2); skipped = not run. The label is stored with the step, so a report stays
* readable without the profile that made it; an old report without one shows the raw key.
*/
final class CheckStep
{
public const MANIFEST = 'manifest';
public const MIRRORS = 'mirrors';
/** The label of a generic step of the profile named $profileLabel; null for any other key. */
public static function genericLabel(string $key, string $profileLabel): ?string
{
return match ($key) {
self::MANIFEST => sprintf('%s-Funktionen, Klassen und Registrierungen', $profileLabel),
self::MIRRORS => sprintf('Fingerabdrücke nachgebauter %s-Funktionen', $profileLabel),
default => null,
};
}
/** @param list<string> $problems */
public function __construct(
public readonly string $key,
public readonly array $problems,
public readonly bool $skipped = false,
public readonly ?string $label = null
) {
}
public function green(): bool
{
return ! $this->skipped && $this->problems === [];
}
public function label(): string
{
return $this->label ?? $this->key;
}
/** @return array{key: string, problems: list<string>, skipped: bool, label?: string} */
public function toArray(): array
{
$data = ['key' => $this->key, 'problems' => $this->problems, 'skipped' => $this->skipped];
return $this->label === null ? $data : $data + ['label' => $this->label];
}
public static function fromArray(mixed $data): ?self
{
if (! is_array($data) || ! is_string($data['key'] ?? null) || ! is_array($data['problems'] ?? null)) {
return null;
}
return new self(
$data['key'],
array_values(array_filter($data['problems'], 'is_string')),
($data['skipped'] ?? false) === true,
is_string($data['label'] ?? null) ? $data['label'] : null
);
}
}
+280
View File
@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use Closure;
use LIT\MCP\Log;
use LIT\MCP\Plugin;
use LogicException;
use Throwable;
defined('ABSPATH') || exit;
/**
* The compatibility state of all third-party plugins, evaluated per Profile and memoised per request. Everything
* that cannot be judged (a throwing closure or check, an unknown version under a minimum, an unowned capability)
* counts as locked – fail closed.
*/
final class Compatibility
{
/** @var array<string, Profile> keyed by profile ID */
private array $byId = [];
/** @var array<string, array{0: Capability, 1: Profile}> keyed by capability ID */
private array $byCapability = [];
/** @var array<string, bool> */
private array $actives = [];
/** @var array<string, string|null> */
private array $versions = [];
/** @var array<string, CompatibilityMatrix> */
private array $matrices = [];
/** @var array<string, ManifestCheck> */
private array $checks = [];
/** @var Closure(Profile): ManifestCheck */
private Closure $check;
/** @var Closure(Profile): ?CheckReport */
private Closure $report;
/**
* @param list<Profile> $profiles
* @param (Closure(Profile): ManifestCheck)|null $check Builds the check of a profile; default: Profile::checker().
* @param (Closure(Profile): ?CheckReport)|null $report The latest check report of a profile; a red one for
* the current state locks a LockOnFailure profile.
* Default: none (the structure check is the base).
* @throws LogicException For a profile ID or a capability ID that occurs twice.
*/
public function __construct(private array $profiles, ?Closure $check = null, ?Closure $report = null)
{
$this->report = $report ?? static fn (Profile $profile): ?CheckReport => null;
$this->check = $check ?? static fn (Profile $profile): ManifestCheck => $profile->checker();
foreach ($profiles as $profile) {
if (isset($this->byId[$profile->id])) {
throw new LogicException('profile ID twice: ' . $profile->id);
}
$this->byId[$profile->id] = $profile;
foreach ($profile->capabilities as $capability) {
if (isset($this->byCapability[$capability->id()])) {
throw new LogicException('capability ID twice: ' . $capability->id());
}
$this->byCapability[$capability->id()] = [$capability, $profile];
}
}
}
/** @return list<Profile> */
public function profiles(): array
{
return $this->profiles;
}
public function profile(string $id): ?Profile
{
return $this->byId[$id] ?? null;
}
public function capability(string $id): ?Capability
{
return ($this->byCapability[$id] ?? [null])[0];
}
public function owner(Capability $capability): ?Profile
{
return ($this->byCapability[$capability->id()] ?? [1 => null])[1];
}
public function active(Profile $profile): bool
{
return $this->actives[$profile->id] ??= $profile->active === null
|| self::attempt($profile->active, $profile) === true;
}
public function version(Profile $profile): ?string
{
if (! array_key_exists($profile->id, $this->versions)) {
$version = $profile->version === null ? null : self::attempt($profile->version, $profile);
$this->versions[$profile->id] = is_string($version) ? $version : null;
}
return $this->versions[$profile->id];
}
public function matrix(Profile $profile): CompatibilityMatrix
{
return $this->matrices[$profile->id] ??= $this->build($profile);
}
/** @return list<string> Labels of what is wrong with the plugin; for ReportOnly the problems of the check. */
public function problems(Profile $profile): array
{
if ($profile->tier !== Tier::ReportOnly) {
return $this->matrix($profile)->problems();
}
try {
return array_values(array_map(
static fn (Problem $problem): string => $problem->label,
$this->checkOf($profile)->problems()
));
} catch (Throwable $e) {
Log::failure('compat ' . $profile->id, $e);
return [self::failed($profile)];
}
}
/** Runs the admin check of the profile (spec §6.2); a report for $userId. */
public function check(Profile $profile, int $userId): CheckReport
{
return CompatibilityCheck::forProfile($profile, $this->checkOf($profile))->run($userId);
}
public function usable(Capability $capability): bool
{
$owner = $this->owner($capability);
return $owner !== null && $this->matrix($owner)->of($capability)->usable();
}
/**
* The lock of those of $capabilities that are not usable, null when all are. Unowned ones are left out.
*
* @param list<Capability> $capabilities
*/
public function lockOf(array $capabilities): ?CapabilityLocked
{
$locked = [];
$owners = [];
foreach ($capabilities as $capability) {
$owner = $this->owner($capability);
if ($owner === null) {
continue;
}
$status = $this->matrix($owner)->of($capability);
if (! $status->usable()) {
$locked[$capability->id()] = $status;
$owners[$owner->id] = $owner;
}
}
return $locked === [] ? null : CapabilityLocked::of(array_values($locked), array_values($owners));
}
private function build(Profile $profile): CompatibilityMatrix
{
$capabilities = $profile->capabilities;
try {
if ($profile->tier === Tier::ReleasePerVersion) {
return ($profile->matrix)();
}
if ($profile->tier === Tier::ReportOnly) {
return CompatibilityMatrix::allReleased($capabilities);
}
if (! $this->active($profile)) {
return CompatibilityMatrix::allIncompatible(
$capabilities,
sprintf('%s ist nicht installiert oder nicht aktiv.', $profile->label)
);
}
$tooOld = $this->versionProblem($profile);
if ($tooOld !== null) {
return CompatibilityMatrix::allIncompatible($capabilities, $tooOld);
}
$red = $this->redReport($profile);
if ($red !== null) {
// shortcut: a step names no capability, so a red check locks all of them; refine with step features.
return CompatibilityMatrix::allIncompatible($capabilities, $red);
}
return CompatibilityMatrix::fromProblems(
$capabilities,
$this->checkOf($profile)->problems(),
null,
automatic: true
);
} catch (Throwable $e) {
Log::failure('compat ' . $profile->id, $e);
return CompatibilityMatrix::allIncompatible($capabilities, self::failed($profile));
}
}
/**
* What the latest report says when it is red and made for the current state; null = no reason to lock. A report
* without steps is no check result. The reason names only the labels of the steps, never their problem texts
* (they reach MCP clients in the 423). Only a new green check or a version change (plugin or third-party plugin)
* lifts a lock by a red report.
*/
private function redReport(Profile $profile): ?string
{
$report = ($this->report)($profile);
$version = $this->version($profile) ?? '';
$stateKey = $profile->stateKey === null ? '' : ($profile->stateKey)($version);
if (
$report === null
|| $report->steps === []
|| $report->green()
|| ! $report->currentFor($version, Plugin::VERSION, $stateKey)
) {
return null;
}
$failed = array_map(
static fn (CheckStep $step): string => $step->label(),
array_values(array_filter($report->steps, static fn (CheckStep $step): bool => ! $step->green()))
);
return sprintf(
'Die letzte Prüfung von %s ist fehlgeschlagen (%s); Details im Admin-Bereich.',
$profile->label,
implode('; ', $failed)
);
}
/** The report stored by the admin check, for Compatibility's $report parameter. */
public static function storedReport(Profile $profile): ?CheckReport
{
return CheckReportStore::forProfile($profile)->latest();
}
private function versionProblem(Profile $profile): ?string
{
if ($profile->minVersion === null) {
return null;
}
$version = $this->version($profile);
if ($version === null) {
return sprintf(
'Die Version von %s ist unbekannt (Mindestversion %s).',
$profile->label,
$profile->minVersion
);
}
return version_compare($version, $profile->minVersion, '<')
? sprintf('%s %s ist älter als die Mindestversion %s.', $profile->label, $version, $profile->minVersion)
: null;
}
private function checkOf(Profile $profile): ManifestCheck
{
return $this->checks[$profile->id] ??= ($this->check)($profile);
}
private static function failed(Profile $profile): string
{
return sprintf('Die Kompatibilitätsprüfung von %s ist fehlgeschlagen.', $profile->label);
}
/** The closure's result, null when it throws. */
private static function attempt(Closure $closure, Profile $profile): mixed
{
try {
return $closure();
} catch (Throwable $e) {
Log::failure('compat ' . $profile->id, $e);
return null;
}
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use Closure;
use LIT\MCP\Log;
use LIT\MCP\Plugin;
use Throwable;
defined('ABSPATH') || exit;
/**
* The admin check of one profile (spec §6.2) – also wp lit-mcp divi-check: manifest (code and registrations),
* fingerprints (only when a manifest has mirrors), then the extra steps of the profile. A step that throws is a red
* step. Not final so the route and the command can be tested with a mock.
*/
class CompatibilityCheck
{
public function __construct(private Profile $profile, private ManifestCheck $manifests)
{
}
public static function forProfile(Profile $profile, ?ManifestCheck $check = null): self
{
return new self($profile, $check ?? $profile->checker());
}
public function run(int $userId): CheckReport
{
$label = $this->profile->label;
$manifest = self::guarded(fn (): array => ManifestCheck::failed(
array_merge($this->manifests->codeChecks(), $this->manifests->registrationChecks())
));
$steps = [new CheckStep(
CheckStep::MANIFEST,
$manifest,
false,
CheckStep::genericLabel(CheckStep::MANIFEST, $label)
)];
if (array_filter($this->profile->manifests, static fn (Manifest $m): bool => $m->mirrors !== []) !== []) {
$steps[] = new CheckStep(
CheckStep::MIRRORS,
self::guarded(fn (): array => ManifestCheck::failed($this->manifests->mirrorChecks())),
false,
CheckStep::genericLabel(CheckStep::MIRRORS, $label)
);
}
if ($this->profile->extraSteps !== null) {
$steps = [...$steps, ...($this->profile->extraSteps)($manifest === [])];
}
$version = (string) ($this->profile->version === null ? '' : ($this->profile->version)());
return new CheckReport(
wp_generate_uuid4(),
gmdate('c'),
$userId,
$version,
Plugin::VERSION,
$this->profile->stateKey === null ? '' : ($this->profile->stateKey)($version),
$steps
);
}
/** @return list<string> The step's problems; a throwing step is one problem. */
public static function guarded(Closure $step): array
{
try {
return array_values(array_filter((array) $step(), 'is_string'));
} catch (Throwable $e) {
// The report reaches MCP clients through the lock reason; the exception text stays in the log.
Log::failure('compat check step', $e);
return ['Prüfschritt abgebrochen (Details im PHP-Error-Log).'];
}
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use Closure;
use LIT\MCP\Integrations\Core\EnvironmentComponent;
use LogicException;
defined('ABSPATH') || exit;
/**
* One third-party plugin in lit-site-status: not installed, active, or active but broken (problems filled). No
* per-capability rows – the problem labels already name what is wrong. A ReleasePerVersion profile (Divi) brings its
* own component with the release status. The Compatibility comes per closure because it exists only after the
* integrations that provide the profiles.
*/
final class CompatibilityComponent implements EnvironmentComponent
{
/**
* @param Closure(): Compatibility $compatibility
* @throws LogicException For a ReleasePerVersion profile.
*/
public function __construct(private Profile $profile, private Closure $compatibility)
{
if ($profile->tier === Tier::ReleasePerVersion) {
throw new LogicException(
sprintf('profile %s has a release status and needs its own component', $profile->id)
);
}
}
/** @return list<CompatibilityComponent> One per profile except the ReleasePerVersion ones (own components). */
public static function forProfiles(Compatibility $compatibility): array
{
$profiles = array_filter(
$compatibility->profiles(),
static fn (Profile $profile): bool => $profile->tier !== Tier::ReleasePerVersion
);
return array_values(array_map(
static fn (Profile $profile): self => new self($profile, static fn (): Compatibility => $compatibility),
$profiles
));
}
/** @return array{id: string, active: bool, version: ?string, problems: list<string>, release: null} */
public function describe(): array
{
$compatibility = ($this->compatibility)();
if (! $compatibility->active($this->profile)) {
return [
'id' => $this->profile->id,
'active' => false,
'version' => null,
'problems' => [],
'release' => null,
];
}
return [
'id' => $this->profile->id,
'active' => true,
'version' => $compatibility->version($this->profile),
'problems' => $compatibility->problems($this->profile),
'release' => null,
];
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/**
* The release state of every capability of one integration (E1). A capability the constructor did not get a status
* for is incompatible (fail closed), so of() always answers. The overall state of the matrix is its worst state.
*/
final class CompatibilityMatrix
{
public const MISSING = 'Für diese Fähigkeit liegt kein Prüfergebnis vor.';
/** Where a release comes from: the plugin's list of tested versions, the site's database, the automatic check. */
public const SOURCE_CODE = 'code';
public const SOURCE_DATABASE = 'database';
public const SOURCE_AUTO = 'auto';
/** @var array<string, FeatureStatus> keyed by capability ID, in the order of the capabilities */
private array $features = [];
/**
* @param list<Capability> $capabilities
* @param array<string, FeatureStatus> $statuses keyed by capability ID
*/
public function __construct(array $capabilities, array $statuses)
{
foreach ($capabilities as $capability) {
$status = $statuses[$capability->id()] ?? null;
$this->features[$capability->id()] = $status instanceof FeatureStatus && $status->feature === $capability
? $status
: self::missing($capability);
}
}
private static function missing(Capability $capability): FeatureStatus
{
return new FeatureStatus($capability, ReleaseState::Incompatible, [self::MISSING]);
}
/** @param list<Capability> $capabilities */
public static function allReleased(array $capabilities, string $source = self::SOURCE_CODE): self
{
return self::uniform($capabilities, ReleaseState::Released, [], $source);
}
/** @param list<Capability> $capabilities */
public static function allIncompatible(array $capabilities, string $problem): self
{
return self::uniform($capabilities, ReleaseState::Incompatible, [$problem]);
}
/**
* Every capability in $state – the matrix of a status built without one (ReleaseStatus' constructor).
*
* @param list<Capability> $capabilities
* @param list<string> $problems
*/
public static function uniform(
array $capabilities,
ReleaseState $state,
array $problems = [],
?string $source = null
): self {
$released = $state === ReleaseState::Released;
$statuses = [];
foreach ($capabilities as $capability) {
$statuses[$capability->id()] = new FeatureStatus(
$capability,
$state,
$released ? [] : $problems,
$released ? ($source ?? self::SOURCE_CODE) : null
);
}
return new self($capabilities, $statuses);
}
/**
* The matrix the problems of a check add up to (spec §5.2–§5.4): per capability a hard problem (call, mirror,
* constant) makes it incompatible, released version or not; a released version releases every other one; without
* release the automatic release decides (changed signature: incompatible, missing reference: untested, no
* problem: released from SOURCE_AUTO), else it is untested.
*
* @param list<Capability> $capabilities
* @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).
*/
public static function fromProblems(array $capabilities, array $problems, ?string $source, bool $automatic): self
{
$statuses = [];
foreach ($capabilities as $capability) {
$concerning = array_values(array_filter(
$problems,
static fn (Problem $problem): bool => $problem->concerns($capability)
));
$statuses[$capability->id()] = self::statusOf($capability, $concerning, $source, $automatic);
}
return new self($capabilities, $statuses);
}
/** @param list<Problem> $problems The problems concerning $capability. */
private static function statusOf(
Capability $capability,
array $problems,
?string $source,
bool $automatic
): FeatureStatus {
$hard = array_filter($problems, static fn (Problem $problem): bool => $problem->isHard());
if ($hard !== []) {
return new FeatureStatus($capability, ReleaseState::Incompatible, self::labels($hard));
}
if ($source !== null) {
return new FeatureStatus($capability, ReleaseState::Released, [], $source);
}
if (! $automatic) {
return new FeatureStatus($capability, ReleaseState::Untested, self::labels($problems));
}
$changed = array_filter($problems, static fn (Problem $problem): bool => $problem->kind === Problem::SIGNATURE);
if ($changed !== []) {
return new FeatureStatus($capability, ReleaseState::Incompatible, self::labels($changed));
}
return $problems === []
? new FeatureStatus($capability, ReleaseState::Released, [], self::SOURCE_AUTO)
: new FeatureStatus($capability, 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));
}
public function of(Capability $feature): FeatureStatus
{
return $this->features[$feature->id()] ?? self::missing($feature);
}
/** @param list<Capability> $features */
public function usable(array $features): bool
{
foreach ($features as $feature) {
if (! $this->of($feature)->usable()) {
return false;
}
}
return true;
}
/**
* The statuses of those of $features that are not usable – each once, in the order of $features. The one answer
* to "what locks this call" for the gates, the adapters and the
* refusals.
*
* @param list<Capability> $features
* @return list<FeatureStatus>
*/
public function locked(array $features): array
{
$locked = [];
foreach ($features as $feature) {
$status = $this->of($feature);
if (! $status->usable()) {
$locked[$feature->id()] = $status;
}
}
return array_values($locked);
}
/** @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));
}
}
@@ -2,14 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** A Divi constant an adapter reads (spec §4.1); it must be defined. */ /** A constant of a third-party plugin or theme that an adapter reads (spec §4.1); it must be defined. */
final class ConstantRequirement implements Requirement final class ConstantRequirement implements Requirement
{ {
/** @param list<DiviFeature> $features [] = the default of the manifest. */ /** @param list<Capability> $features [] = the default of the manifest. */
public function __construct(public readonly string $name, private readonly array $features = []) public function __construct(public readonly string $name, private readonly array $features = [])
{ {
} }
@@ -2,20 +2,20 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** The release state of one Divi feature (E1): released (with its source), untested or incompatible. */ /** The release state of one capability (E1): released (with its source), untested or incompatible. */
final class FeatureStatus final class FeatureStatus
{ {
/** /**
* @param list<string> $problems Labels of the problems behind the state (Problem::$label). * @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 * @param string|null $source For a released feature: CompatibilityMatrix::SOURCE_CODE, SOURCE_DATABASE or
* SOURCE_AUTO; null otherwise. * SOURCE_AUTO; null otherwise.
*/ */
public function __construct( public function __construct(
public readonly DiviFeature $feature, public readonly Capability $feature,
public readonly ReleaseState $state, public readonly ReleaseState $state,
public readonly array $problems = [], public readonly array $problems = [],
public readonly ?string $source = null public readonly ?string $source = null
@@ -31,7 +31,7 @@ final class FeatureStatus
public function summary(): array public function summary(): array
{ {
return [ return [
'id' => $this->feature->value, 'id' => $this->feature->id(),
'label' => $this->feature->label(), 'label' => $this->feature->label(),
'state' => $this->state->value, 'state' => $this->state->value,
'problems' => $this->problems, 'problems' => $this->problems,
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/** A global variable an adapter reads (e.g. a function-pointer or settings global of a plugin); it must be set. */
final class GlobalRequirement implements Requirement
{
/** @param list<Capability> $features [] = the default of the manifest. */
public function __construct(public readonly string $name, private readonly array $features = [])
{
}
public function label(): string
{
return 'Globale Variable ' . $this->name;
}
public function features(): array
{
return $this->features;
}
/** isset, not array_key_exists: a global that is null cannot be used by the adapter either. */
public function isSet(): bool
{
return isset($GLOBALS[$this->name]);
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -2,40 +2,36 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* The Divi dependencies of one adapter (spec §4.1) – the single source for DiviCompatibility, the release gate, * The external dependencies of one adapter (spec §4.1) – the single source for the compatibility checks, the release
* the admin check and DiviBoundaryTest. Pure data, no WordPress calls. * gate, the admin check and DiviBoundaryTest. Pure data, no WordPress calls. Only area and calls are needed; the
* other fields are optional (the Divi integration uses all of them).
*/ */
final class Manifest 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> */ /** @var list<ConstantRequirement> */
public readonly array $constants; public readonly array $constants;
/** /**
* @param string $area One of the constants above (the five areas of spec §3). * @param string $area Area label for reports (the Divi integration: DiviAreas).
* @param list<Call> $calls Divi functions and static methods the adapter calls. * @param list<Call> $calls Functions and static methods the adapter calls.
* @param list<string|ConstantRequirement> $constants Divi constants it reads; a plain name stands for a * @param list<string|ConstantRequirement> $constants Constants it reads; a plain name stands for a
* ConstantRequirement without features of its own. * ConstantRequirement without features of its own.
* @param list<string> $postTypes Divi post types it needs registered. * @param list<string> $postTypes Post types it needs registered.
* @param list<string> $taxonomies Divi taxonomies it needs registered. * @param list<string> $taxonomies Taxonomies it needs registered.
* @param list<string> $blockTypes Divi block types it needs (registered or in Divi's * @param list<string> $blockTypes Block types it needs (registered or known
* module metadata). * to the ManifestCheck knowsBlock closure).
* @param list<string> $keys Other Divi names it uses as strings: meta keys, options, * @param list<string> $keys Other names it uses as strings: meta keys, options,
* internal post types that need no registration check, * internal post types that need no registration check,
* query arguments. * query arguments.
* @param list<Mirror> $mirrors Divi functions whose storage logic it repeats. * @param list<Mirror> $mirrors Functions whose storage logic it repeats.
* @param list<DiviFeature> $features The features of every call, mirror and constant that names * @param list<Capability> $features The features of every call, mirror and constant that names
* none itself (featuresOf()). * none itself (featuresOf()).
* @param list<GlobalRequirement> $globals Global variables it reads (isset check).
*/ */
public function __construct( public function __construct(
public readonly string $area, public readonly string $area,
@@ -46,7 +42,8 @@ final class Manifest
public readonly array $blockTypes = [], public readonly array $blockTypes = [],
public readonly array $keys = [], public readonly array $keys = [],
public readonly array $mirrors = [], public readonly array $mirrors = [],
public readonly array $features = [] public readonly array $features = [],
public readonly array $globals = []
) { ) {
$this->constants = array_map( $this->constants = array_map(
static fn (string|ConstantRequirement $constant): ConstantRequirement => is_string($constant) static fn (string|ConstantRequirement $constant): ConstantRequirement => is_string($constant)
@@ -56,13 +53,13 @@ final class Manifest
); );
} }
/** @return list<Requirement> Calls, constants and mirrors – everything the release gate checks as code. */ /** @return list<Requirement> Calls, constants, mirrors and globals – everything the release gate checks as code. */
public function requirements(): array public function requirements(): array
{ {
return [...$this->calls, ...$this->constants, ...$this->mirrors]; return [...$this->calls, ...$this->constants, ...$this->mirrors, ...$this->globals];
} }
/** @return list<DiviFeature> The features $requirement names itself, else this manifest's default. */ /** @return list<Capability> The features $requirement names itself, else this manifest's default. */
public function featuresOf(Requirement $requirement): array public function featuresOf(Requirement $requirement): array
{ {
$own = $requirement->features(); $own = $requirement->features();
@@ -71,8 +68,8 @@ final class Manifest
} }
/** /**
* @return list<string> Every Divi name the adapter's code may contain (DiviBoundaryTest): called functions and * @return list<string> Every external name the adapter's code may contain (DiviBoundaryTest): called functions and
* classes, constants, post types, taxonomies, keys and the names of mirrored functions. * classes, constants, globals, post types, taxonomies, keys and the names of mirrored functions.
*/ */
public function names(): array public function names(): array
{ {
@@ -86,6 +83,7 @@ final class Manifest
return array_values(array_unique(array_merge( return array_values(array_unique(array_merge(
$names, $names,
$constants, $constants,
array_map(static fn (GlobalRequirement $global): string => $global->name, $this->globals),
$this->postTypes, $this->postTypes,
$this->taxonomies, $this->taxonomies,
$this->keys, $this->keys,
@@ -2,18 +2,16 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
use Closure; use Closure;
use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LogicException;
use ReflectionFunction;
use ReflectionMethod;
use Throwable; use Throwable;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* The checks of all adapter manifests (spec §4.3), as label => check for reports, and as problems with their Divi * The checks of all adapter manifests (spec §4.3), as label => check for reports, and as problems with their
* features for the release matrix (problems(), E1). Code checks (calls with the argument count the adapter passes * 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, * 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. * taxonomies, block types) depend on the request context (stage3 finding 6) and only show in reports.
@@ -22,32 +20,30 @@ defined('ABSPATH') || exit;
*/ */
class ManifestCheck 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, Manifest> $manifests Area key => manifest.
* @param array<string, string>|null $expected Mirror ID or "sig:<symbol>" => sha256; * @param array<string, string>|null $expected Mirror ID or "sig:<symbol>" => sha256; null = none known.
* null = DiviReference::expected().
* @param (Closure(Mirror): ?string)|null $current Hash of the installed mirror, null = unreadable; * @param (Closure(Mirror): ?string)|null $current Hash of the installed mirror, null = unreadable;
* null = SourceFingerprint in the Divi theme directory. * no closure = every mirror is unreadable (a problem).
* @param (Closure(string): bool)|null $knowsBlock null = DiviModules::knowsBlock(). * @param (Closure(string): bool)|null $knowsBlock Whether a block type is known; required when a manifest
* has block types, which would otherwise pass unchecked.
* @param bool $signatures false = no signature checks: neither reference-missing
* nor signature problems (calls are still checked for
* existence and argument count).
* @throws LogicException When a manifest has block types but $knowsBlock is null.
*/ */
public function __construct( public function __construct(
?array $manifests = null, private array $manifests,
private ?array $expected = null, private ?array $expected = null,
?Closure $current = null, private ?Closure $current = null,
?Closure $knowsBlock = null private ?Closure $knowsBlock = null,
private bool $signatures = true
) { ) {
$this->manifests = $manifests ?? DiviAdapters::manifests(); foreach ($manifests as $manifest) {
$this->current = $current ?? self::installed(...); if ($manifest->blockTypes !== [] && $knowsBlock === null) {
$this->knowsBlock = $knowsBlock ?? DiviModules::knowsBlock(...); throw new LogicException('block types need a knowsBlock closure, else they pass unchecked');
}
}
} }
/** @return array<string, Closure(): bool> */ /** @return array<string, Closure(): bool> */
@@ -62,6 +58,9 @@ class ManifestCheck
$checks[$manifest->area . ': ' . $constant->label()] = $checks[$manifest->area . ': ' . $constant->label()] =
static fn (): bool => defined($constant->name); static fn (): bool => defined($constant->name);
} }
foreach ($manifest->globals as $global) {
$checks[$manifest->area . ': ' . $global->label()] = static fn (): bool => $global->isSet();
}
} }
return $checks; return $checks;
@@ -92,7 +91,7 @@ class ManifestCheck
$checks[$manifest->area . ': Taxonomie ' . $taxonomy] = $checks[$manifest->area . ': Taxonomie ' . $taxonomy] =
static fn (): bool => taxonomy_exists($taxonomy); static fn (): bool => taxonomy_exists($taxonomy);
} }
foreach ($manifest->blockTypes as $block) { foreach ($this->knowsBlock === null ? [] : $manifest->blockTypes as $block) {
$checks[$manifest->area . ': Block-Typ ' . $block] = fn (): bool => ($this->knowsBlock)($block); $checks[$manifest->area . ': Block-Typ ' . $block] = fn (): bool => ($this->knowsBlock)($block);
} }
} }
@@ -123,8 +122,8 @@ class ManifestCheck
} }
/** /**
* Calls (missing or other argument count: call; no reference: reference-missing; other signature: signature) * Calls (missing or other argument count: call; no reference: reference-missing; other signature: signature;
* and constants. Reflection only – cheap enough for every request. * the last two only with signatures), constants and globals. Reflection only – cheap enough for every request.
* *
* @return list<Problem> * @return list<Problem>
*/ */
@@ -147,6 +146,15 @@ class ManifestCheck
); );
} }
} }
foreach ($manifest->globals as $global) {
if (! $global->isSet()) {
$problems[] = new Problem(
$manifest->area . ': ' . $global->label(),
$manifest->featuresOf($global),
Problem::GLOBAL
);
}
}
} }
return $problems; return $problems;
@@ -154,8 +162,8 @@ class ManifestCheck
/** /**
* Mirrors that differ from their reference, cannot be read or have no reference – kind mirror in every case: * 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 * a mirror repeats how the third-party code writes, so it never runs unverified, whatever the release. Reads
* resolver caches the result. * files; the resolver caches the result.
* *
* @return list<Problem> * @return list<Problem>
*/ */
@@ -180,22 +188,7 @@ class ManifestCheck
/** The function or method exists and takes the number of arguments the adapter passes. */ /** The function or method exists and takes the number of arguments the adapter passes. */
public static function accepts(Call $call): bool public static function accepts(Call $call): bool
{ {
$class = $call->className(); return $call->accepts();
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 private function callProblem(Manifest $manifest, Call $call): ?Problem
@@ -206,7 +199,11 @@ class ManifestCheck
return new Problem($label, $features, Problem::CALL); return new Problem($label, $features, Problem::CALL);
} }
$expected = $this->expected()[DiviReference::signatureKey($call)] ?? null; if (! $this->signatures) {
return null;
}
$expected = ($this->expected ?? [])[$call->signatureKey()] ?? null;
if ($expected === null) { if ($expected === null) {
return new Problem($label . ' – Referenz der Signatur fehlt', $features, Problem::REFERENCE_MISSING); return new Problem($label . ' – Referenz der Signatur fehlt', $features, Problem::REFERENCE_MISSING);
} }
@@ -219,15 +216,9 @@ class ManifestCheck
/** The mirror's reference exists and the installed source has the same fingerprint. */ /** The mirror's reference exists and the installed source has the same fingerprint. */
private function matches(Mirror $mirror): bool private function matches(Mirror $mirror): bool
{ {
$expected = $this->expected()[$mirror->id] ?? null; $expected = ($this->expected ?? [])[$mirror->id] ?? null;
return $expected !== null && ($this->current)($mirror) === $expected; return $expected !== null && $this->current !== 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 private static function mirrorLabel(Manifest $manifest, Mirror $mirror): string
@@ -244,13 +235,4 @@ class ManifestCheck
return false; 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;
}
} }
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/**
* A function of a third-party plugin or theme whose logic an adapter repeats instead of calling it (spec §4.1
* "mirrors"). Its fingerprint (SourceFingerprint) must match the one in fingerprints.json, else the third-party code
* changed what we copy.
*/
final class Mirror implements Requirement
{
/**
* @param string $id Key in fingerprints.json and name of its reference source (the Divi integration
* keeps them in tests/Fixtures/divi/reference/<id>.txt – an example, not a rule).
* @param string $file File of the third-party code, relative to its plugin or 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 external name for DiviBoundaryTest); null for a
* function.
* @param string $what What the plugin repeats (German, for reports).
* @param list<Capability> $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 . '()';
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use Closure;
defined('ABSPATH') || exit;
/** The one check step a profile's behaviour probe makes (Profile::$extraSteps), shared by Pods and WP Fastest Cache. */
final class ProbeStep
{
/**
* Skipped while the manifest is red (the probe would fail anyway). A probe with nothing to probe (null) is a
* green step that says so, not a skipped one: a skipped step makes the report red and would lock the plugin on a
* site that simply has nothing to probe yet.
*
* @param Closure(): (list<string>|null) $probe Problems, [] = green, null = nothing to probe.
* @return Closure(bool): list<CheckStep>
*/
public static function extra(string $key, string $label, string $labelNothingToProbe, Closure $probe): Closure
{
return static function (bool $manifestClean) use ($key, $label, $labelNothingToProbe, $probe): array {
if (! $manifestClean) {
return [new CheckStep($key, [], true, $label)];
}
$none = false;
$problems = CompatibilityCheck::guarded(static function () use ($probe, &$none): array {
$result = $probe();
$none = $result === null;
return $result ?? [];
});
return [new CheckStep($key, $problems, false, $none ? $labelNothingToProbe : $label)];
};
}
}
@@ -2,15 +2,17 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
use Closure;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* One failed manifest check and the Divi features it concerns (Manifest::featuresOf()). The kind decides how the * One failed manifest check and the capabilities 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; * resolver weighs it (E1): call, mirror, constant and global lock their features for every version, released
* signature and reference-missing only matter for a version without release (automatic release). A problem without * or not; signature and reference-missing only matter for a version without release (automatic release). A problem
* features concerns every feature (fail closed). * without features concerns every feature (fail closed).
*/ */
final class Problem final class Problem
{ {
@@ -18,6 +20,7 @@ final class Problem
public const SIGNATURE = 'signature'; public const SIGNATURE = 'signature';
public const MIRROR = 'mirror'; public const MIRROR = 'mirror';
public const CONSTANT = 'constant'; public const CONSTANT = 'constant';
public const GLOBAL = 'global';
public const REFERENCE_MISSING = 'reference-missing'; public const REFERENCE_MISSING = 'reference-missing';
public const RESOLVER = 'resolver'; public const RESOLVER = 'resolver';
@@ -26,15 +29,16 @@ final class Problem
self::SIGNATURE, self::SIGNATURE,
self::MIRROR, self::MIRROR,
self::CONSTANT, self::CONSTANT,
self::GLOBAL,
self::REFERENCE_MISSING, self::REFERENCE_MISSING,
self::RESOLVER, self::RESOLVER,
]; ];
private const HARD = [self::CALL, self::MIRROR, self::CONSTANT, self::RESOLVER]; private const HARD = [self::CALL, self::MIRROR, self::CONSTANT, self::GLOBAL, self::RESOLVER];
/** /**
* @param string $label German, e.g. "Seiten: et_x()" (the labels of ManifestCheck). * @param string $label German, e.g. "Seiten: et_x()" (the labels of ManifestCheck).
* @param list<DiviFeature> $features * @param list<Capability> $features
* @param string $kind One of the constants above. * @param string $kind One of the constants above.
*/ */
public function __construct( public function __construct(
public readonly string $label, public readonly string $label,
@@ -43,13 +47,13 @@ final class Problem
) { ) {
} }
/** Locks its features whatever the release (the code no longer fits Divi or a mirror differs). */ /** Locks its features whatever the release (the third-party code no longer fits or a mirror differs). */
public function isHard(): bool public function isHard(): bool
{ {
return in_array($this->kind, self::HARD, true); return in_array($this->kind, self::HARD, true);
} }
public function concerns(DiviFeature $feature): bool public function concerns(Capability $feature): bool
{ {
return $this->features === [] || in_array($feature, $this->features, true); return $this->features === [] || in_array($feature, $this->features, true);
} }
@@ -60,12 +64,16 @@ final class Problem
return [ return [
'label' => $this->label, 'label' => $this->label,
'kind' => $this->kind, 'kind' => $this->kind,
'features' => array_map(static fn (DiviFeature $feature): string => $feature->value, $this->features), 'features' => array_map(static fn (Capability $feature): string => $feature->id(), $this->features),
]; ];
} }
/** The problem of toArray(), or null for anything else (an unknown kind or feature included). */ /**
public static function fromArray(mixed $row): ?self * The problem of toArray(), or null for anything else (an unknown kind or feature included).
*
* @param Closure(string): ?Capability $capability Finds a capability by its ID, null for an unknown one.
*/
public static function fromArray(mixed $row, Closure $capability): ?self
{ {
if ( if (
! is_array($row) ! is_array($row)
@@ -78,7 +86,7 @@ final class Problem
$features = []; $features = [];
foreach ($row['features'] as $value) { foreach ($row['features'] as $value) {
$feature = is_string($value) ? DiviFeature::tryFrom($value) : null; $feature = is_string($value) ? $capability($value) : null;
if ($feature === null) { if ($feature === null) {
return null; return null;
} }
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use Closure;
use LogicException;
defined('ABSPATH') || exit;
/**
* Everything the compatibility layer needs to know about one third-party plugin: its capabilities, what its code
* depends on, how to tell whether it is active and which version it has. Pure data; Compatibility evaluates it.
* Mit benannten Argumenten bauen.
*/
final class Profile
{
/**
* @param list<Capability> $capabilities
* @param list<Manifest> $manifests
* @param (Closure(): bool)|null $active Plugin installed and active; null = always.
* @param (Closure(): ?string)|null $version The installed version, null result = unknown.
* @param string|null $minVersion Lowest supported version (version_compare).
* @param (Closure(): CompatibilityMatrix)|null $matrix Required for ReleasePerVersion, unused otherwise.
* @param list<string> $testedVersions Versions the capabilities were verified against.
* @param (Closure(string): string)|null $stateKey What a check report is valid for, given the version.
* @param (Closure(bool): list<CheckStep>)|null $extraSteps Steps after manifest and fingerprints; gets
* whether the manifest step was clean.
* @param (Closure(): ManifestCheck)|null $manifestCheck The check of this profile; null = its manifests
* without signature checks.
* @param (Closure(CheckStep): string)|null $stepLabel Label of a stored step that has none (reports from
* before labels were stored); null = the step's own.
* @param string $reportVersionKey Array key of the version in stored reports and
* release rows (Divi: 'divi_version').
* @throws LogicException For a ReleasePerVersion profile without matrix or a capability ID given twice.
*/
public function __construct(
public readonly string $id,
public readonly string $label,
public readonly Tier $tier,
public readonly array $capabilities,
public readonly array $manifests = [],
public readonly ?Closure $active = null,
public readonly ?Closure $version = null,
public readonly ?string $minVersion = null,
public readonly ?Closure $matrix = null,
public readonly array $testedVersions = [],
public readonly ?Closure $stateKey = null,
public readonly ?Closure $extraSteps = null,
public readonly ?Closure $manifestCheck = null,
public readonly ?Closure $stepLabel = null,
public readonly string $reportVersionKey = 'version'
) {
if ($tier === Tier::ReleasePerVersion && $matrix === null) {
throw new LogicException(sprintf('profile %s: a ReleasePerVersion profile needs a matrix closure', $id));
}
$ids = array_map(static fn (Capability $capability): string => $capability->id(), $capabilities);
$duplicates = array_keys(array_filter(array_count_values($ids), static fn (int $n): bool => $n > 1));
if ($duplicates !== []) {
throw new LogicException(sprintf('profile %s: capability IDs twice: %s', $id, implode(', ', $duplicates)));
}
}
/** The check of this profile: its manifestCheck closure, else its manifests without signature checks. */
public function checker(): ManifestCheck
{
return $this->manifestCheck !== null
? ($this->manifestCheck)()
: new ManifestCheck($this->manifests, signatures: false);
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/** An integration that describes the third-party plugin it depends on for the compatibility layer. */
interface ProvidesCompatibility
{
/** @return list<Profile> */
public function compatibilities(): array;
}
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/**
* Versions of one profile released on this site after a green admin check (spec §5.3, source "database"), option
* lit_mcp_<profile>_releases. An entry holds for the plugin version that ran the check only.
*
* Not final so the resolver can be tested with a mock.
*/
class ReleaseStore
{
/** @param string $versionKey Row key of the version (Divi: 'divi_version', so stored rows stay as they are). */
public function __construct(private string $profileId, private string $versionKey = 'version')
{
}
public static function forProfile(Profile $profile): self
{
return new self($profile->id, $profile->reportVersionKey);
}
/**
* @return list<array<string, string|int>> Rows with the keys <versionKey>, plugin_version, user_id, released_at,
* report_id.
*/
public function entries(): array
{
$raw = get_option($this->option(), []);
$entries = [];
foreach (is_array($raw) ? $raw : [] as $row) {
if (
is_array($row)
&& is_string($row[$this->versionKey] ?? null)
&& is_string($row['plugin_version'] ?? null)
&& is_int($row['user_id'] ?? null)
&& is_string($row['released_at'] ?? null)
&& is_string($row['report_id'] ?? null)
) {
$entries[] = [
$this->versionKey => $row[$this->versionKey],
'plugin_version' => $row['plugin_version'],
'user_id' => $row['user_id'],
'released_at' => $row['released_at'],
'report_id' => $row['report_id'],
];
}
}
return $entries;
}
/**
* @return array<string, string|int>|null
*/
public function find(string $version, string $pluginVersion): ?array
{
foreach ($this->entries() as $entry) {
if ($entry[$this->versionKey] === $version && $entry['plugin_version'] === $pluginVersion) {
return $entry;
}
}
return null;
}
public function add(string $version, string $pluginVersion, int $userId, string $reportId): void
{
$entries = $this->without($version, $pluginVersion);
$entries[] = [
$this->versionKey => $version,
'plugin_version' => $pluginVersion,
'user_id' => $userId,
'released_at' => gmdate('c'),
'report_id' => $reportId,
];
update_option($this->option(), $entries, false);
}
public function withdraw(string $version, string $pluginVersion): bool
{
if ($this->find($version, $pluginVersion) === null) {
return false;
}
update_option($this->option(), $this->without($version, $pluginVersion), false);
return true;
}
/**
* @return list<array<string, string|int>> Rows with the keys <versionKey>, plugin_version, user_id, released_at,
* report_id.
*/
private function without(string $version, string $pluginVersion): array
{
return array_values(array_filter(
$this->entries(),
fn (array $entry): bool => $entry[$this->versionKey] !== $version
|| $entry['plugin_version'] !== $pluginVersion
));
}
private function option(): string
{
return 'lit_mcp_' . $this->profileId . '_releases';
}
}
@@ -2,11 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** One thing an adapter needs from Divi (a call, a mirrored function or a constant) and the features it serves. */ /**
* One thing an adapter needs from a third-party plugin or theme (a call, a mirrored function or a constant) and the
* features it serves.
*/
interface Requirement interface Requirement
{ {
/** Name for reports, e.g. "GlobalPreset::get_data()" (without the manifest area). */ /** Name for reports, e.g. "GlobalPreset::get_data()" (without the manifest area). */
@@ -16,7 +19,7 @@ interface Requirement
* The features this requirement serves itself; [] = the default of the manifest that lists it * The features this requirement serves itself; [] = the default of the manifest that lists it
* (Manifest::featuresOf() resolves that). * (Manifest::featuresOf() resolves that).
* *
* @return list<DiviFeature> * @return list<Capability>
*/ */
public function features(): array; public function features(): array;
} }
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
use ReflectionException; use ReflectionException;
use ReflectionFunction; use ReflectionFunction;
@@ -14,14 +14,15 @@ use ReflectionType;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Fingerprint of the signature of one Divi function or static method (spec §4.2, E1 of the plan "Divi-Fähigkeiten"), * Fingerprint of the signature of one function or static method of a third-party plugin or theme (spec §4.2, E1 of
* read with Reflection from the running PHP process – so it needs the symbol to be loaded (in WordPress Divi is) and * the plan "Divi-Fähigkeiten"), read with Reflection from the running PHP process – so it needs the symbol to be
* never includes a file. The class of a method is autoloaded the way ManifestCheck::accepts() already does it. * loaded (in WordPress the plugin or theme is, once active) 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 * 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 * 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: * (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), * - Parameter names: the plugin passes every argument positionally (PHP 8 named arguments are never used),
* so a rename cannot break a call, and a harmless rename must not lock a feature. * 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 * - 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 * requests and that change spelling without changing meaning; only "has a default" matters for the number of
@@ -2,18 +2,18 @@
declare(strict_types=1); declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Compatibility;
use RuntimeException; use RuntimeException;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Fingerprint of one Divi function or method (spec §4.2), read with token_get_all(): the file is never included, * Fingerprint of one function or method of a third-party plugin or theme (spec §4.2), read with token_get_all(): the
* so functions Divi loads only in some requests (theme-builder/api.php, admin-ajax only) are readable in every * file is never included, so functions loaded only in some requests (e.g. Divi's theme-builder/api.php, admin-ajax
* request. The hash covers the tokens from "function" to the closing brace without comments and whitespace – * only) are readable in every request. The hash covers the tokens from "function" to the closing brace without
* new comments or reformatting keep it, any code change changes it. $source is the verbatim text of those * comments and whitespace – new comments or reformatting keep it, any code change changes it. $source is the verbatim
* tokens for the human diff (wp lit-mcp divi-check --diff). * text of those tokens for the human diff (wp lit-mcp divi-check --diff).
*/ */
final class SourceFingerprint final class SourceFingerprint
{ {
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use LIT\MCP\Log;
use Throwable;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* The life cycle of a check probe's throwaway entry: a marked draft is created, exercised and always deleted for
* good (finally, not to the trash, with its revisions and meta). Entries a dead check left behind carry the marker;
* removeOrphans() deletes them. Nothing variable (exception or WP_Error text, read values) enters a report – it
* reaches MCP clients through the lock reason – the details go to the PHP error log.
*
* Creating and deleting fire the hooks of every other plugin on the site (save_post, delete_post …); that cannot be
* avoided with a real wp_insert_post()/wp_delete_post().
*/
final class ThrowawayPost
{
/** Meta key and title prefix the Pods and WP Fastest Cache probes give their entries (the Divi write probe has its own). */
public const MARKER = '_lit_mcp_check_entry';
public const TITLE = '[LIT MCP Prüfung]';
/** Only entries not modified for this long are orphans – a running check's entry is younger. */
private const ORPHAN_AGE_SECONDS = 600;
/**
* @param string $marker Meta key of the entries (only a query hint, the title prefix decides).
* @param string $titlePrefix Title of the entries starts with it.
* @param string $abortedText Report text when the exercise (or the creation) threw.
* @param string $createFailedText Report text when the entry could not be created.
* @param string $deleteFailedFormat sprintf format with %d for the ID of an entry that could not be deleted.
* @param string $logContext Context of the log lines.
*/
public function __construct(
private string $marker,
private string $titlePrefix,
private string $abortedText,
private string $createFailedText,
private string $deleteFailedFormat,
private string $logContext
) {
}
/**
* @param callable(int): list<string> $exercise Gets the ID of the draft, returns the problems found.
* @return list<string> Fixed German problem texts; [] = green.
*/
public function run(string $postType, callable $exercise): array
{
$id = 0;
$problems = [];
try {
$id = $this->create($postType);
$problems = $id > 0 ? $exercise($id) : [$this->createFailedText];
} catch (Throwable $e) {
Log::failure($this->logContext, $e);
$problems = [$this->abortedText];
} finally {
if ($id > 0 && ! self::delete($id)) {
$problems[] = sprintf($this->deleteFailedFormat, $id);
}
}
return $problems;
}
/**
* @param array<int, string>|string $postTypes
* @return list<int> IDs of entries a dead check left behind, deleted now. Only drafts and private entries (a
* probe may pass through private) whose title starts with the prefix are considered.
*/
public function removeOrphans(array|string $postTypes): array
{
$posts = get_posts([
'post_type' => $postTypes,
'post_status' => ['draft', 'private'],
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked entries at most.
'meta_key' => $this->marker,
// post_modified_gmt, not post_date_gmt: an edit by the check keeps it current. A literal GMT timestamp,
// because a relative string would be read in the site's time zone against a GMT column.
'date_query' => [[
'before' => gmdate('Y-m-d H:i:s', time() - self::ORPHAN_AGE_SECONDS),
'column' => 'post_modified_gmt',
]],
'posts_per_page' => 20,
'no_found_rows' => true,
'suppress_filters' => true,
]);
$removed = [];
foreach (is_array($posts) ? $posts : [] as $post) {
if (! $post instanceof WP_Post || ! str_starts_with((string) $post->post_title, $this->titlePrefix)) {
continue;
}
if (self::delete((int) $post->ID)) {
$removed[] = (int) $post->ID;
}
}
return $removed;
}
/** @return int The new ID; 0 when WordPress refused (logged). */
private function create(string $postType): int
{
$id = wp_insert_post([
'post_type' => $postType,
'post_status' => 'draft',
// Without it a draft keeps post_date_gmt and post_modified_gmt at 0000-00-00 and looks ancient.
'post_date_gmt' => gmdate('Y-m-d H:i:s'),
'post_title' => $this->titlePrefix . ' ' . gmdate('Y-m-d H:i'),
'post_content' => '',
'meta_input' => [$this->marker => '1'],
], true);
if ($id instanceof WP_Error) {
Log::failure($this->logContext, 'could not create the entry: ' . $id->get_error_message());
return 0;
}
if ((int) $id <= 0) {
Log::failure($this->logContext, 'could not create the entry: no ID');
}
return max(0, (int) $id);
}
private static function delete(int $id): bool
{
return wp_delete_post($id, true) instanceof WP_Post;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/** How strictly the compatibility layer treats a third-party plugin. */
enum Tier: string
{
/** Problems are only reported; nothing is ever locked. */
case ReportOnly = 'report';
/** A hard problem (or an inactive or too old plugin) locks the affected capabilities; otherwise they are free. */
case LockOnFailure = 'lock';
/** Every plugin version needs a release; the profile brings its own matrix (Divi). */
case ReleasePerVersion = 'release';
}
+6 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Core; namespace LIT\MCP\Integrations\Core;
use Closure;
use LIT\MCP\Access; use LIT\MCP\Access;
use LIT\MCP\Guides\GuideRepository; use LIT\MCP\Guides\GuideRepository;
use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\Integration;
@@ -12,8 +13,11 @@ defined('ABSPATH') || exit;
final class CoreIntegration implements Integration final class CoreIntegration implements Integration
{ {
/** @param list<EnvironmentComponent> $components What lit-site-status reports besides Pods and Events Calendar. */ /**
public function __construct(private string $guidesDirectory, private array $components = []) * @param (Closure(): list<EnvironmentComponent>)|null $components What lit-site-status reports besides the Events
* Calendar; asked when the status is built.
*/
public function __construct(private string $guidesDirectory, private ?Closure $components = null)
{ {
} }
+10 -12
View File
@@ -4,16 +4,21 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Core; namespace LIT\MCP\Integrations\Core;
use Closure;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Detects the themes/plugins LIT MCP integrates with: the components it is given (Divi, wired in Plugin) first, then * Detects the themes/plugins LIT MCP integrates with: the components it is given (Divi, Pods; wired in Plugin) first,
* Pods and The Events Calendar. * then The Events Calendar.
*/ */
class EnvironmentProbe class EnvironmentProbe
{ {
/** @param list<EnvironmentComponent> $components Components of integrations, reported in this order. */ /**
public function __construct(private array $components = []) * @param (Closure(): list<EnvironmentComponent>)|null $components Components of integrations, reported in this
* order; asked per call (the registry that knows them exists later).
*/
public function __construct(private ?Closure $components = null)
{ {
} }
@@ -26,18 +31,11 @@ class EnvironmentProbe
$tecActive = class_exists('Tribe__Events__Main'); $tecActive = class_exists('Tribe__Events__Main');
$described = array_map( $described = array_map(
static fn (EnvironmentComponent $component): array => $component->describe(), static fn (EnvironmentComponent $component): array => $component->describe(),
$this->components $this->components === null ? [] : ($this->components)()
); );
return [ return [
...$described, ...$described,
[
'id' => 'pods',
'active' => defined('PODS_VERSION'),
'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
'problems' => [],
'release' => null,
],
[ [
'id' => 'the-events-calendar', 'id' => 'the-events-calendar',
'active' => $tecActive, 'active' => $tecActive,
@@ -0,0 +1,323 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Admin;
use Closure;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Compatibility\Capability;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\FeatureStatus;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
defined('ABSPATH') || exit;
/**
* What Divi adds to the tab "Compatibility" (Admin\CompatibilityTab, which owns check, release and withdrawal):
* the admin notice on every admin page while a feature is unusable, the removal of check pages a dead check left
* behind (in the tab's load action, not while rendering), the columns source / problems / affected tools of the
* capability table (E3), the source-specific explanation of the overall state, above the capability table (intro)
* the orphan report, the next step and what the check does and, closing the section, the automation note and – with a
* report – the Visual Builder hint.
*/
final class DiviAdminExtras
{
/** @var list<int> Check pages removeOrphans() deleted in this request; renderIntro() reports them. */
private array $removed = [];
/**
* @param FeatureOverview|null $overview null = a table without the affected tools.
* @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment.
* @param CheckReportStore|null $reports null = the Divi store.
*/
public function __construct(
private ?FeatureOverview $overview = null,
private ?Closure $version = null,
private ?CheckReportStore $reports = null
) {
}
public function register(): void
{
add_action('admin_notices', [$this, 'renderNotice']);
add_action(CompatibilityTab::LOAD_ACTION, [$this, 'removeOrphans']);
add_action(CompatibilityTab::INTRO_ACTION, [$this, 'renderIntro']);
add_action(CompatibilityTab::EXTRA_ACTION, [$this, 'renderExtras']);
add_filter(CompatibilityTab::EXPLANATION_FILTER, [$this, 'explanation'], 10, 3);
add_filter(CompatibilityTab::COLUMNS_FILTER, [$this, 'columns'], 10, 2);
}
/**
* Only for administrators, only with Divi active, only while a feature is not usable – and not on the tab
* "Compatibility", which shows the same table. Reads the cached matrix (D7), so no check runs on an admin page.
*/
public function renderNotice(): void
{
if (! current_user_can('manage_options') || $this->version() === '' || $this->onCompatibilityTab()) {
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 */
__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'LIT MCP: Mit Divi %1$s sind diese Fähigkeiten nicht nutzbar: %2$s. Die betroffenen Tools lehnen ab, alle anderen laufen weiter.',
'wp-lit-mcp'
),
$this->version(),
implode(', ', array_map(
static fn (FeatureStatus $status): string => $status->feature->label(),
$unusable
))
)),
esc_url(SettingsPage::url(CompatibilityTab::SLUG)),
esc_html__('Details im Reiter „Kompatibilität“', 'wp-lit-mcp')
);
}
private function onCompatibilityTab(): bool
{
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
$tab = is_string($_GET['tab'] ?? null) ? sanitize_key(wp_unslash($_GET['tab'])) : '';
return is_object($screen) && $screen->id === SettingsPage::SCREEN_HOOK && $tab === CompatibilityTab::SLUG;
}
/**
* lit_mcp_compat_profile_load('divi'), before any output (the tab fires it for administrators on its own tab):
* 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. renderExtras() only reports what was deleted (renderIntro()).
*/
public function removeOrphans(string $profileId): void
{
if ($profileId !== DiviProfile::ID || ! current_user_can('manage_options') || $this->version() === '') {
return;
}
$this->removed = WriteProbe::removeOrphans();
}
/**
* Columns of Divi's capability table: where the release comes from, the problems of the capability and the
* tools it affects. Reads the overview once per table (the matrix is the same for every row).
*
* @param list<array<string, mixed>> $columns
* @return list<array<string, mixed>>
*/
public function columns(array $columns, string $profileId): array
{
if ($profileId !== DiviProfile::ID) {
return $columns;
}
$rows = null;
$row = function (Capability $capability, CompatibilityMatrix $matrix) use (&$rows): array {
$rows ??= array_column(($this->overview ?? FeatureOverview::withoutTools())->rows($matrix), null, 'id');
return $rows[$capability->id()] ?? [];
};
return [
...$columns,
[
'heading' => __('Quelle', 'wp-lit-mcp'),
'cell' => fn (Capability $c, CompatibilityMatrix $m): array => array_values(array_filter(
[$this->sourceLabel($row($c, $m)['source'] ?? null)]
)),
],
[
'heading' => __('Probleme', 'wp-lit-mcp'),
'cell' => static fn (Capability $c, CompatibilityMatrix $m): array => $row($c, $m)['problems'] ?? [],
],
[
'heading' => __('Betroffene Tools', 'wp-lit-mcp'),
'cell' => static fn (Capability $c, CompatibilityMatrix $m): array => $row($c, $m)['tools'] ?? [],
'code' => true,
],
];
}
private function sourceLabel(?string $source): string
{
return match ($source) {
ReleaseStatus::SOURCE_CODE => __('Im Plugin als getestet hinterlegt', 'wp-lit-mcp'),
ReleaseStatus::SOURCE_DATABASE => __('Von dir auf dieser Website freigegeben', 'wp-lit-mcp'),
ReleaseStatus::SOURCE_AUTO => __('Automatisch geprüft', 'wp-lit-mcp'),
default => '',
};
}
/**
* lit_mcp_compat_profile_intro('divi'), above the capability table: the deleted check pages, the next step and
* what the check does (it writes to the site, so it says so).
*/
public function renderIntro(string $profileId): void
{
if ($profileId !== DiviProfile::ID) {
return;
}
if ($this->removed !== []) {
printf(
'<div class="notice notice-warning inline"><p>%s</p></div>',
esc_html(sprintf(
/* translators: %s: comma separated page IDs */
__('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'),
implode(', ', $this->removed)
))
);
}
$this->renderNextStep(ReleaseGate::status());
echo '<p class="description">' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Die Prüfung legt eine private Testseite an, prüft Lesen und Schreiben und löscht sie wieder.',
'wp-lit-mcp'
) . '</p>';
}
/**
* Filter lit_mcp_compat_explanation: Divi says where its release comes from (plugin, automatic, this website)
* instead of the generic sentence, so the section does not explain the state twice.
*/
public function explanation(string $text, string $profileId, ReleaseState $state): string
{
if ($profileId !== DiviProfile::ID) {
return $text;
}
$status = ReleaseGate::status();
return match (true) {
$status->state === ReleaseState::Incompatible => __(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'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'
),
$status->state === ReleaseState::Untested => __(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Diese Version ist nicht geprüft. Die Tools der ungetesteten Fähigkeiten (Tabelle unten) lehnen ab, bis die Version freigegeben ist; alle anderen laufen weiter.',
'wp-lit-mcp'
),
$status->source === ReleaseStatus::SOURCE_CODE => __(
'Diese Version ist im Plugin als getestet hinterlegt.',
'wp-lit-mcp'
),
$status->source === ReleaseStatus::SOURCE_AUTO => __(
// 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'
),
default => $this->releasedOnThisSite($status),
};
}
private function releasedOnThisSite(ReleaseStatus $status): string
{
$user = get_userdata((int) ($status->release['user_id'] ?? 0));
$releasedAt = (int) strtotime((string) ($status->release['released_at'] ?? ''));
return sprintf(
/* translators: 1: date, 2: user name */
__('Auf dieser Website freigegeben am %1$s von %2$s.', 'wp-lit-mcp'),
date_i18n((string) get_option('date_format'), $releasedAt),
is_object($user) && isset($user->display_name) ? (string) $user->display_name : '?'
);
}
/** lit_mcp_compat_profile_extra('divi'): the end of the Divi section (shown only while Divi is active). */
public function renderExtras(string $profileId): void
{
if ($profileId !== DiviProfile::ID) {
return;
}
$this->renderAutomationNote();
$reports = $this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY);
if ($reports->latest() !== null) {
echo '<p class="description">' . esc_html__(
'Optional: eine Divi-Seite im Visual Builder öffnen und speichern.',
'wp-lit-mcp'
) . '</p>';
}
}
/** One sentence on what to do now; a released version needs none. */
private function renderNextStep(ReleaseStatus $status): void
{
$text = match ($status->state) {
ReleaseState::Untested => sprintf(
/* translators: %s: Divi version */
__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text.
'Divi %s ist noch nicht geprüft. Starte die Prüfung; danach kannst du die Version freigeben.',
'wp-lit-mcp'
),
$status->version
),
ReleaseState::Incompatible => sprintf(
/* translators: %s: Divi version */
__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text.
'Divi %s hat Stellen geändert, die LIT MCP nutzt. Die betroffenen Tools bleiben gesperrt, bis ein Plugin-Update erscheint.',
'wp-lit-mcp'
),
$status->version
),
default => null,
};
if ($text === null) {
return;
}
printf(
'<div class="notice notice-%s inline"><p><strong>%s</strong></p></div>',
$status->state === ReleaseState::Incompatible ? 'error' : 'warning',
esc_html($text)
);
}
/** E1: what the automatic release does and how to switch it off. */
private function renderAutomationNote(): void
{
echo '<details class="lit-mcp-hint"><summary>'
. esc_html__('So funktioniert die automatische Freigabe', 'wp-lit-mcp') . '</summary>';
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 Aufrufe (Signaturen) und die Fingerabdrücke nachgebauter Divi-Abläufe unverändert sind und alle Konstanten definiert sind. Das prüft Aufrufformen, nicht das Verhalten von Divi. 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 als ungetestet.',
'wp-lit-mcp'
),
'<code>lit_mcp_divi_auto_release</code>'
) . '</p></details>';
}
private function version(): string
{
return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')();
}
}
-576
View File
@@ -1,576 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Admin;
use Closure;
use LIT\MCP\Admin\AdminPostAction;
use LIT\MCP\Admin\AdminTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
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;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin;
defined('ABSPATH') || exit;
/**
* 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" (whenever a database release applies,
* whatever the overall state). Loading the tab removes check pages a dead check left behind – in the page's load
* hook (removeOrphans()), not while rendering. renderNotice() is the admin notice on every admin page while a
* feature is unusable.
*/
final class DiviTab implements AdminTab
{
public const SLUG = 'divi';
public const RELEASE_ACTION = 'lit_mcp_divi_release';
public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw';
/** @var list<int> Check pages removeOrphans() deleted in this request; render() reports them. */
private array $removed = [];
/**
* @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(
private ?CheckReportStore $reports = null,
private ?ReleaseStore $releases = null,
private ?ChangeLog $log = null,
private ?AdminPostAction $action = null,
private ?Closure $version = null,
private ?Closure $stateKey = null,
private ?FeatureOverview $overview = null
) {
}
public function slug(): string
{
return self::SLUG;
}
public function label(): string
{
return __('Divi', 'wp-lit-mcp');
}
public function registerSettings(): void
{
}
public function registerActions(): void
{
add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']);
add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'removeOrphans']);
$this->registerNotice();
}
/** The admin notice for administrators while a Divi feature is unusable (registerActions() runs in admin only). */
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 */
__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'LIT MCP: Mit Divi %1$s sind diese Fähigkeiten nicht nutzbar: %2$s. Die betroffenen Tools lehnen ab, alle anderen laufen weiter.',
'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;
return is_object($screen) && $screen->id === SettingsPage::SCREEN_HOOK && $this->requested();
}
/**
* 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();
}
public function release(): void
{
$action = $this->action ?? new AdminPostAction();
$action->guard();
check_admin_referer(self::RELEASE_ACTION);
$version = $this->version();
$report = $this->reports()->latest();
$fresh = $report !== null
&& $report->freshFor($version, Plugin::VERSION, $this->stateKey($version));
if ($version === '' || ! $fresh) {
$action->redirect(self::SLUG, 'divi_release_refused');
return;
}
($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id);
ReleaseGate::invalidate();
$this->log?->record(
'lit/divi-release',
'divi',
$version,
sprintf('Divi %s für LIT MCP %s freigegeben (Prüfbericht %s).', $version, Plugin::VERSION, $report->id)
);
$action->redirect(self::SLUG, 'divi_released');
}
public function withdraw(): void
{
$action = $this->action ?? new AdminPostAction();
$action->guard();
check_admin_referer(self::WITHDRAW_ACTION);
$version = $this->version();
if (! ($this->releases ?? new ReleaseStore())->withdraw($version, Plugin::VERSION)) {
$action->redirect(self::SLUG, 'divi_withdraw_failed');
return;
}
ReleaseGate::invalidate();
$this->log?->record(
'lit/divi-release',
'divi',
$version,
sprintf('Freigabe von Divi %s zurückgezogen.', $version)
);
$action->redirect(self::SLUG, 'divi_withdrawn');
}
public function render(): void
{
if ($this->version() === '') {
echo '<p>' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text.
'Divi ist auf dieser Website nicht aktiv; die Divi-Freigabe ist ohne Bedeutung.',
'wp-lit-mcp'
) . '</p>';
return;
}
$status = ReleaseGate::status();
$report = $this->reports()->latest();
echo '<h2>' . esc_html__('Divi-Freigabe', 'wp-lit-mcp') . '</h2>';
echo '<p>' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'LIT MCP prüft nach einem Divi-Update jede Fähigkeit einzeln (z. B. „Seiten schreiben“). Ist sie nicht nutzbar, lehnen nur die Tools ab, die sie brauchen; alle anderen laufen weiter.',
'wp-lit-mcp'
) . '</p>';
if ($this->removed !== []) {
printf(
'<div class="notice notice-warning inline"><p>%s</p></div>',
esc_html(sprintf(
/* translators: %s: comma separated page IDs */
__('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'),
implode(', ', $this->removed)
))
);
}
$this->renderNextStep($status);
$this->renderStatus($status);
$this->renderFeatures($status);
$this->renderForms($status, $report);
if ($report !== null) {
$this->renderReport($report);
}
}
/** One sentence on what to do now, above the details; a released version needs none. */
private function renderNextStep(ReleaseStatus $status): void
{
if ($status->version === '') {
return;
}
$text = match ($status->state) {
ReleaseState::Untested => sprintf(
/* translators: %s: Divi version */
__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text.
'Divi %s ist noch nicht geprüft. Starte unten die Prüfung; danach kannst du die Version freigeben.',
'wp-lit-mcp'
),
$status->version
),
ReleaseState::Incompatible => sprintf(
/* translators: %s: Divi version */
__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text.
'Divi %s hat Stellen geändert, die LIT MCP nutzt. Die betroffenen Tools bleiben gesperrt, bis ein Plugin-Update erscheint.',
'wp-lit-mcp'
),
$status->version
),
default => null,
};
if ($text === null) {
return;
}
printf(
'<div class="notice notice-%s inline"><p><strong>%s</strong></p></div>',
$status->state === ReleaseState::Incompatible ? 'error' : 'warning',
esc_html($text)
);
}
private function renderStatus(ReleaseStatus $status): void
{
echo '<table class="form-table" role="presentation"><tbody>';
printf(
'<tr><th scope="row">%s</th><td>%s</td></tr>',
esc_html__('Divi-Version', 'wp-lit-mcp'),
esc_html($status->version === '' ? __('nicht erkannt', 'wp-lit-mcp') : $status->version)
);
printf(
'<tr><th scope="row">%s</th><td>%s – %s</td></tr>',
esc_html__('Gesamtzustand', 'wp-lit-mcp'),
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in stateBadge().
$this->stateBadge($status->state),
esc_html($this->explanation($status))
);
if ($status->problems !== []) {
echo '<tr><th scope="row">' . esc_html__('Geändert in Divi', 'wp-lit-mcp') . '</th><td><ul>';
foreach ($status->problems as $problem) {
echo '<li>' . esc_html($problem) . '</li>';
}
echo '</ul></td></tr>';
}
echo '</tbody></table>';
}
private function renderFeatures(ReleaseStatus $status): void
{
// Open when something needs attention; a released version keeps the long table folded.
printf(
'<details class="lit-mcp-divi-details"%s><summary>%s</summary>',
$status->state === ReleaseState::Released ? '' : ' open',
esc_html__('Fähigkeiten im Detail', 'wp-lit-mcp')
);
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 Tools', '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) {
// data-label: on phones each row becomes a card and the cell names itself.
printf('<tr><td data-label="%s">%s</td>', esc_attr($headings[0]), esc_html($row['label']));
printf(
'<td data-label="%s">%s</td>',
esc_attr($headings[1]),
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in stateBadge().
$this->stateBadge(ReleaseState::from($row['state']))
);
printf(
'<td data-label="%s">%s</td>',
esc_attr($headings[2]),
esc_html($this->sourceLabel($row['source']))
);
printf('<td data-label="%s">', esc_attr($headings[3]));
$this->renderList($row['problems'], false);
printf('</td><td data-label="%s">', esc_attr($headings[4]));
$this->renderList($row['tools'], true);
echo '</td></tr>';
}
echo '</tbody></table>';
$this->renderAutomationNote();
echo '</details>';
}
/** @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) {
ReleaseStatus::SOURCE_CODE => __('Im Plugin als getestet hinterlegt', 'wp-lit-mcp'),
ReleaseStatus::SOURCE_DATABASE => __('Von dir auf dieser Website freigegeben', 'wp-lit-mcp'),
ReleaseStatus::SOURCE_AUTO => __('Automatisch geprüft', '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 Aufrufe (Signaturen) und die Fingerabdrücke nachgebauter Divi-Abläufe unverändert sind und alle Konstanten definiert sind. Das prüft Aufrufformen, nicht das Verhalten von Divi. 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>';
}
/** Same coloured icons as the status list on the tab "Verbindung"; the word stays, colour is never alone. */
private function stateBadge(ReleaseState $state): string
{
$icon = match ($state) {
ReleaseState::Released => 'yes-alt',
ReleaseState::Untested => 'warning',
ReleaseState::Incompatible => 'dismiss',
};
return sprintf(
'<span class="lit-mcp-state lit-mcp-status-%1$s"><span class="dashicons dashicons-%1$s"'
. ' aria-hidden="true"></span> <strong>%2$s</strong></span>',
$icon,
esc_html($this->stateLabel($state))
);
}
private function stateLabel(ReleaseState $state): string
{
return match ($state) {
ReleaseState::Released => __('freigegeben', 'wp-lit-mcp'),
ReleaseState::Untested => __('ungetestet', 'wp-lit-mcp'),
ReleaseState::Incompatible => __('inkompatibel', 'wp-lit-mcp'),
};
}
private function explanation(ReleaseStatus $status): string
{
if ($status->state === ReleaseState::Incompatible) {
return __(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'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'
);
}
if ($status->state === ReleaseState::Untested) {
return __(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Diese Version ist nicht geprüft. Die Tools der ungetesteten Fähigkeiten (Tabelle unten) lehnen ab, bis die Version freigegeben ist; alle anderen laufen weiter.',
'wp-lit-mcp'
);
}
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));
$releasedAt = (int) strtotime((string) ($status->release['released_at'] ?? ''));
return sprintf(
/* translators: 1: date, 2: user name */
__('Auf dieser Website freigegeben am %1$s von %2$s.', 'wp-lit-mcp'),
date_i18n((string) get_option('date_format'), $releasedAt),
is_object($user) && isset($user->display_name) ? (string) $user->display_name : '?'
);
}
private function renderForms(ReleaseStatus $status, ?CheckReport $report): void
{
$fresh = $report !== null
&& $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version));
$untested = $status->state === ReleaseState::Untested && $status->version !== '';
printf('<form method="post" action="%s">', esc_url(DiviCheckRoute::url()));
printf('<input type="hidden" name="_wpnonce" value="%s">', esc_attr(wp_create_nonce('wp_rest')));
// The check is the one thing to do while untested without a usable report; otherwise the release leads.
submit_button(
__('Prüfung starten', 'wp-lit-mcp'),
$untested && ! $fresh ? 'primary' : 'secondary',
'lit-mcp-divi-check',
false
);
echo '<p class="description">' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Die Prüfung legt eine private Testseite an, prüft Lesen und Schreiben und löscht sie wieder. Sie dauert einige Sekunden.',
'wp-lit-mcp'
) . '</p>';
echo '</form>';
if ($untested) {
if ($fresh) {
$this->actionForm(
self::RELEASE_ACTION,
/* translators: %s: Divi version */
sprintf(__('Divi %s freigeben', 'wp-lit-mcp'), $status->version),
'primary'
);
} else {
echo '<p>' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Freigeben geht erst nach einer vollständig grünen Prüfung dieser Divi-Version – Prüfung erneut ausführen.',
'wp-lit-mcp'
) . '</p>';
}
}
// A database release applies whatever the overall state: one incompatible feature must not hide the way back.
if ($status->source === ReleaseStatus::SOURCE_DATABASE || $status->release !== null) {
$this->actionForm(self::WITHDRAW_ACTION, __('Freigabe zurückziehen', 'wp-lit-mcp'), 'secondary');
}
}
private function actionForm(string $action, string $label, string $type): void
{
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
printf('<input type="hidden" name="action" value="%s">', esc_attr($action));
wp_nonce_field($action);
submit_button($label, $type, 'submit', false);
echo '</form>';
}
private function renderReport(CheckReport $report): void
{
printf(
'<h3>%s</h3><p>%s</p><ul class="lit-mcp-divi-report">',
esc_html__('Letzte Prüfung', 'wp-lit-mcp'),
esc_html(sprintf(
/* translators: 1: Divi version, 2: plugin version, 3: date */
__('Divi %1$s mit LIT MCP %2$s, %3$s', 'wp-lit-mcp'),
$report->diviVersion,
$report->pluginVersion,
date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt))
))
);
foreach ($report->steps as $step) {
[$icon, $word] = match (true) {
$step->skipped => ['minus', __('übersprungen', 'wp-lit-mcp')],
$step->green() => ['yes-alt', __('bestanden', 'wp-lit-mcp')],
default => ['dismiss', __('fehlgeschlagen', 'wp-lit-mcp')],
};
printf(
'<li class="lit-mcp-state lit-mcp-status-%1$s"><span class="dashicons dashicons-%1$s"'
. ' aria-hidden="true"></span><span class="screen-reader-text">%2$s:</span> %3$s',
esc_attr($icon),
esc_html($word),
esc_html($step->label())
);
if ($step->skipped) {
echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')';
}
if ($step->problems !== []) {
echo '<ul>';
foreach ($step->problems as $problem) {
echo '<li>' . esc_html($problem) . '</li>';
}
echo '</ul>';
}
echo '</li>';
}
echo '</ul><p class="description">' . esc_html__(
'Optional: eine Divi-Seite im Visual Builder öffnen und speichern.',
'wp-lit-mcp'
) . '</p>';
}
/** 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;
}
private function version(): string
{
return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')();
}
private function stateKey(string $version): string
{
return ($this->stateKey ?? static fn (string $v): string => ReleaseResolver::stateKey($v))($version);
}
private function reports(): CheckReportStore
{
return $this->reports ?? new CheckReportStore();
}
}
+14 -12
View File
@@ -5,17 +5,19 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Cli; namespace LIT\MCP\Integrations\Divi\Cli;
use Closure; use Closure;
use LIT\MCP\Integrations\Divi\Release\CheckReport; use LIT\MCP\Compatibility\LineDiff;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore; use LIT\MCP\Compatibility\SourceFingerprint;
use LIT\MCP\Integrations\Divi\Release\CheckStep; use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix; use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters; use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\DiviReference; use LIT\MCP\Integrations\Divi\Release\DiviReference;
use LIT\MCP\Integrations\Divi\Release\LineDiff;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\SourceFingerprint;
use RuntimeException; use RuntimeException;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -70,8 +72,8 @@ final class DiviCheckCommand
} }
/** /**
* Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format * Prüft die installierte Divi-Version wie der Reiter „Kompatibilität“: Manifeste, Fingerabdrücke, Modulkatalog,
* und Schreibtest auf einer Wegwerfseite. Gibt danach den Zustand je Fähigkeit aus und gibt nichts frei. * 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 * 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). * eine Fähigkeit nicht nutzbar (z. B. Version noch nicht in DiviReleases::TESTED).
* *
@@ -109,10 +111,10 @@ final class DiviCheckCommand
} }
$report = ($this->check)()->run(get_current_user_id()); $report = ($this->check)()->run(get_current_user_id());
($this->reports ?? new CheckReportStore())->save($report); ($this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY))->save($report);
($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->diviVersion ?: '?', $report->pluginVersion)); ($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->version ?: '?', $report->pluginVersion));
foreach ($report->steps as $step) { foreach ($report->steps as $step) {
($this->out)(($step->skipped ? '–' : ($step->green() ? '✓' : '✗')) . ' ' . $step->label()); ($this->out)(($step->skipped ? '–' : ($step->green() ? '✓' : '✗')) . ' ' . DiviCheckSteps::label($step));
foreach ($step->problems as $problem) { foreach ($step->problems as $problem) {
($this->out)(' ' . $problem); ($this->out)(' ' . $problem);
} }
+1 -1
View File
@@ -12,7 +12,7 @@ defined('ABSPATH') || exit;
* One confirmed change per lock at a time (final review I-5). Colors and theme options share ONE lock (audit L5): * One confirmed change per lock at a time (final review I-5). Colors and theme options share ONE lock (audit L5):
* both rewrite the whole et_divi array through et_update_option(). The lock is a row lit_mcp_lock_<name> (never * both rewrite the whole et_divi array through et_update_option(). The lock is a row lit_mcp_lock_<name> (never
* autoloaded) in the options table holding "<time>:<token>" – taken with an atomic INSERT IGNORE like * autoloaded) in the options table holding "<time>:<token>" – taken with an atomic INSERT IGNORE like
* DiviCheckRoute (add_option() reads first and writes after, so two requests could both win; audit L6). A lock * Admin\CheckRoute (add_option() reads first and writes after, so two requests could both win; audit L6). A lock
* older than STALE_AFTER seconds (a request that died) is taken over with a conditional UPDATE on the value just * older than STALE_AFTER seconds (a request that died) is taken over with a conditional UPDATE on the value just
* read, so only one taker wins; release() deletes the row only while it still holds this instance's token, so a * read, so only one taker wins; release() deletes the row only while it still holds this instance's token, so a
* holder that lost a stale lock cannot free the new holder's. A taken lock drops the options cache, so the * holder that lost a stale lock cannot free the new holder's. A taken lock drops the options cache, so the
@@ -6,10 +6,11 @@ namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Packages\GlobalData\GlobalData; use ET\Builder\Packages\GlobalData\GlobalData;
use ET\Builder\Packages\GlobalData\GlobalPreset; use ET\Builder\Packages\GlobalData\GlobalPreset;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -29,7 +30,7 @@ class DesignSystemReader implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::DESIGN, DiviAreas::DESIGN,
calls: [ calls: [
new Call(GlobalData::class . '::get_global_colors', 0), new Call(GlobalData::class . '::get_global_colors', 0),
new Call(GlobalData::class . '::get_global_variables', 0), new Call(GlobalData::class . '::get_global_variables', 0),
@@ -8,12 +8,13 @@ use ET\Builder\Framework\UserRole\UserRole;
use ET\Builder\Packages\Conversion\Conversion; use ET\Builder\Packages\Conversion\Conversion;
use ET\Builder\Packages\GlobalData\GlobalPreset; use ET\Builder\Packages\GlobalData\GlobalPreset;
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration; use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror; use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException; use RuntimeException;
use WP_Error; use WP_Error;
@@ -43,7 +44,7 @@ class DesignSystemWriter implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::DESIGN, DiviAreas::DESIGN,
calls: [ calls: [
// Read by the preset tools as well as by the design system tool. // 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 . '::get_data', 0, [DiviFeature::DesignRead, DiviFeature::PresetWrite]),
+4 -3
View File
@@ -4,10 +4,11 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design; namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -26,7 +27,7 @@ class FontCatalog implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::DESIGN, DiviAreas::DESIGN,
calls: [ calls: [
new Call('et_builder_get_fonts', 0), new Call('et_builder_get_fonts', 0),
new Call('et_builder_get_custom_fonts', 0), new Call('et_builder_get_custom_fonts', 0),
@@ -6,12 +6,13 @@ namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\VisualBuilder\Fonts\FontsUtility; use ET\Builder\VisualBuilder\Fonts\FontsUtility;
use ET\Builder\VisualBuilder\Hooks\HooksRegistration; use ET\Builder\VisualBuilder\Hooks\HooksRegistration;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror; use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException; use RuntimeException;
@@ -35,7 +36,7 @@ class FontInstaller implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::DESIGN, DiviAreas::DESIGN,
calls: [ calls: [
new Call(FontsUtility::class . '::mime_types_font', 0), new Call(FontsUtility::class . '::mime_types_font', 0),
new Call(HooksRegistration::class . '::check_filetype_and_ext_font', 3), new Call(HooksRegistration::class . '::check_filetype_and_ext_font', 3),
@@ -5,11 +5,12 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design; namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Packages\GlobalData\GlobalData; use ET\Builder\Packages\GlobalData\GlobalData;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException; use RuntimeException;
@@ -39,7 +40,7 @@ class GlobalColorsAdapter implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::DESIGN, DiviAreas::DESIGN,
calls: [ calls: [
new Call(GlobalData::class . '::get_global_colors', 0), new Call(GlobalData::class . '::get_global_colors', 0),
new Call(GlobalData::class . '::sanitize_global_colors_data', 1), new Call(GlobalData::class . '::sanitize_global_colors_data', 1),
@@ -5,11 +5,12 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design; namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Packages\GlobalData\GlobalData; use ET\Builder\Packages\GlobalData\GlobalData;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException; use RuntimeException;
use WP_Error; use WP_Error;
@@ -28,7 +29,7 @@ class GlobalVariablesAdapter implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::DESIGN, DiviAreas::DESIGN,
calls: [ calls: [
new Call(GlobalData::class . '::get_global_variables', 0), new Call(GlobalData::class . '::get_global_variables', 0),
new Call(GlobalData::class . '::set_global_variables', 2), new Call(GlobalData::class . '::set_global_variables', 2),
@@ -5,12 +5,13 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design; namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException; use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror; use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException; use RuntimeException;
@@ -61,7 +62,7 @@ class ThemeOptionsAdapter implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::DESIGN, DiviAreas::DESIGN,
calls: [ calls: [
new Call('et_sanitize_font_choices', 1), new Call('et_sanitize_font_choices', 1),
new Call('et_sanitize_global_font_weight', 1), new Call('et_sanitize_global_font_weight', 1),
+3 -2
View File
@@ -4,7 +4,8 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi; namespace LIT\MCP\Integrations\Divi;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck; use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\DiviManifestCheck;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -29,7 +30,7 @@ final class DiviCompatibility
/** @return array<string, callable(): bool> */ /** @return array<string, callable(): bool> */
public static function defaults(?ManifestCheck $check = null): array public static function defaults(?ManifestCheck $check = null): array
{ {
$check ??= new ManifestCheck(); $check ??= DiviManifestCheck::create();
return array_merge($check->codeChecks(), $check->registrationChecks(), $check->mirrorChecks()); return array_merge($check->codeChecks(), $check->registrationChecks(), $check->mirrorChecks());
} }
+35 -17
View File
@@ -7,10 +7,13 @@ namespace LIT\MCP\Integrations\Divi;
use Closure; use Closure;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolAvailability; use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Admin\SettingsPage; use LIT\MCP\Admin\CheckRoute;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Bootable; use LIT\MCP\Integrations\Bootable;
use LIT\MCP\Integrations\Divi\Admin\DiviTab; use LIT\MCP\Integrations\Divi\Admin\DiviAdminExtras;
use LIT\MCP\Integrations\Divi\Cli\DiviCheckCommand; use LIT\MCP\Integrations\Divi\Cli\DiviCheckCommand;
use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner; use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner;
@@ -72,9 +75,8 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview; use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\Schema\DiviModules;
@@ -99,7 +101,7 @@ defined('ABSPATH') || exit;
* DiviWriter; each binds the composition to its entry via DiviWriter::composition(). * DiviWriter; each binds the composition to its entry via DiviWriter::composition().
* contentComposer() gives lit/content-create the Divi short form (spec §6.2). boot() registers Divi's own hooks. * contentComposer() gives lit/content-create the Divi short form (spec §6.2). boot() registers Divi's own hooks.
*/ */
final class DiviIntegration implements Integration, Bootable final class DiviIntegration implements Integration, Bootable, ProvidesCompatibility
{ {
private DiviModules $modules; private DiviModules $modules;
private DesignSystemReader $design; private DesignSystemReader $design;
@@ -186,6 +188,11 @@ final class DiviIntegration implements Integration, Bootable
return DiviModules::isAvailable(); return DiviModules::isAvailable();
} }
public function compatibilities(): array
{
return [$this->profile()];
}
/** /**
* Divi's own post types (library, Theme Builder, canvases, speculation), which the content tools leave alone: * 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). * ContentSchema's excluded post types. Static because the schema is built before this integration (Plugin).
@@ -219,7 +226,7 @@ final class DiviIntegration implements Integration, Bootable
return [new DiviLogoUsage()]; return [new DiviLogoUsage()];
} }
/** Which tools each Divi feature affects – for lit-site-status and the Divi tab (E3). */ /** Which tools each Divi feature affects – for lit-site-status and the tab "Compatibility" (E3). */
public function featureOverview(): FeatureOverview public function featureOverview(): FeatureOverview
{ {
return new FeatureOverview($this->allAbilities ?? static fn (): array => []); return new FeatureOverview($this->allAbilities ?? static fn (): array => []);
@@ -242,8 +249,8 @@ final class DiviIntegration implements Integration, Bootable
/** /**
* Divi's own boot steps, registered whether or not Divi is loaded yet (Bootable): a theme update checks the * 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 * fingerprints again at once (spec §5.4), the WP-CLI command (spec §7) and the Divi additions to the admin tab
* the tab "Divi" after the plugin's own tabs. * "Compatibility" (spec §6; the check route itself is generic, Admin\CheckRoute).
*/ */
public function boot(): void public function boot(): void
{ {
@@ -252,16 +259,21 @@ final class DiviIntegration implements Integration, Bootable
ReleaseGate::invalidate(mirrors: true); ReleaseGate::invalidate(mirrors: true);
}); });
// Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no // The admin check runs as a generic REST route (Admin\CheckRoute, registered by Plugin); a stored check or a
// Tools-tab gate: the check tests Divi, not this site's tool switches. // release changes the state, so the gate forgets its status of this request.
$check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->shortForm(null)); add_action(CheckRoute::CHANGED_ACTION, static function (string $profileId): void {
(new DiviCheckRoute($check))->register(); if ($profileId === DiviProfile::ID) {
DiviCheckCommand::register($check); ReleaseGate::invalidate();
}
});
add_filter( // Spec §7: the same check as WP-CLI command. Its short form has no Tools-tab gate: the check tests Divi,
SettingsPage::TABS_FILTER, // not this site's tool switches.
fn (array $tabs): array => [...$tabs, new DiviTab(log: $this->log, overview: $this->featureOverview())] $profile = $this->profile();
); DiviCheckCommand::register(static fn (): CompatibilityCheck => CompatibilityCheck::forProfile($profile));
// No tab of its own: notice, orphan cleanup, columns and notes join the tab "Compatibility".
(new DiviAdminExtras($this->featureOverview()))->register();
} }
public function abilities(): array public function abilities(): array
@@ -422,6 +434,12 @@ final class DiviIntegration implements Integration, Bootable
} }
/** @param (Closure(): bool)|null $writable */ /** @param (Closure(): bool)|null $writable */
/** The Divi profile; the check's write test composes through the short form without a Tools-tab gate. */
private function profile(): Profile
{
return DiviProfile::profile(fn (): DiviContentComposer => $this->shortForm(null));
}
private function shortForm(?Closure $writable): DiviContentComposer private function shortForm(?Closure $writable): DiviContentComposer
{ {
return new DiviContentComposer($this->schema, $this->composition(), new DiviSaver(), $this->design, $writable); return new DiviContentComposer($this->schema, $this->composition(), new DiviSaver(), $this->design, $writable);
@@ -5,12 +5,13 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library; namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror; use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\WordPress\Content\NewDraft; use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use RuntimeException; use RuntimeException;
@@ -55,7 +56,7 @@ class DiviLibraryStore implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::LIBRARY, DiviAreas::LIBRARY,
calls: [ calls: [
// isGlobal() serves listing and describing as well as the global edits. // isGlobal() serves listing and describing as well as the global edits.
new Call( new Call(
+4 -3
View File
@@ -6,10 +6,11 @@ namespace LIT\MCP\Integrations\Divi\Library;
use Closure; use Closure;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -35,7 +36,7 @@ class DiviRoles implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::LIBRARY, DiviAreas::LIBRARY,
calls: [new Call('et_pb_is_allowed', 1)], calls: [new Call('et_pb_is_allowed', 1)],
features: [DiviFeature::Roles] features: [DiviFeature::Roles]
); );
+5 -4
View File
@@ -4,12 +4,13 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages; namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror; use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException; use RuntimeException;
use WP_Post; use WP_Post;
@@ -35,7 +36,7 @@ final class DiviPages implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::PAGES, DiviAreas::PAGES,
calls: [ calls: [
// Also called by DiviThemeBuilder::clearSiteCaches() – a break locks both features. // Also called by DiviThemeBuilder::clearSiteCaches() – a break locks both features.
new Call( new Call(
@@ -8,10 +8,11 @@ use Closure;
use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules; use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
use LIT\MCP\Integrations\Divi\Compose\LoopScope; use LIT\MCP\Integrations\Divi\Compose\LoopScope;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Log; use LIT\MCP\Log;
use Throwable; use Throwable;
@@ -56,7 +57,7 @@ class DynamicContentMap implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::CATALOG, DiviAreas::CATALOG,
calls: [new Call(self::REGISTRY . '::get_options', 2)], calls: [new Call(self::REGISTRY . '::get_options', 2)],
features: [DiviFeature::DynamicSources] features: [DiviFeature::DynamicSources]
); );
-47
View File
@@ -1,47 +0,0 @@
<?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() . '()';
}
}
@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** The latest admin check report of this site (spec §6.3). Not final so the Divi tab can be tested with a mock. */
class CheckReportStore
{
public const OPTION = 'lit_mcp_divi_check_report';
public function latest(): ?CheckReport
{
return CheckReport::fromArray(get_option(self::OPTION, null));
}
public function save(CheckReport $report): void
{
update_option(self::OPTION, $report->toArray(), false);
}
}
@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** One step of the admin check (spec §6.2); skipped = not run (the write test after missing Divi functions). */
final class CheckStep
{
public const MANIFEST = 'manifest';
public const MIRRORS = 'mirrors';
public const CATALOG = 'catalog';
public const FORMAT = 'format';
public const WRITE = 'write';
private const LABELS = [
self::MANIFEST => 'Divi-Funktionen, Klassen und Registrierungen',
self::MIRRORS => 'Fingerabdrücke nachgebauter Divi-Funktionen',
self::CATALOG => 'Modulkatalog (Kurzform-Pfade)',
self::FORMAT => 'Format im Speicher (Parser und Serializer)',
self::WRITE => 'Schreibtest auf einer Wegwerfseite',
];
/** @param list<string> $problems */
public function __construct(
public readonly string $key,
public readonly array $problems,
public readonly bool $skipped = false
) {
}
public function green(): bool
{
return ! $this->skipped && $this->problems === [];
}
public function label(): string
{
return self::LABELS[$this->key] ?? $this->key;
}
/** @return array{key: string, problems: list<string>, skipped: bool} */
public function toArray(): array
{
return ['key' => $this->key, 'problems' => $this->problems, 'skipped' => $this->skipped];
}
public static function fromArray(mixed $data): ?self
{
if (! is_array($data) || ! is_string($data['key'] ?? null) || ! is_array($data['problems'] ?? null)) {
return null;
}
return new self(
$data['key'],
array_values(array_filter($data['problems'], 'is_string')),
($data['skipped'] ?? false) === true
);
}
}
@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Plugin;
use Throwable;
defined('ABSPATH') || exit;
/**
* The admin check (spec §6.2) – also wp lit-mcp divi-check: manifests (code and registrations), fingerprints,
* module catalog, format round trip and the write test. A step that throws is a red step; the write test is skipped
* only when Divi functions are missing (it would fail anyway), not when only fingerprints changed – so
* --update-reference can see whether everything else still works. Not final so the route and the command can be
* tested with a mock.
*/
class CompatibilityCheck
{
/**
* @param Closure(): list<string> $catalog Short-form problems against Divi's live catalog.
* @param Closure(): string $version Installed Divi version ('' = unknown).
* @param Closure(string): string $stateKey ReleaseResolver::stateKey().
*/
public function __construct(
private ManifestCheck $manifests,
private Closure $catalog,
private FormatCheck $format,
private WriteProbe $probe,
private Closure $version,
private Closure $stateKey
) {
}
public static function create(ContentComposer $composer): self
{
return new self(
new ManifestCheck(),
static fn (): array => DiviModules::isAvailable()
? ShortformCheck::problems((new DiviModules())->catalog())
: ['Divi 5 ist in dieser Anfrage nicht geladen.'],
new FormatCheck(),
new WriteProbe($composer),
static fn (): string => DiviEnvironment::version() ?? '',
static fn (string $version): string => ReleaseResolver::stateKey($version)
);
}
public function run(int $userId): CheckReport
{
$manifest = self::guarded(fn (): array => ManifestCheck::failed(
array_merge($this->manifests->codeChecks(), $this->manifests->registrationChecks())
));
$steps = [
new CheckStep(CheckStep::MANIFEST, $manifest),
new CheckStep(CheckStep::MIRRORS, self::guarded(
fn (): array => ManifestCheck::failed($this->manifests->mirrorChecks())
)),
new CheckStep(CheckStep::CATALOG, self::guarded($this->catalog)),
new CheckStep(CheckStep::FORMAT, self::guarded(fn (): array => $this->format->problems())),
$manifest === []
? new CheckStep(CheckStep::WRITE, self::guarded(fn (): array => $this->probe->run()))
: new CheckStep(CheckStep::WRITE, [], true),
];
$version = ($this->version)();
return new CheckReport(
wp_generate_uuid4(),
gmdate('c'),
$userId,
$version,
Plugin::VERSION,
($this->stateKey)($version),
$steps
);
}
/** @return list<string> */
private static function guarded(Closure $step): array
{
try {
return array_values(array_filter((array) $step(), 'is_string'));
} catch (Throwable $e) {
return ['Prüfschritt abgebrochen: ' . $e->getMessage()];
}
}
}
@@ -1,148 +0,0 @@
<?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);
}
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;
}
/**
* The statuses of those of $features that are not usable – each once, in the order of $features. The one answer
* to "what locks this call" for the gate (DiviFeatureGate), the adapters (ReleaseGate::assertFeatures()) and the
* refusal (ReleaseStatus::refusalFor()).
*
* @param list<DiviFeature> $features
* @return list<FeatureStatus>
*/
public function locked(array $features): array
{
$locked = [];
foreach ($features as $feature) {
$status = $this->of($feature);
if (! $status->usable()) {
$locked[$feature->value] = $status;
}
}
return array_values($locked);
}
/** @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));
}
}
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Compatibility\Manifest;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** A class that may touch Divi (spec §3); it lists every Divi dependency in its manifest. */ /** A class that may touch Divi (spec §3); it lists every Divi dependency in its manifest. */
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\FontCatalog; use LIT\MCP\Integrations\Divi\Design\FontCatalog;
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** The five Divi areas of spec §3. Each value is the prefix of the labels in the admin check. */
final class DiviAreas
{
public const PAGES = 'Seiten';
public const LIBRARY = 'Bibliothek';
public const THEME_BUILDER = 'Theme Builder';
public const DESIGN = 'Designsystem';
public const CATALOG = 'Katalog';
}
@@ -1,125 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Integrations\Divi\Admin\DiviTab;
use LIT\MCP\Log;
use Throwable;
use WP_REST_Response;
defined('ABSPATH') || exit;
/**
* "Prüfung starten" of the Divi tab (spec §6.1): POST /wp-json/lit-mcp/v1/divi-check. A REST request on purpose –
* not admin-post – so the check runs in the same context as MCP calls (Divi renders its blocks in REST requests,
* lit-page-render-summary). The form sends the wp_rest nonce as _wpnonce (cookie authentication); only manage_options.
* One check at a time: an atomic "INSERT IGNORE" on the options table (self::LOCK) is the lock – add_option() reads
* then writes (wp-includes/option.php) and is not atomic, so two simultaneous requests could both "acquire" it. A
* lock older than LOCK_TTL is a dead check's, taken over with a conditional UPDATE (compare-and-swap on the value
* just read) so only one of several concurrent takers wins. A check that throws still releases the lock (finally)
* and redirects with divi_check_failed (logged, never shown to the browser as an unhandled 500). Answers 303 back
* to the tab.
*/
final class DiviCheckRoute
{
public const NAMESPACE = 'lit-mcp/v1';
public const ROUTE = '/divi-check';
public const LOCK = 'lit_mcp_divi_check_lock';
private const LOCK_TTL = 600;
/** @param Closure(): CompatibilityCheck $check */
public function __construct(private Closure $check, private ?CheckReportStore $reports = null)
{
}
public function register(): void
{
add_action('rest_api_init', [$this, 'registerRoute']);
}
public function registerRoute(): void
{
register_rest_route(self::NAMESPACE, self::ROUTE, [
'methods' => 'POST',
'callback' => [$this, 'handle'],
'permission_callback' => static fn (): bool => current_user_can('manage_options'),
]);
}
public static function url(): string
{
return rest_url(self::NAMESPACE . self::ROUTE);
}
public function handle(): WP_REST_Response
{
$notice = 'divi_check_running';
if (self::lock()) {
try {
$report = ($this->check)()->run(get_current_user_id());
($this->reports ?? new CheckReportStore())->save($report);
ReleaseGate::invalidate();
$notice = 'divi_checked';
} catch (Throwable $e) {
Log::failure('divi check', $e);
$notice = 'divi_check_failed';
} finally {
delete_option(self::LOCK);
}
}
return new WP_REST_Response(
null,
303,
['Location' => SettingsPage::url(DiviTab::SLUG, ['lit_mcp_notice' => $notice])]
);
}
private static function lock(): bool
{
global $wpdb;
if (self::insertLock($wpdb, time())) {
return true;
}
// get_var(), not get_option(): the row was written with raw SQL, bypassing the options cache.
$since = $wpdb->get_var($wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
self::LOCK
));
if ($since === null) {
// The lock was released between our failed insert and this read; try once more.
return self::insertLock($wpdb, time());
}
if (time() - (int) $since < self::LOCK_TTL) {
return false;
}
$taken = $wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
(string) time(),
self::LOCK,
$since
));
return (int) $taken === 1;
}
/** INSERT IGNORE: 1 row affected = the lock did not exist and is now ours; 0 = someone else holds it. */
private static function insertLock(\wpdb $wpdb, int $now): bool
{
$inserted = $wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
self::LOCK,
(string) $now
));
return (int) $inserted === 1;
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\CompatibilityCheck;
defined('ABSPATH') || exit;
/** The check steps only Divi has (module catalog, format round trip, write test) and their labels. */
final class DiviCheckSteps
{
public const CATALOG = 'catalog';
public const FORMAT = 'format';
public const WRITE = 'write';
/** Labels of the Divi-only steps; manifest and mirrors come from CheckStep::genericLabel(). */
private const LABELS = [
self::CATALOG => 'Modulkatalog (Kurzform-Pfade)',
self::FORMAT => 'Format im Speicher (Parser und Serializer)',
self::WRITE => 'Schreibtest auf einer Wegwerfseite',
];
/** @param list<string> $problems */
public static function step(string $key, array $problems, bool $skipped = false): CheckStep
{
return new CheckStep($key, $problems, $skipped, self::LABELS[$key] ?? null);
}
/**
* The Profile::$extraSteps of Divi: module catalog, format round trip and the write test. The write test is
* skipped only when Divi functions are missing (it would fail anyway), not when only fingerprints changed – so
* --update-reference can see whether everything else still works.
*
* @param Closure(): list<string> $catalog Short-form problems against Divi's live catalog.
* @param Closure(): list<string> $probe WriteProbe::run().
* @return Closure(bool): list<CheckStep>
*/
public static function extra(Closure $catalog, FormatCheck $format, Closure $probe): Closure
{
return static fn (bool $manifestClean): array => [
self::step(self::CATALOG, CompatibilityCheck::guarded($catalog)),
self::step(self::FORMAT, CompatibilityCheck::guarded(static fn (): array => $format->problems())),
$manifestClean
? self::step(self::WRITE, CompatibilityCheck::guarded($probe))
: self::step(self::WRITE, [], true),
];
}
public static function label(CheckStep $step): string
{
// Reports saved before labels were stored have none.
return $step->label
?? self::LABELS[$step->key]
?? CheckStep::genericLabel($step->key, DiviProfile::LABEL)
?? $step->key;
}
}
@@ -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
{ {
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityGuard; use LIT\MCP\Abilities\AbilityGuard;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\ReleaseState;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -17,7 +18,8 @@ defined('ABSPATH') || exit;
* answers divi_incompatible / divi_untested with the affected capability instead of failing inside Divi. A refused * 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 * 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 * nothing (RequiresDiviFeatures, or DeclaresRequirements for lit/content-create without divi) is left alone; the
* adapters still check each write themselves. * adapters still check each write themselves. IDs that are no Divi capability are ignored here; CapabilityGate
* checks them (and locks the unknown ones).
*/ */
final class DiviFeatureGate implements AbilityGuard final class DiviFeatureGate implements AbilityGuard
{ {
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Compatibility\SourceFingerprint;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
defined('ABSPATH') || exit;
/** The ManifestCheck of Divi: its adapter manifests, signature references, theme sources and block registry. */
final class DiviManifestCheck
{
public static function create(): ManifestCheck
{
return new ManifestCheck(
DiviAdapters::manifests(),
DiviReference::expected(),
self::installed(...),
DiviModules::knowsBlock(...)
);
}
private static function installed(Mirror $mirror): ?string
{
$dir = DiviEnvironment::themeDir();
return $dir === null
? null
: SourceFingerprint::of($dir . '/' . $mirror->file, $mirror->function, $mirror->class)->hash;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LogicException;
defined('ABSPATH') || exit;
/** Divi as a compatibility profile: every version needs a release, the matrix comes from the release gate. */
final class DiviProfile
{
public const ID = 'divi';
public const LABEL = 'Divi';
/** Stored reports and release rows keep this key, so data from before the generic layer stays readable. */
public const REPORT_VERSION_KEY = 'divi_version';
/** @param (Closure(): ContentComposer)|null $composer The write test's composer; without it that step fails. */
public static function profile(?Closure $composer = null): Profile
{
return new Profile(
id: self::ID,
label: self::LABEL,
tier: Tier::ReleasePerVersion,
capabilities: DiviFeature::cases(),
manifests: array_values(DiviAdapters::manifests()),
active: static fn (): bool => DiviEnvironment::version() !== null,
version: DiviEnvironment::version(...),
matrix: static fn () => ReleaseGate::status()->matrix,
testedVersions: DiviReleases::TESTED,
stateKey: ReleaseResolver::stateKey(...),
extraSteps: DiviCheckSteps::extra(
static fn (): array => DiviModules::isAvailable()
? ShortformCheck::problems((new DiviModules())->catalog())
: ['Divi 5 ist in dieser Anfrage nicht geladen.'],
new FormatCheck(),
static fn (): array => (new WriteProbe(
$composer === null ? throw new LogicException('no composer for the write test') : $composer()
))->run()
),
manifestCheck: DiviManifestCheck::create(...),
stepLabel: DiviCheckSteps::label(...),
reportVersionKey: self::REPORT_VERSION_KEY
);
}
}
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\SignatureFingerprint;
use LIT\MCP\Compatibility\SourceFingerprint;
use RuntimeException; use RuntimeException;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -18,8 +21,7 @@ final class DiviReference
{ {
public const FILE = __DIR__ . '/fingerprints.json'; public const FILE = __DIR__ . '/fingerprints.json';
/** Key prefix of the signature fingerprints; no mirror ID starts with it. */ public const SIGNATURE_PREFIX = Call::SIGNATURE_PREFIX;
public const SIGNATURE_PREFIX = 'sig:';
/** /**
* @return array<string, string> Mirror ID or "sig:<symbol>" => sha256, sorted by key; [] when the file is * @return array<string, string> Mirror ID or "sig:<symbol>" => sha256, sorted by key; [] when the file is
@@ -61,12 +63,6 @@ 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). * Signature fingerprints of the calls in the running PHP process (Divi loaded).
* *
@@ -82,7 +78,7 @@ final class DiviReference
if ($hash === null) { if ($hash === null) {
throw new RuntimeException(sprintf('signature of %s: symbol is not loaded', $call->label())); throw new RuntimeException(sprintf('signature of %s: symbol is not loaded', $call->label()));
} }
$signatures[self::signatureKey($call)] = $hash; $signatures[$call->signatureKey()] = $hash;
} }
ksort($signatures); ksort($signatures);
@@ -8,7 +8,8 @@ defined('ABSPATH') || exit;
/** /**
* Divi versions checked with this plugin version (spec §5.3, source "code"): add a version after * Divi versions checked with this plugin version (spec §5.3, source "code"): add a version after
* wp lit-mcp divi-check ran green against it. Any other version needs a release in the Divi tab of each site. * wp lit-mcp divi-check ran green against it. Any other version needs a release in the tab "Compatibility" of each
* site.
*/ */
final class DiviReleases final class DiviReleases
{ {
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Release;
use Closure; use Closure;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Compatibility\CompatibilityMatrix;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -21,7 +22,7 @@ final class FeatureOverview
{ {
} }
/** An overview that knows no tools (tests, and a Divi tab built without a registry). */ /** An overview that knows no tools (tests, and an admin tab built without a registry). */
public static function withoutTools(): self public static function withoutTools(): self
{ {
return new self(static fn (): array => []); return new self(static fn (): array => []);
@@ -18,7 +18,7 @@ final class FeatureSets
/** /**
* The capabilities $ability declares for $input: its own diviFeatures() (the Divi tools) or the IDs a core tool * 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 * 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). * DiviFeature is ignored (CapabilityGate checks the IDs of other integrations and locks unknown ones).
* *
* @param array<string, mixed> $input * @param array<string, mixed> $input
* @return list<DiviFeature> * @return list<DiviFeature>
-46
View File
@@ -1,46 +0,0 @@
<?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 . '()';
}
}
+17 -5
View File
@@ -5,6 +5,9 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Integrations\Divi\Release;
use Closure; use Closure;
use LIT\MCP\Compatibility\Capability;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\FeatureStatus;
use LIT\MCP\Log; use LIT\MCP\Log;
use Throwable; use Throwable;
@@ -37,7 +40,10 @@ final class ReleaseGate
self::$status = (self::$resolver ?? static fn (): ReleaseStatus => (new ReleaseResolver())->resolve())(); self::$status = (self::$resolver ?? static fn (): ReleaseStatus => (new ReleaseResolver())->resolve())();
} catch (Throwable $e) { } catch (Throwable $e) {
Log::failure('divi release status', $e); Log::failure('divi release status', $e);
self::$status = ReleaseStatus::of('', CompatibilityMatrix::allIncompatible(self::UNRESOLVED)); self::$status = ReleaseStatus::of(
'',
CompatibilityMatrix::allIncompatible(DiviFeature::cases(), self::UNRESOLVED)
);
} }
return self::$status; return self::$status;
@@ -107,11 +113,17 @@ final class ReleaseGate
{ {
$status = self::status(); $status = self::status();
$locked = $status->matrix->locked($features); $locked = $status->matrix->locked($features);
if ($locked === []) {
return null;
}
return $locked === [] ? null : new DiviLocked( $named = array_values(array_filter(
$status, array_map(static fn (FeatureStatus $feature): Capability => $feature->feature, $locked),
array_map(static fn (FeatureStatus $feature): DiviFeature => $feature->feature, $locked) static fn (Capability $capability): bool => $capability instanceof DiviFeature
); ));
// Fail closed: the lock stays; without a DiviFeature among its capabilities it names the features asked for.
return new DiviLocked($status, $named === [] ? $features : $named);
} }
public static function whileProbing(int $postId, callable $write): mixed public static function whileProbing(int $postId, callable $write): mixed
@@ -5,6 +5,10 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Integrations\Divi\Release;
use Closure; use Closure;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Compatibility\Problem;
use LIT\MCP\Plugin; use LIT\MCP\Plugin;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -44,15 +48,20 @@ final class ReleaseResolver
public function resolve(): ReleaseStatus public function resolve(): ReleaseStatus
{ {
$version = ($this->version ?? DiviEnvironment::version(...))() ?? ''; $version = ($this->version ?? DiviEnvironment::version(...))() ?? '';
$check = $this->check ?? new ManifestCheck(); $check = $this->check ?? DiviManifestCheck::create();
$problems = array_merge($check->codeProblems(), $this->mirrorProblems($check, $version)); $problems = array_merge($check->codeProblems(), $this->mirrorProblems($check, $version));
if (in_array($version, DiviReleases::TESTED, true)) { if (in_array($version, DiviReleases::TESTED, true)) {
return ReleaseStatus::of($version, self::matrix($problems, ReleaseStatus::SOURCE_CODE, false)); return ReleaseStatus::of($version, self::matrix($problems, CompatibilityMatrix::SOURCE_CODE, false));
} }
$entry = $version === '' ? null : ($this->store ?? new ReleaseStore())->find($version, Plugin::VERSION); $store = $this->store ?? new ReleaseStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY);
$entry = $version === '' ? null : $store->find($version, Plugin::VERSION);
if ($entry !== null) { if ($entry !== null) {
return ReleaseStatus::of($version, self::matrix($problems, ReleaseStatus::SOURCE_DATABASE, false), $entry); return ReleaseStatus::of(
$version,
self::matrix($problems, CompatibilityMatrix::SOURCE_DATABASE, false),
$entry
);
} }
return ReleaseStatus::of($version, self::matrix($problems, null, $version !== '' && self::automatic())); return ReleaseStatus::of($version, self::matrix($problems, null, $version !== '' && self::automatic()));
@@ -81,60 +90,10 @@ final class ReleaseResolver
return apply_filters(self::AUTO_RELEASE_FILTER, true) === true; return apply_filters(self::AUTO_RELEASE_FILTER, true) === true;
} }
/** /** @param list<Problem> $problems */
* @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 private static function matrix(array $problems, ?string $source, bool $automatic): CompatibilityMatrix
{ {
$features = []; return CompatibilityMatrix::fromProblems(DiviFeature::cases(), $problems, $source, $automatic);
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> */ /** @return list<Problem> */
@@ -174,7 +133,7 @@ final class ReleaseResolver
$problems = []; $problems = [];
foreach ($entry['problems'] as $row) { foreach ($entry['problems'] as $row) {
$problem = Problem::fromArray($row); $problem = Problem::fromArray($row, DiviFeature::tryFrom(...));
if ($problem === null) { if ($problem === null) {
return null; return null;
} }
@@ -5,6 +5,9 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\FeatureStatus;
use LIT\MCP\Compatibility\ReleaseState;
use LogicException; use LogicException;
use WP_Error; use WP_Error;
@@ -16,13 +19,13 @@ defined('ABSPATH') || exit;
*/ */
final class ReleaseStatus final class ReleaseStatus
{ {
public const SOURCE_CODE = 'code'; public const SOURCE_CODE = CompatibilityMatrix::SOURCE_CODE;
public const SOURCE_DATABASE = 'database'; public const SOURCE_DATABASE = CompatibilityMatrix::SOURCE_DATABASE;
public const SOURCE_AUTO = 'auto'; public const SOURCE_AUTO = CompatibilityMatrix::SOURCE_AUTO;
private const FEATURE_UNTESTED = 'Achtung: %1$s ist auf dieser Website für %2$s noch nicht für LIT MCP ' 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 ' . 'freigegeben. Das ist gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Kompatibilität die '
. 'ausführt und die Version freigibt. Es wurde nichts geändert.'; . '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 ' 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.'; . '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, ' private const FEATURE_INCOMPATIBLE = '%1$s hat Stellen geändert, die LIT MCP für %2$s nutzt. Das ist gesperrt, '
@@ -52,7 +55,7 @@ final class ReleaseStatus
if ($matrix !== null && $matrix->state() !== $state) { if ($matrix !== null && $matrix->state() !== $state) {
throw new LogicException('the matrix is ' . $matrix->state()->value . ', not ' . $state->value); throw new LogicException('the matrix is ' . $matrix->state()->value . ', not ' . $state->value);
} }
$this->matrix = $matrix ?? CompatibilityMatrix::uniform($state, $problems, $source); $this->matrix = $matrix ?? CompatibilityMatrix::uniform(DiviFeature::cases(), $state, $problems, $source);
} }
/** @param array<string, mixed>|null $release The database entry of a SOURCE_DATABASE release. */ /** @param array<string, mixed>|null $release The database entry of a SOURCE_DATABASE release. */
@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* Divi versions released on this site after a green admin check (spec §5.3, source "database"). An entry holds for
* the plugin version that ran the check only.
*
* Not final so the resolver can be tested with a mock.
*/
class ReleaseStore
{
public const OPTION = 'lit_mcp_divi_releases';
/**
* @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
* report_id: string}>
*/
public function entries(): array
{
$raw = get_option(self::OPTION, []);
$entries = [];
foreach (is_array($raw) ? $raw : [] as $row) {
if (
is_array($row)
&& is_string($row['divi_version'] ?? null)
&& is_string($row['plugin_version'] ?? null)
&& is_int($row['user_id'] ?? null)
&& is_string($row['released_at'] ?? null)
&& is_string($row['report_id'] ?? null)
) {
$entries[] = [
'divi_version' => $row['divi_version'],
'plugin_version' => $row['plugin_version'],
'user_id' => $row['user_id'],
'released_at' => $row['released_at'],
'report_id' => $row['report_id'],
];
}
}
return $entries;
}
/**
* @return array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
* report_id: string}|null
*/
public function find(string $diviVersion, string $pluginVersion): ?array
{
foreach ($this->entries() as $entry) {
if ($entry['divi_version'] === $diviVersion && $entry['plugin_version'] === $pluginVersion) {
return $entry;
}
}
return null;
}
public function add(string $diviVersion, string $pluginVersion, int $userId, string $reportId): void
{
$entries = $this->without($diviVersion, $pluginVersion);
$entries[] = [
'divi_version' => $diviVersion,
'plugin_version' => $pluginVersion,
'user_id' => $userId,
'released_at' => gmdate('c'),
'report_id' => $reportId,
];
update_option(self::OPTION, $entries, false);
}
public function withdraw(string $diviVersion, string $pluginVersion): bool
{
if ($this->find($diviVersion, $pluginVersion) === null) {
return false;
}
update_option(self::OPTION, $this->without($diviVersion, $pluginVersion), false);
return true;
}
/**
* @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
* report_id: string}>
*/
private function without(string $diviVersion, string $pluginVersion): array
{
return array_values(array_filter(
$this->entries(),
static fn (array $entry): bool => $entry['divi_version'] !== $diviVersion
|| $entry['plugin_version'] !== $pluginVersion
));
}
}
+17 -61
View File
@@ -5,10 +5,10 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release; namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Compatibility\ThrowawayPost;
use LIT\MCP\Integrations\Divi\Pages\DiviPages; use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer; use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use Throwable;
use WP_Error; use WP_Error;
use WP_Post; use WP_Post;
@@ -19,7 +19,7 @@ defined('ABSPATH') || exit;
* → Serializer → DiviSaver, with the release lock opened for this page only), checked (content, builder flags, * → Serializer → DiviSaver, with the release lock opened for this page only), checked (content, builder flags,
* Divi renders every module without a PHP warning from Divi's files) and always deleted for good – in finally, * Divi renders every module without a PHP warning from Divi's files) and always deleted for good – in finally,
* not to the trash, with its revisions and meta. Pages a dead check left behind carry MARKER; removeOrphans() * not to the trash, with its revisions and meta. Pages a dead check left behind carry MARKER; removeOrphans()
* deletes them. No change log entry. Not final so CompatibilityCheck can be tested with a mock. * deletes them (both in ThrowawayPost). No change log entry. Not final so CompatibilityCheck can be tested with a mock.
*/ */
class WriteProbe class WriteProbe
{ {
@@ -69,9 +69,6 @@ class WriteProbe
'litcheck-accordion', 'litcheck-tab', 'litcheck-icon-list', 'litcheck-accordion', 'litcheck-tab', 'litcheck-icon-list',
]; ];
/** Only pages older than this are orphans – a running check's page is younger. */
private const ORPHAN_AGE = '10 minutes ago';
/** /**
* @param string|null $diviDir PHP warnings from files below it fail the check; null = DiviEnvironment::themeDir(). * @param string|null $diviDir PHP warnings from files below it fail the check; null = DiviEnvironment::themeDir().
*/ */
@@ -85,64 +82,28 @@ class WriteProbe
/** @return list<string> German problems; [] = green. */ /** @return list<string> German problems; [] = green. */
public function run(): array public function run(): array
{ {
$id = wp_insert_post([ return self::throwaway()->run('page', fn (int $id): array => $this->exercise($id));
'post_type' => 'page',
'post_status' => 'draft',
'post_title' => self::TITLE . ' ' . gmdate('Y-m-d H:i'),
'post_content' => '',
'meta_input' => [self::MARKER => '1'],
], true);
if ($id instanceof WP_Error || (int) $id <= 0) {
return ['Die Testseite konnte nicht angelegt werden: '
. ($id instanceof WP_Error ? $id->get_error_message() : 'keine ID')];
}
$id = (int) $id;
try {
$problems = $this->exercise($id);
} catch (Throwable $e) {
$problems = ['Schreibtest abgebrochen: ' . $e->getMessage()];
} finally {
if (! self::delete($id)) {
$problems[] = sprintf(
'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.',
$id
);
}
}
return $problems;
} }
/** /**
* @return list<int> IDs of check pages a dead check left behind, deleted now. Only drafts whose title starts * @return list<int> IDs of check pages a dead check left behind, deleted now (ThrowawayPost::removeOrphans():
* with self::TITLE are considered – the marker meta alone is not enough (it is only a query hint here). * marked drafts and private pages with the check title only).
*/ */
public static function removeOrphans(): array public static function removeOrphans(): array
{ {
$posts = get_posts([ return self::throwaway()->removeOrphans('page');
'post_type' => 'page', }
'post_status' => 'draft',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked pages at most.
'meta_key' => self::MARKER,
'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']],
'fields' => 'all',
'posts_per_page' => 20,
'no_found_rows' => true,
'suppress_filters' => true,
]);
$removed = []; private static function throwaway(): ThrowawayPost
foreach (is_array($posts) ? $posts : [] as $post) { {
if (! $post instanceof WP_Post || ! str_starts_with((string) $post->post_title, self::TITLE)) { return new ThrowawayPost(
continue; self::MARKER,
} self::TITLE,
if (self::delete((int) $post->ID)) { 'Schreibtest abgebrochen (Details im PHP-Error-Log).',
$removed[] = (int) $post->ID; 'Die Testseite konnte nicht angelegt werden (Details im PHP-Error-Log).',
} 'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.',
} 'divi write test'
);
return $removed;
} }
/** @return list<string> */ /** @return list<string> */
@@ -216,9 +177,4 @@ class WriteProbe
return [$html, $warnings]; return [$html, $warnings];
} }
private static function delete(int $id): bool
{
return wp_delete_post($id, true) instanceof WP_Post;
}
} }
+4 -3
View File
@@ -7,10 +7,11 @@ namespace LIT\MCP\Integrations\Divi\Schema;
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration; use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -39,7 +40,7 @@ class DiviModules implements DiviAdapter
public static function manifest(): Manifest public static function manifest(): Manifest
{ {
return new Manifest( return new Manifest(
Manifest::CATALOG, DiviAreas::CATALOG,
calls: [ calls: [
new Call(ModuleRegistration::class . '::get_all_core_modules_metadata', 0), new Call(ModuleRegistration::class . '::get_all_core_modules_metadata', 0),
new Call(ModuleRegistration::class . '::get_default_attrs', 2), new Call(ModuleRegistration::class . '::get_default_attrs', 2),
@@ -4,12 +4,13 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder; namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Compatibility\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror; use LIT\MCP\Compatibility\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException; use RuntimeException;
use Throwable; use Throwable;
@@ -83,7 +84,7 @@ class DiviThemeBuilder implements DiviAdapter
$dir = 'includes/builder/frontend-builder/theme-builder/'; $dir = 'includes/builder/frontend-builder/theme-builder/';
return new Manifest( return new Manifest(
Manifest::THEME_BUILDER, DiviAreas::THEME_BUILDER,
calls: [ calls: [
// liveId(): reads the live post, or creates it like Divi's first save. // liveId(): reads the live post, or creates it like Divi's first save.
new Call( new Call(
@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\FastestCache;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* lit/cache-clear: empties the WP Fastest Cache on request – for changes made outside this plugin (the plugin's own
* writes empty it anyway, FastestCacheListener). Dry run without confirm; with confirm: true the whole cache goes
* through the same throttle and trailing purge as the listener (FastestCachePurger), a post through
* wpfc_clear_post_cache_by_id (any status: WPFC also finds the address of a trashed post). The change log entry is
* ignored by the listener (NAME).
*/
final class CacheClearAbility implements Ability, DeclaresRequirements
{
public const NAME = 'lit/cache-clear';
private const OUTCOMES = ['purged', 'throttled', 'cache_off'];
public function __construct(private FastestCachePurger $purger, private ChangeLog $log)
{
}
public function name(): string
{
return self::NAME;
}
public function label(): string
{
return 'Seiten-Cache leeren';
}
public function description(): string
{
return 'Leert den Seiten-Cache von WP Fastest Cache: scope "all" den ganzen Cache, scope "post" nur die Seite '
. 'eines Beitrags (post_id). Änderungen durch LIT MCP leeren ihn ohnehin automatisch – das Tool ist für '
. 'Fälle, in denen etwas außerhalb von LIT MCP geändert wurde. Ohne confirm nur Probelauf (nichts wird '
. 'geleert); mit confirm: true nach Rückfrage beim Nutzer ausführen. Das Leeren des ganzen Caches ist auf '
. 'alle 30 Sekunden gedrosselt (ein Nachlauf folgt).';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'scope' => ['type' => 'string', 'enum' => ['all', 'post']],
'post_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Pflicht bei scope "post".'],
'confirm' => ['type' => 'boolean', 'description' => 'true nach Bestätigung durch den Nutzer.'],
],
'required' => ['scope'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(
[
'scope' => ['type' => 'string', 'enum' => ['all', 'post']],
'outcome' => ['type' => 'string', 'enum' => self::OUTCOMES],
],
['scope', 'outcome']
);
}
public function annotations(): array
{
return Annotations::write(true);
}
public function isDirect(): bool
{
return false;
}
public function requirements(array $input): array
{
return match ($input['scope'] ?? null) {
'all' => [FastestCacheFeature::PurgeAll->id()],
'post' => [FastestCacheFeature::PurgePost->id()],
default => [],
};
}
public function permission(array $input): bool|WP_Error
{
$target = $this->target($input);
return $target instanceof WP_Error ? $target : true;
}
public function execute(array $input): mixed
{
$target = $this->target($input);
if ($target instanceof WP_Error) {
return $target;
}
$post = $target instanceof WP_Post ? $target : null;
$scope = $post === null ? 'all' : 'post';
$what = $post === null
? 'den ganzen Seiten-Cache'
: sprintf('die Seite von "%s" (ID %d)', $post->post_title, $post->ID);
$enabled = $this->purger->cacheEnabled();
if (($input['confirm'] ?? false) !== true) {
return $this->dryRun($scope, $post, $what, $enabled);
}
if (! $enabled) {
return $this->result(
$scope,
'cache_off',
'Der Seiten-Cache von WP Fastest Cache ist ausgeschaltet; nichts zu leeren.'
);
}
if ($post !== null) {
$outcome = $this->purger->post((int) $post->ID) ? FastestCachePurger::PURGED : FastestCachePurger::LOCKED;
} else {
$outcome = $this->purger->everything();
}
if ($outcome === FastestCachePurger::LOCKED) {
return Errors::make(
'compat_incompatible',
'WP Fastest Cache ist gesperrt (siehe Reiter Kompatibilität). Es wurde nichts geleert.',
'Den Nutzer informieren, dass die Kompatibilität von WP Fastest Cache geprüft werden muss.',
423
);
}
$summary = match ($outcome) {
FastestCachePurger::THROTTLED => 'Der ganze Seiten-Cache wurde vor weniger als 30 Sekunden geleert; das '
. 'Leeren folgt als Nachlauf per WP-Cron.',
default => ucfirst($what) . ' wurde geleert.',
};
return $this->result($scope, $outcome, $summary, $post);
}
private function dryRun(string $scope, ?WP_Post $post, string $what, bool $enabled): WP_Error
{
$throttled = $scope === 'all' && $this->purger->throttled();
$note = '';
if (! $enabled) {
$note = ' Der Cache ist ausgeschaltet; es würde nichts geleert.';
} elseif ($throttled) {
$note = ' Das Leeren wäre gerade gedrosselt und folgt als Nachlauf.';
}
return Errors::confirmRequired(
sprintf('Probelauf: %s leeren?%s Nichts wurde geleert.', ucfirst($what), $note),
array_filter(
['scope' => $scope, 'post_id' => $post?->ID, 'cache_enabled' => $enabled, 'throttled' => $throttled],
static fn (mixed $value): bool => $value !== null
)
);
}
/** @return array<string, mixed> */
private function result(string $scope, string $outcome, string $summary, ?WP_Post $post = null): array
{
$result = ToolResult::ok($summary)->with('scope', $scope)->with('outcome', $outcome);
if (in_array($outcome, ['purged', 'throttled'], true)) {
$warnings = [];
$logId = FollowUp::run(
fn () => $this->log->record(
self::NAME,
'wpfc',
$post === null ? 'all' : (string) $post->ID,
$summary
),
self::NAME . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; der Cache wurde trotzdem behandelt.',
$warnings
);
$result->logId(is_int($logId) ? $logId : null)->warnings($warnings);
}
return $result->toArray();
}
/**
* The checked target: the WP_Post of scope "post", true for "all" – or the error. Input is not schema-validated
* yet when permission() runs.
*
* @param array<string, mixed> $input
*/
private function target(array $input): WP_Post|true|WP_Error
{
$scope = $input['scope'] ?? null;
if ($scope === 'all') {
return current_user_can('manage_options')
? true
: Errors::forbidden('Keine Berechtigung, den ganzen Seiten-Cache zu leeren (manage_options).');
}
if ($scope !== 'post') {
return Errors::invalidInput('scope fehlt oder ist ungültig.', ['scope' => '"all" oder "post"']);
}
$id = $input['post_id'] ?? null;
if (! is_int($id) || $id < 1) {
return Errors::invalidInput(
'post_id fehlt oder ist ungültig.',
['post_id' => 'positive Ganzzahl erwartet']
);
}
$post = get_post($id);
if (! $post instanceof WP_Post) {
return Errors::notFound(sprintf('Eintrag %d nicht gefunden.', $id));
}
return current_user_can('edit_post', $id)
? $post
: Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id));
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\FastestCache;
use Closure;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\ProbeStep;
defined('ABSPATH') || exit;
/** The check step only WP Fastest Cache has (the purge probe, FastestCacheProbe) and its labels. */
final class FastestCacheCheckSteps
{
public const PROBE = 'probe';
private const LABEL = 'Leeren-Test an einer Wegwerf-Seite im Cache';
private const LABEL_CACHE_OFF = self::LABEL . ' (Cache ist ausgeschaltet, nicht geprüft)';
/**
* The Profile::$extraSteps of WP Fastest Cache. A switched-off cache is a green step that says so: a skipped step
* would lock the purge on a site that caches nothing.
*
* @param Closure(): (list<string>|null) $probe FastestCacheProbe::run().
* @return Closure(bool): list<CheckStep>
*/
public static function extra(Closure $probe): Closure
{
return ProbeStep::extra(self::PROBE, self::LABEL, self::LABEL_CACHE_OFF, $probe);
}
public static function label(CheckStep $step): string
{
// Reports saved before labels were stored have none.
return $step->label
?? ($step->key === self::PROBE ? self::LABEL : null)
?? CheckStep::genericLabel($step->key, FastestCacheCompatibility::LABEL)
?? $step->key;
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\FastestCache;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\ConstantRequirement;
use LIT\MCP\Compatibility\GlobalRequirement;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
defined('ABSPATH') || exit;
/**
* WP Fastest Cache as a compatibility profile: the two public purge functions FastestCacheListener calls, checked
* against 1.5.5 (wpFastestCache.php). Both only fire an action the WpFastestCache instance hooks in its constructor
* (singleDeleteCache, deleteCache); the probe exercises singleDeleteCache and checks that hook (FastestCacheProbe).
* The cache switch lives in the global $wp_fastest_cache_options (the decoded option WpFastestCache).
*/
final class FastestCacheCompatibility
{
public const LABEL = 'WP Fastest Cache';
public static function profile(): Profile
{
$all = FastestCacheFeature::cases();
return new Profile(
id: 'wpfc',
label: self::LABEL,
tier: Tier::LockOnFailure,
capabilities: $all,
manifests: [new Manifest(
'WP Fastest Cache',
calls: [
new Call('wpfc_clear_post_cache_by_id', 1, [FastestCacheFeature::PurgePost]),
new Call('wpfc_clear_all_cache', 0, [FastestCacheFeature::PurgeAll]),
new Call('WpFastestCache::singleDeleteCache', 3, [FastestCacheFeature::PurgePost]),
new Call('WpFastestCache::getWpContentDir', 1, [FastestCacheFeature::PurgePost]),
],
constants: [new ConstantRequirement('WPFC_MAIN_PATH', $all)],
globals: [
new GlobalRequirement('wp_fastest_cache', $all),
new GlobalRequirement('wp_fastest_cache_options', $all),
]
)],
active: static fn (): bool => defined('WPFC_MAIN_PATH'),
version: static function (): ?string {
if (! defined('WPFC_MAIN_PATH')) {
return null;
}
$header = get_file_data(WPFC_MAIN_PATH . 'wpFastestCache.php', ['Version' => 'Version']);
$version = $header['Version'] ?? '';
return $version === '' ? null : $version;
},
testedVersions: ['1.5.5'],
extraSteps: FastestCacheCheckSteps::extra(static fn (): ?array => (new FastestCacheProbe())->run()),
stepLabel: FastestCacheCheckSteps::label(...)
);
}
/** WPFC caches only while its switch is on (wpFastestCacheStatus in the decoded option, an object or []). */
public static function cacheEnabled(): bool
{
$options = $GLOBALS['wp_fastest_cache_options'] ?? null;
return is_object($options) && ! empty($options->wpFastestCacheStatus);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\FastestCache;
use LIT\MCP\Compatibility\Capability;
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 does to WP Fastest Cache: empty its cache for one post or entirely. */
enum FastestCacheFeature: string implements Capability
{
case PurgePost = 'wpfc-purge-post';
case PurgeAll = 'wpfc-purge-all';
public function id(): string
{
return $this->value;
}
public function label(): string
{
return match ($this) {
self::PurgePost => 'Cache eines Beitrags leeren',
self::PurgeAll => 'Gesamten Cache leeren',
};
}
public function isWrite(): bool
{
return true;
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\FastestCache;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Bootable;
use LIT\MCP\Integrations\Integration;
defined('ABSPATH') || exit;
/**
* WP Fastest Cache: the compatibility profile (FastestCacheListener purges after writes, wired by Plugin), the
* cleanup of probe pages a dead check left behind and the tool lit/cache-clear – a tool group only while WP Fastest
* Cache is loaded.
*/
final class FastestCacheIntegration implements Integration, ProvidesCompatibility, Bootable
{
/** @var callable(): bool */
private $available;
/** @param callable(): bool|null $available Whether WP Fastest Cache is loaded; null = WPFC_MAIN_PATH is defined. */
public function __construct(
?callable $available,
private FastestCachePurger $purger,
private ChangeLog $log
) {
$this->available = $available ?? static fn (): bool => defined('WPFC_MAIN_PATH');
}
public function boot(): void
{
add_action(CompatibilityTab::LOAD_ACTION, [$this, 'removeOrphans']);
}
/**
* lit_mcp_compat_profile_load('wpfc'), before any output: deletes probe pages a dead check left behind
* (FastestCacheProbe::removeOrphans()). A GET without nonce on purpose: only the plugin's own leftovers,
* idempotent.
*/
public function removeOrphans(string $profileId): void
{
if ($profileId === 'wpfc' && $this->isAvailable() && current_user_can('manage_options')) {
FastestCacheProbe::removeOrphans();
}
}
public function id(): string
{
return 'wpfc';
}
public function label(): string
{
return FastestCacheCompatibility::LABEL;
}
public function isAvailable(): bool
{
return ($this->available)();
}
public function abilities(): array
{
return [new CacheClearAbility($this->purger, $this->log)];
}
public function compatibilities(): array
{
return [FastestCacheCompatibility::profile()];
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\FastestCache;
use Closure;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Log;
use Throwable;
defined('ABSPATH') || exit;
/**
* Empties the WP Fastest Cache after a write of this plugin (hook lit_mcp_changed). WPFC purges on its own only on
* post status transitions (and only with its "new post"/"update post" settings on) and term edits: field, term and
* featured-image updates, options, menu locations, Divi design, theme builder and global library modules it never
* sees.
*
* Purge with trailing run: the listener only collects, one purge runs at the end of the request (shutdown). A post
* of a public post type (not attachment) that still exists: that post (wpfc_clear_post_cache_by_id, which also
* clears the home page, feeds, sitemap, neighbours and term archives of the post). More than one post, or anything
* else: the whole cache of this site, once. The whole-cache purge runs at most every THROTTLE seconds (a client
* looping over media updates must not keep the cache empty): a purge inside the lock time schedules exactly one
* trailing purge (TRAILING_HOOK) at its end (FastestCachePurger, shared with lit/cache-clear). Single-post purges are
* never throttled. The release/withdraw entries of the check workflow change nothing on the site and purge nothing;
* the entries of lit/cache-clear purged themselves.
*/
final class FastestCacheListener
{
public const TRAILING_HOOK = FastestCachePurger::TRAILING_HOOK;
public const PURGED_AT = FastestCachePurger::PURGED_AT;
private FastestCachePurger $purger;
private Closure $postIdFor;
/** @var array<int, true> posts changed in this request */
private array $posts = [];
private bool $everything = false;
private bool $queued = false;
/**
* Defaults are the real WPFC and WordPress functions; closures only for tests.
*
* @param Closure(FastestCacheFeature): bool $usable Compatibility::usable().
* @param (Closure(): bool)|null $cacheEnabled WPFC caching switched on.
* @param (Closure(int): void)|null $purgePost wpfc_clear_post_cache_by_id($postId).
* @param (Closure(): void)|null $purgeAll wpfc_clear_all_cache().
* @param (Closure(string, string): ?int)|null $postIdFor Post ID if objectType/objectId name an existing
* post of a public type but attachment.
*/
public function __construct(
Closure $usable,
?Closure $cacheEnabled = null,
?Closure $purgePost = null,
?Closure $purgeAll = null,
?Closure $postIdFor = null
) {
$this->purger = new FastestCachePurger($usable, $cacheEnabled, $purgePost, $purgeAll);
$this->postIdFor = $postIdFor ?? self::publicPostId(...);
}
public static function create(Compatibility $compatibility): self
{
return new self($compatibility->usable(...));
}
public function register(): void
{
add_action(ChangeLog::CHANGED_ACTION, [$this, 'onChange'], 10, 4);
add_action(self::TRAILING_HOOK, [$this, 'trailingPurge']);
}
/** lit_mcp_changed: only collects, see flush(). Never throws. */
public function onChange(string $ability, string $objectType, string $objectId, ?int $logId = null): void
{
try {
$ignored = CompatibilityTab::isReleaseAbility($ability) || $ability === CacheClearAbility::NAME;
if ($ignored || ! $this->purger->cacheEnabled()) {
return;
}
$postId = ($this->postIdFor)($objectType, $objectId);
if ($postId === null) {
$this->everything = true;
} else {
$this->posts[$postId] = true;
}
if (! $this->queued) {
$this->queued = true;
add_action('shutdown', [$this, 'flush']);
}
} catch (Throwable $e) {
Log::failure('WP Fastest Cache purge', $e);
}
}
/** shutdown: the one purge of the request. Never throws: a failed purge must not break the response. */
public function flush(): void
{
$posts = array_keys($this->posts);
$everything = $this->everything;
$this->posts = [];
$this->everything = false;
try {
if ($everything || count($posts) > 1) {
$this->purger->everything();
} elseif ($posts !== []) {
$this->purger->post($posts[0]);
}
} catch (Throwable $e) {
Log::failure('WP Fastest Cache purge', $e);
}
}
/** Cron TRAILING_HOOK: the whole-cache purge a throttled one postponed. Same checks as the first try. */
public function trailingPurge(): void
{
try {
if ($this->purger->cacheEnabled()) {
$this->purger->everything();
}
} catch (Throwable $e) {
Log::failure('WP Fastest Cache trailing purge', $e);
}
}
private static function publicPostId(string $objectType, string $objectId): ?int
{
if ($objectType === 'attachment' || ! ctype_digit($objectId)) {
return null;
}
$type = get_post_type_object($objectType);
if ($type === null || empty($type->public)) {
return null;
}
return get_post((int) $objectId) instanceof \WP_Post ? (int) $objectId : null;
}
}
@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\FastestCache;
use Closure;
use LIT\MCP\Compatibility\ThrowawayPost;
use LIT\MCP\Log;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Purge probe of the WP Fastest Cache check: a marked throwaway page gets a cache file where WPFC keeps the cache of
* that page (cache/all/<path of its address>/index.html, WPFC's own getWpContentDir), WpFastestCache::
* singleDeleteCache – the method behind wpfc_clear_post_cache_by_id, which FastestCacheListener calls – runs for it,
* and the file must be gone (WPFC moves the directory to cache/tmpWpfc, which it empties itself). The probe also
* checks that WPFC still hooks wpfc_clear_post_cache_by_id to that method: without the hook the public function does
* nothing, silently. The page and the cache directory are always removed (ThrowawayPost); entries a dead check left
* behind are deleted by removeOrphans(). The whole-cache purge (wpfc_clear_all_cache) is not probed: it would empty
* the live cache of the site on every check. Runs only from the check route (Compatibility::check).
*
* The purge is narrowed to the page ($to_clear_parents = false; otherwise WPFC also empties the home page, author
* page, sitemap, neighbours and term archives). NOT avoidable: WPFC still empties the two feed caches and purges the
* external caches configured in WPFC (Varnish, Cloudflare) on every call; creating and deleting the page fire the
* hooks of every other plugin (Divi 5 empties caches on insert and delete of a page).
*
* The page stays a draft; WPFC takes the address a draft would have once published. The draft gets a fixed slug
* straight in the database, without hooks: an empty slug would make its address the home page.
*
* shortcut: needs a pretty permalink structure (a "?page_id=" address has no cache directory, nothing to probe then).
*/
final class FastestCacheProbe
{
private const SLUG_PREFIX = 'lit-mcp-check-';
private const PURGE_HOOK = 'wpfc_clear_post_cache_by_id';
private const ABORTED = 'Fastest-Cache-Probe abgebrochen (Details im PHP-Error-Log).';
private const NOT_HOOKED = 'WP Fastest Cache verarbeitet wpfc_clear_post_cache_by_id nicht mehr; das Leeren eines '
. 'Beitrags bliebe wirkungslos.';
private const NOT_PURGED = 'Die Testdatei im Cache blieb nach dem Leeren durch WP Fastest Cache bestehen.';
private const NO_SLUG = 'Die Adresse der Testseite konnte nicht gesetzt werden (Details im PHP-Error-Log).';
private const NOT_WRITTEN = 'Die Testdatei konnte nicht im Cache-Verzeichnis angelegt werden '
. '(Details im PHP-Error-Log).';
private Closure $enabled;
private Closure $hooked;
private Closure $cacheDir;
private Closure $purge;
private Closure $setSlug;
/**
* Defaults are the real WPFC and WordPress calls; closures only for tests.
*
* @param (Closure(): bool)|null $enabled WPFC caching switched on.
* @param (Closure(): bool)|null $hooked WPFC hooks wpfc_clear_post_cache_by_id to singleDeleteCache.
* @param (Closure(int): ?string)|null $cacheDir Cache directory (with trailing slash) of a page; null = none.
* @param (Closure(int): void)|null $purge singleDeleteCache for this page only.
* @param (Closure(int): bool)|null $setSlug Sets the probe slug without hooks; false = not changed.
*/
public function __construct(
?Closure $enabled = null,
?Closure $hooked = null,
?Closure $cacheDir = null,
?Closure $purge = null,
?Closure $setSlug = null
) {
$this->enabled = $enabled ?? FastestCacheCompatibility::cacheEnabled(...);
$this->hooked = $hooked ?? static fn (): bool
=> has_action(self::PURGE_HOOK, [$GLOBALS['wp_fastest_cache'] ?? null, 'singleDeleteCache']) !== false;
$this->cacheDir = $cacheDir ?? self::cacheDirFor(...);
$this->purge = $purge ?? static function (int $id): void {
$GLOBALS['wp_fastest_cache']->singleDeleteCache(false, $id, false);
};
$this->setSlug = $setSlug ?? self::writeSlug(...);
}
/** @return list<string>|null German problems, [] = green; null = nothing to probe (cache off, no usable path). */
public function run(): ?array
{
if (! ($this->enabled)()) {
return null;
}
if (! ($this->hooked)()) {
return [self::NOT_HOOKED];
}
$unchecked = false;
$problems = self::throwaway()->run('page', function (int $id) use (&$unchecked): array {
if (! ($this->setSlug)($id)) {
Log::line('wpfc probe: the database did not set the slug of page ' . $id);
return [self::NO_SLUG];
}
$dir = ($this->cacheDir)($id);
if ($dir === null) {
$unchecked = true;
return [];
}
return $this->exercise($id, $dir);
});
return $unchecked && $problems === [] ? null : $problems;
}
/** @return list<int> IDs of probe pages a dead check left behind, deleted now. */
public static function removeOrphans(): array
{
return self::throwaway()->removeOrphans('page');
}
private static function throwaway(): ThrowawayPost
{
return new ThrowawayPost(
ThrowawayPost::MARKER,
ThrowawayPost::TITLE,
self::ABORTED,
'Die Probe-Seite konnte nicht angelegt werden (Details im PHP-Error-Log).',
'Die Probe-Seite %d konnte nicht gelöscht werden; bitte endgültig löschen (Titel „'
. ThrowawayPost::TITLE . ' …“).',
'wpfc probe'
);
}
/**
* WPFC's cache directory of the probe page, computed the way singleDeleteCache does (the path of the published
* address), or null when it is not exactly <path>/<probe slug>/: a plain "?page_id=" address, a path with "..".
*/
public static function cacheDirFor(int $postId): ?string
{
$post = get_post($postId);
if (! $post instanceof WP_Post) {
return null;
}
$published = clone $post;
$published->post_status = 'publish';
$address = urldecode((string) get_permalink($published));
if (preg_match('#^https?://[^/]+/(.+)$#', $address, $match) !== 1) {
return null;
}
$path = trim($match[1], '/');
$slug = self::SLUG_PREFIX . $postId;
if (str_contains($path, '..') || ($path !== $slug && ! str_ends_with($path, '/' . $slug))) {
return null;
}
return $GLOBALS['wp_fastest_cache']->getWpContentDir('/cache/all/') . $path . '/';
}
// The probe's own file in WPFC's own cache directory, written the way WPFC does it (plain PHP, no WP_Filesystem).
// phpcs:disable WordPress.WP.AlternativeFunctions
/** @return list<string> */
private function exercise(int $id, string $dir): array
{
$file = $dir . 'index.html';
try {
if (! wp_mkdir_p($dir) || file_put_contents($file, '<!-- lit-mcp purge probe -->') === false) {
Log::line('wpfc probe: could not write the test file ' . $file);
return [self::NOT_WRITTEN];
}
($this->purge)($id);
clearstatcache(true, $file);
if (! file_exists($file)) {
return [];
}
Log::line('wpfc probe: singleDeleteCache left the test file ' . $file . ' in place.');
return [self::NOT_PURGED];
} finally {
@unlink($file);
@rmdir($dir);
}
}
// phpcs:enable WordPress.WP.AlternativeFunctions
/** A fixed slug (an empty one would make the page's address the home page) – no hooks, no clean_post_cache. */
private static function writeSlug(int $id): bool
{
global $wpdb;
$rows = $wpdb->update($wpdb->posts, ['post_name' => self::SLUG_PREFIX . $id], ['ID' => $id]);
wp_cache_delete($id, 'posts');
return $rows !== false && (int) $rows > 0;
}
}
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\FastestCache;
use Closure;
use LIT\MCP\Log;
defined('ABSPATH') || exit;
/**
* The two purges of WP Fastest Cache, shared by FastestCacheListener (after every write) and lit/cache-clear: the
* capability check, the 30 s throttle of the whole-cache purge with its one trailing purge (TRAILING_HOOK) and the
* stamp (PURGED_AT). The state lives in the transient and the cron event, so any number of instances share it.
*/
final class FastestCachePurger
{
public const TRAILING_HOOK = 'lit_mcp_wpfc_trailing_purge';
/** Transient with the time of the last whole-cache purge (also in Uninstaller). */
public const PURGED_AT = 'lit_mcp_wpfc_purged_at';
/** Seconds between two whole-cache purges. */
private const THROTTLE = 30;
public const PURGED = 'purged';
public const THROTTLED = 'throttled';
public const LOCKED = 'locked';
private Closure $usable;
private Closure $cacheEnabled;
private Closure $purgePost;
private Closure $purgeAll;
private bool $warned = false;
/**
* Defaults are the real WPFC functions; closures only for tests.
*
* @param Closure(FastestCacheFeature): bool $usable Compatibility::usable().
* @param (Closure(): bool)|null $cacheEnabled WPFC caching switched on.
* @param (Closure(int): void)|null $purgePost wpfc_clear_post_cache_by_id($postId).
* @param (Closure(): void)|null $purgeAll wpfc_clear_all_cache().
*/
public function __construct(
Closure $usable,
?Closure $cacheEnabled = null,
?Closure $purgePost = null,
?Closure $purgeAll = null
) {
$this->usable = $usable;
$this->cacheEnabled = $cacheEnabled ?? FastestCacheCompatibility::cacheEnabled(...);
$this->purgePost = $purgePost ?? static function (int $id): void {
wpfc_clear_post_cache_by_id($id);
};
$this->purgeAll = $purgeAll ?? static function (): void {
wpfc_clear_all_cache();
};
}
public function cacheEnabled(): bool
{
return ($this->cacheEnabled)();
}
/** Whether a whole-cache purge now would only be postponed. */
public function throttled(): bool
{
return self::within($this->lastPurge());
}
private static function within(?int $last): bool
{
return $last !== null && time() - $last < self::THROTTLE;
}
/** Time of the last whole-cache purge; null = none within the lock time. Transients may come back as strings. */
private function lastPurge(): ?int
{
$last = get_transient(self::PURGED_AT);
return is_numeric($last) ? (int) $last : null;
}
/** wpfc_clear_post_cache_by_id for one post; false = locked. Never throttled. */
public function post(int $postId): bool
{
if (! $this->allowed(FastestCacheFeature::PurgePost)) {
return false;
}
($this->purgePost)($postId);
return true;
}
/**
* Whole cache, at most every THROTTLE seconds. Inside the lock time one trailing purge is scheduled (cron events
* are per site: the callback runs on this blog).
*
* @return self::PURGED|self::THROTTLED|self::LOCKED
*/
public function everything(): string
{
if (! $this->allowed(FastestCacheFeature::PurgeAll)) {
return self::LOCKED;
}
$last = $this->lastPurge();
if ($last !== null && self::within($last)) {
if (wp_next_scheduled(self::TRAILING_HOOK) === false) {
wp_schedule_single_event($last + self::THROTTLE, self::TRAILING_HOOK);
}
return self::THROTTLED;
}
($this->purgeAll)();
set_transient(self::PURGED_AT, time(), self::THROTTLE);
return self::PURGED;
}
private function allowed(FastestCacheFeature $feature): bool
{
if (($this->usable)($feature)) {
return true;
}
if (! $this->warned) {
$this->warned = true;
Log::line(sprintf('WP Fastest Cache not purged: %s not usable (see tab Compatibility)', $feature->id()));
}
return false;
}
}
+36 -1
View File
@@ -5,6 +5,10 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations; namespace LIT\MCP\Integrations;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LogicException;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -17,6 +21,7 @@ final class IntegrationRegistry
private ?array $loaded = null; private ?array $loaded = null;
private bool $booted = false; private bool $booted = false;
private ?Compatibility $compatibility = null;
public function __construct(Integration ...$integrations) public function __construct(Integration ...$integrations)
{ {
@@ -63,7 +68,8 @@ final class IntegrationRegistry
} }
/** /**
* Abilities of the available integrations, grouped by integration id (registry order). * Abilities of the available integrations, grouped by integration id (registry order). An integration without
* abilities (Pods) is no group.
* *
* @return array<string, list<Ability>> * @return array<string, list<Ability>>
*/ */
@@ -71,6 +77,9 @@ final class IntegrationRegistry
{ {
$groups = []; $groups = [];
foreach ($this->loaded() as [$integration, $own]) { foreach ($this->loaded() as [$integration, $own]) {
if ($own === []) {
continue;
}
$groups[$integration->id()] = array_merge($groups[$integration->id()] ?? [], $own); $groups[$integration->id()] = array_merge($groups[$integration->id()] ?? [], $own);
} }
@@ -101,6 +110,32 @@ final class IntegrationRegistry
return $labels; return $labels;
} }
/** @return list<Profile> Profiles of every registered integration that provides some, available or not. */
public function compatibilities(): array
{
$profiles = [];
foreach ($this->integrations as $integration) {
if ($integration instanceof ProvidesCompatibility) {
array_push($profiles, ...$integration->compatibilities());
}
}
return $profiles;
}
/**
* The compatibility state of all profiles, one shared instance (its matrices are memoised per request).
*
* @throws LogicException When two profiles or capabilities share an ID (a programming error, PluginTest).
*/
public function compatibility(): Compatibility
{
return $this->compatibility ??= new Compatibility(
$this->compatibilities(),
report: Compatibility::storedReport(...)
);
}
/** @return list<string> Ids of all known integrations, available or not. */ /** @return list<string> Ids of all known integrations, available or not. */
public function ids(): array public function ids(): array
{ {
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Pods;
use Closure;
use LIT\MCP\Compatibility\CheckStep;
use LIT\MCP\Compatibility\ProbeStep;
defined('ABSPATH') || exit;
/** The check step only Pods has (the behaviour probe, PodsProbe) and its labels. */
final class PodsCheckSteps
{
public const PROBE = 'probe';
private const LABEL = 'Lese- und Schreibtest an einem Wegwerf-Eintrag';
private const LABEL_NO_TARGET = self::LABEL . ' (kein Pod mit Textfeld vorhanden, nicht geprüft)';
/**
* The Profile::$extraSteps of Pods: the probe (ProbeStep: skipped on a red manifest, green with a hint when
* there is no pod to probe).
*
* @param Closure(): (list<string>|null) $probe PodsProbe::run().
* @return Closure(bool): list<CheckStep>
*/
public static function extra(Closure $probe): Closure
{
return ProbeStep::extra(self::PROBE, self::LABEL, self::LABEL_NO_TARGET, $probe);
}
public static function label(CheckStep $step): string
{
// Reports saved before labels were stored have none.
return $step->label
?? ($step->key === self::PROBE ? self::LABEL : null)
?? CheckStep::genericLabel($step->key, PodsCompatibility::LABEL)
?? $step->key;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Pods;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\ConstantRequirement;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
defined('ABSPATH') || exit;
/**
* Pods as a compatibility profile: the calls PodsFieldSource makes, checked against Pods 3.3.9.2. Argument counts
* are those PodsFieldSource passes; the check adds a behaviour probe (PodsProbe). A hard problem (missing function
* or constant, Pods below 2.8) locks the affected capabilities; Pods has no per-version release.
*/
final class PodsCompatibility
{
public const LABEL = 'Pods';
public static function profile(): Profile
{
$all = PodsFeature::cases();
return new Profile(
id: 'pods',
label: self::LABEL,
tier: Tier::LockOnFailure,
capabilities: $all,
manifests: [new Manifest(
'Pods',
[
new Call('pods_api', 0, [PodsFeature::SchemaRead]),
new Call('PodsAPI::load_pod', 1, [PodsFeature::SchemaRead]),
new Call('pods', 2, [PodsFeature::FieldRead, PodsFeature::FieldWrite]),
new Call('Pods::field', 1, [PodsFeature::FieldRead]),
new Call('Pods::save', 4, [PodsFeature::FieldWrite]),
new Call('pods_has_permissions', 1, [PodsFeature::FieldPermissions]),
new Call('pods_permission', 1, [PodsFeature::FieldPermissions]),
],
[new ConstantRequirement('PODS_VERSION', $all)]
)],
active: static fn (): bool => defined('PODS_VERSION'),
version: static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
minVersion: '2.8',
testedVersions: ['3.3.9.2'],
extraSteps: PodsCheckSteps::extra(
static fn (): ?array => (new PodsProbe(new PodsFieldSource(static fn (): bool => true)))->run()
),
stepLabel: PodsCheckSteps::label(...)
);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Pods;
use LIT\MCP\Compatibility\Capability;
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 Pods, as far as a Pods update may break it separately. */
enum PodsFeature: string implements Capability
{
case SchemaRead = 'pods-schema-read';
case FieldRead = 'pods-field-read';
case FieldWrite = 'pods-field-write';
case FieldPermissions = 'pods-field-permissions';
public function id(): string
{
return $this->value;
}
public function label(): string
{
return match ($this) {
self::SchemaRead => 'Pods-Schema lesen',
self::FieldRead => 'Pods-Felder lesen',
self::FieldWrite => 'Pods-Felder schreiben',
self::FieldPermissions => 'Pods-Feldrechte prüfen',
};
}
public function isWrite(): bool
{
return $this === self::FieldWrite;
}
}
+40 -12
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Pods; namespace LIT\MCP\Integrations\Pods;
use Closure;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition; use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldSource; use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
use RuntimeException; use RuntimeException;
@@ -21,6 +22,10 @@ defined('ABSPATH') || exit;
* They are mapped like Pods' own view does: hidden → HIDDEN; read_only → READONLY; restricted * They are mapped like Pods' own view does: hidden → HIDDEN; read_only → READONLY; restricted
* and not permitted for the current user → READONLY with read_only_restricted, else HIDDEN. * and not permitted for the current user → READONLY with read_only_restricted, else HIDDEN.
* write() refuses every field that is not writable before anything reaches Pods. * write() refuses every field that is not writable before anything reaches Pods.
*
* Every part is gated by a compatibility capability ($usable, from Compatibility::usable): the schema (SchemaRead,
* else no fields), reading (FieldRead, else null), writing (FieldWrite and FieldRead, else write() throws and the
* fields are READONLY) and the permission check (FieldPermissions, else no field counts as permitted).
*/ */
final class PodsFieldSource implements FieldSource final class PodsFieldSource implements FieldSource
{ {
@@ -45,24 +50,25 @@ final class PodsFieldSource implements FieldSource
/** @var callable(object): ?bool Pods field => null (unrestricted) or whether the current user may use it. */ /** @var callable(object): ?bool Pods field => null (unrestricted) or whether the current user may use it. */
private $permitted; private $permitted;
public function __construct(?callable $loadPod = null, ?callable $podFor = null, ?callable $permitted = null) /** @param Closure(PodsFeature): bool $usable Whether a Pods capability may be used (a locked one is never called). */
{ public function __construct(
private Closure $usable,
?callable $loadPod = null,
?callable $podFor = null,
?callable $permitted = null
) {
$this->loadPod = $loadPod ?? static fn (string $name): mixed => pods_api()->load_pod(['name' => $name]); $this->loadPod = $loadPod ?? static fn (string $name): mixed => pods_api()->load_pod(['name' => $name]);
$this->podFor = $podFor ?? static fn (string $name, int $id): mixed => pods($name, $id); $this->podFor = $podFor ?? static fn (string $name, int $id): mixed => pods($name, $id);
$this->permitted = $permitted ?? static function (object $field): ?bool { $this->permitted = $permitted ?? static function (object $field): ?bool {
if (! function_exists('pods_has_permissions') || ! pods_has_permissions($field)) { // Fail closed: without the Pods permission API no field counts as permitted.
return null; if (! function_exists('pods_has_permissions') || ! function_exists('pods_permission')) {
return false;
} }
return function_exists('pods_permission') && (bool) pods_permission($field); return pods_has_permissions($field) ? (bool) pods_permission($field) : null;
}; };
} }
public static function isAvailable(): bool
{
return function_exists('pods_api') && function_exists('pods');
}
public function id(): string public function id(): string
{ {
return 'pods'; return 'pods';
@@ -70,6 +76,10 @@ final class PodsFieldSource implements FieldSource
public function fieldsFor(string $postType): array public function fieldsFor(string $postType): array
{ {
if (! ($this->usable)(PodsFeature::SchemaRead)) {
return [];
}
$pod = ($this->loadPod)($postType); $pod = ($this->loadPod)($postType);
if (! is_object($pod) || $pod->get_type() !== 'post_type') { if (! is_object($pod) || $pod->get_type() !== 'post_type') {
return []; return [];
@@ -90,6 +100,10 @@ final class PodsFieldSource implements FieldSource
public function read(int $postId, FieldDefinition $field): mixed public function read(int $postId, FieldDefinition $field): mixed
{ {
if (! ($this->usable)(PodsFeature::FieldRead)) {
return null;
}
$pod = ($this->podFor)((string) get_post_type($postId), $postId); $pod = ($this->podFor)((string) get_post_type($postId), $postId);
if (! is_object($pod)) { if (! is_object($pod)) {
return null; return null;
@@ -109,6 +123,10 @@ final class PodsFieldSource implements FieldSource
public function write(int $postId, string $postType, array $values, array $definitions): void public function write(int $postId, string $postType, array $values, array $definitions): void
{ {
if (! $this->canWrite()) {
throw new RuntimeException('Pods fields cannot be written: Pods is locked by the compatibility check.');
}
$data = []; $data = [];
foreach ($values as $key => $value) { foreach ($values as $key => $value) {
$status = isset($definitions[$key]) ? $definitions[$key]->status : null; $status = isset($definitions[$key]) ? $definitions[$key]->status : null;
@@ -179,11 +197,21 @@ final class PodsFieldSource implements FieldSource
return FieldDefinition::HIDDEN; return FieldDefinition::HIDDEN;
} }
if (($this->permitted)($field) === false) { // Fail closed: without the permission check no field counts as permitted.
if (! ($this->usable)(PodsFeature::FieldPermissions) || ($this->permitted)($field) === false) {
return self::flag($field, 'read_only_restricted') ? FieldDefinition::READONLY : FieldDefinition::HIDDEN; return self::flag($field, 'read_only_restricted') ? FieldDefinition::READONLY : FieldDefinition::HIDDEN;
} }
return self::flag($field, 'read_only') ? FieldDefinition::READONLY : $open; $status = self::flag($field, 'read_only') ? FieldDefinition::READONLY : $open;
// Pods writing is locked (it needs FieldWrite and FieldRead) → nothing is writable.
return $status !== FieldDefinition::HIDDEN && ! $this->canWrite() ? FieldDefinition::READONLY : $status;
}
/** A write snapshots the value first (read), so FieldRead is as necessary as FieldWrite. */
private function canWrite(): bool
{
return ($this->usable)(PodsFeature::FieldWrite) && ($this->usable)(PodsFeature::FieldRead);
} }
/** A Pods boolean option (stored as 1/'1'/true, like pods_v_bool()). */ /** A Pods boolean option (stored as 1/'1'/true, like pods_v_bool()). */
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Pods;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Bootable;
use LIT\MCP\Integrations\Integration;
defined('ABSPATH') || exit;
/**
* Pods has no tools of its own (the content tools read its fields through PodsFieldSource); this integration only
* brings the compatibility profile and the cleanup of probe entries a dead check left behind. Without abilities it
* is no tool group.
*/
final class PodsIntegration implements Integration, ProvidesCompatibility, Bootable
{
/** @var callable(): bool */
private $available;
/** @param callable(): bool|null $available Whether Pods is loaded; null = PODS_VERSION is defined. */
public function __construct(?callable $available = null)
{
$this->available = $available ?? static fn (): bool => defined('PODS_VERSION');
}
public function boot(): void
{
add_action(CompatibilityTab::LOAD_ACTION, [$this, 'removeOrphans']);
}
/**
* lit_mcp_compat_profile_load('pods'), before any output: deletes probe entries a dead check left behind
* (PodsProbe::removeOrphans()). A GET without nonce on purpose: only the plugin's own leftovers, idempotent.
*/
public function removeOrphans(string $profileId): void
{
if ($profileId === 'pods' && $this->isAvailable() && current_user_can('manage_options')) {
PodsProbe::removeOrphans();
}
}
public function id(): string
{
return 'pods';
}
public function label(): string
{
return 'Pods';
}
public function isAvailable(): bool
{
return ($this->available)();
}
public function abilities(): array
{
return [];
}
public function compatibilities(): array
{
return [PodsCompatibility::profile()];
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Pods;
use LIT\MCP\Compatibility\ThrowawayPost;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Log;
use Throwable;
defined('ABSPATH') || exit;
/**
* Behaviour probe of the Pods check: through PodsFieldSource (always usable here – the probe is what frees a locked
* profile again) it loads the schema of an existing pod, writes a plain text field of a throwaway draft entry,
* reads it back and compares. The pod and its schema are never touched (no save_pod, so no rewrite flush, no
* schema cache change); only the entry is created and always deleted for good (ThrowawayPost). Entries a dead
* check left behind carry ThrowawayPost::MARKER; removeOrphans() deletes them. Runs only from the check route
* (Compatibility::check).
*
* Target: a pod of a post type Pods created itself (pod arg 'object' empty) comes first, then a foreign type Pods
* only extends (object = the type, e.g. an events plugin), last post and page. Creating and deleting the entry
* fires the hooks of other plugins on that type (unavoidable with wp_insert_post/wp_delete_post), and Pods may
* check other required fields on a partial save than the plain text field the probe writes.
*
* shortcut: relations (output ids) are not probed – they need a second pod entry; add when a Pods update breaks them.
*/
final class PodsProbe
{
public const VALUE = 'litcheck-pods-probe';
private const ABORTED = 'Pods-Probe abgebrochen (Details im PHP-Error-Log).';
private const RANK_OWN = 0;
private const RANK_EXTENDED = 1;
private const RANK_CORE = 2;
/** @var callable(string): mixed */
private $loadPod;
/** @var callable(): list<string> */
private $postTypes;
/**
* @param callable(string): mixed|null $loadPod Pods schema of a post type (raw field types).
* @param callable(): list<string>|null $postTypes Post types to look at; the best-ranked target wins, then the
* first of them.
*/
public function __construct(
private PodsFieldSource $source,
?callable $loadPod = null,
?callable $postTypes = null
) {
$this->loadPod = $loadPod ?? static fn (string $name): mixed => pods_api()->load_pod(['name' => $name]);
$this->postTypes = $postTypes ?? static fn (): array => array_values(array_unique([
...array_values(get_post_types(['_builtin' => false])),
'post',
'page',
]));
}
/** @return list<string>|null German problems, [] = green; null = no pod with a plain text field to probe. */
public function run(): ?array
{
try {
$target = $this->target();
} catch (Throwable $e) {
Log::failure('pods probe', $e);
return [self::ABORTED];
}
if ($target === null) {
return null;
}
[$postType, $field] = $target;
return self::throwaway()->run($postType, fn (int $id): array => $this->exercise($id, $postType, $field));
}
/** @return list<int> IDs of probe entries a dead check left behind, deleted now. */
public static function removeOrphans(): array
{
return self::throwaway()->removeOrphans(array_values(get_post_types()));
}
private static function throwaway(): ThrowawayPost
{
return new ThrowawayPost(
ThrowawayPost::MARKER,
ThrowawayPost::TITLE,
self::ABORTED,
'Der Probe-Eintrag konnte nicht angelegt werden (Details im PHP-Error-Log).',
'Der Probe-Eintrag %d konnte nicht gelöscht werden; bitte endgültig löschen (Titel „'
. ThrowawayPost::TITLE . ' …“).',
'pods probe'
);
}
/** @return array{0: string, 1: FieldDefinition}|null Post type and field of the best-ranked text pod. */
private function target(): ?array
{
$best = null;
$bestRank = PHP_INT_MAX;
foreach (($this->postTypes)() as $postType) {
$pod = ($this->loadPod)($postType);
$field = is_object($pod) ? $this->textField($postType, $pod) : null;
if ($field === null) {
continue;
}
$rank = self::rank($postType, $pod);
if ($rank === self::RANK_OWN) {
return [$postType, $field];
}
if ($rank < $bestRank) {
[$best, $bestRank] = [[$postType, $field], $rank];
}
}
return $best;
}
private static function rank(string $postType, object $pod): int
{
if ((string) $pod->get_arg('object', '') === '') {
return self::RANK_OWN;
}
return in_array($postType, ['post', 'page'], true) ? self::RANK_CORE : self::RANK_EXTENDED;
}
private function textField(string $postType, object $pod): ?FieldDefinition
{
$definitions = [];
foreach ($this->source->fieldsFor($postType) as $definition) {
$definitions[$definition->key] = $definition;
}
foreach ($pod->get_fields() as $field) {
// Raw type 'text' only: email, slug, website … validate or rewrite the value.
$definition = $definitions[(string) $field->get_name()] ?? null;
if (
$definition !== null && (string) $field->get_type() === 'text'
&& $definition->status === FieldDefinition::WRITABLE
&& ! $definition->multiple
) {
return $definition;
}
}
return null;
}
/** @return list<string> */
private function exercise(int $id, string $postType, FieldDefinition $field): array
{
$this->source->write($id, $postType, [$field->key => self::VALUE], [$field->key => $field]);
$read = $this->source->read($id, $field);
if ($read === self::VALUE) {
return [];
}
// The field name and the value stay in the log: the report reaches MCP clients.
Log::line(sprintf(
'pods probe: field %s read back %s instead of the written value.',
$field->key,
is_scalar($read) ? '"' . $read . '"' : gettype($read)
));
return ['Gelesener Wert weicht vom geschriebenen ab.'];
}
}

Some files were not shown because too many files have changed in this diff Show More