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>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 12:51:03 +01:00
co-authored by Claude Opus 5.5
parent c85131603b
commit 7d6a97a54e
48 changed files with 1469 additions and 647 deletions
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
@@ -14,7 +14,7 @@ final class CheckReport
public readonly string $id,
public readonly string $createdAt,
public readonly int $userId,
public readonly string $diviVersion,
public readonly string $version,
public readonly string $pluginVersion,
public readonly string $stateKey,
public readonly array $steps
@@ -35,11 +35,16 @@ final class CheckReport
return true;
}
/** Green and made for exactly this Divi version, plugin version and Divi files (ReleaseResolver::stateKey()). */
public function freshFor(string $diviVersion, string $pluginVersion, string $stateKey): bool
/** Green and made for exactly this version, plugin version and state (Profile::$stateKey). */
public function freshFor(string $version, string $pluginVersion, string $stateKey): bool
{
return $this->green()
&& $this->diviVersion === $diviVersion
return $this->green() && $this->currentFor($version, $pluginVersion, $stateKey);
}
/** 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->stateKey === $stateKey;
}
@@ -55,26 +60,29 @@ final class CheckReport
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 [
'id' => $this->id,
'created_at' => $this->createdAt,
'user_id' => $this->userId,
'divi_version' => $this->diviVersion,
$versionKey => $this->version,
'plugin_version' => $this->pluginVersion,
'state_key' => $this->stateKey,
'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)) {
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)) {
return null;
}
@@ -96,7 +104,7 @@ final class CheckReport
$data['id'],
$data['created_at'],
$data['user_id'],
$data['divi_version'],
$data[$versionKey],
$data['plugin_version'],
$data['state_key'],
$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
);
}
}
+81 -14
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace LIT\MCP\Compatibility;
use Closure;
use LIT\MCP\Log;
use LIT\MCP\Plugin;
use LogicException;
use Throwable;
@@ -21,25 +23,31 @@ final class Compatibility
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: its manifests
* without signature checks.
* @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)
public function __construct(private array $profiles, ?Closure $check = null, ?Closure $report = null)
{
$this->check = $check ?? static fn (Profile $profile): ManifestCheck => new ManifestCheck(
$profile->manifests,
signatures: false
);
$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);
@@ -77,14 +85,18 @@ final class Compatibility
public function active(Profile $profile): bool
{
return $profile->active === null || self::attempt($profile->active) === true;
return $this->actives[$profile->id] ??= $profile->active === null
|| self::attempt($profile->active, $profile) === true;
}
public function version(Profile $profile): ?string
{
$version = $profile->version === null ? null : self::attempt($profile->version);
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 is_string($version) ? $version : null;
return $this->versions[$profile->id];
}
public function matrix(Profile $profile): CompatibilityMatrix
@@ -104,11 +116,19 @@ final class Compatibility
static fn (Problem $problem): string => $problem->label,
$this->checkOf($profile)->problems()
));
} catch (Throwable) {
} 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);
@@ -161,17 +181,62 @@ final class Compatibility
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) {
} 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) {
@@ -202,11 +267,13 @@ final class Compatibility
}
/** The closure's result, null when it throws. */
private static function attempt(Closure $closure): mixed
private static function attempt(Closure $closure, Profile $profile): mixed
{
try {
return $closure();
} catch (Throwable) {
} 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).'];
}
}
}
+23 -1
View File
@@ -12,6 +12,7 @@ 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
{
@@ -22,6 +23,14 @@ final class Profile
* @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 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(
@@ -33,7 +42,12 @@ final class Profile
public readonly ?Closure $active = null,
public readonly ?Closure $version = null,
public readonly ?string $minVersion = null,
public readonly ?Closure $matrix = 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 string $reportVersionKey = 'version'
) {
if ($tier === Tier::ReleasePerVersion && $matrix === null) {
throw new LogicException(sprintf('profile %s: a ReleasePerVersion profile needs a matrix closure', $id));
@@ -45,4 +59,12 @@ final class Profile
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);
}
}
+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';
}
}
+15 -8
View File
@@ -10,16 +10,18 @@ use LIT\MCP\Admin\AdminTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Compatibility\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Compatibility\ReleaseStore;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin;
@@ -159,7 +161,7 @@ final class DiviTab implements AdminTab
return;
}
($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id);
$this->releases()->add($version, Plugin::VERSION, get_current_user_id(), $report->id);
ReleaseGate::invalidate();
$this->log?->record(
'lit/divi-release',
@@ -177,7 +179,7 @@ final class DiviTab implements AdminTab
check_admin_referer(self::WITHDRAW_ACTION);
$version = $this->version();
if (! ($this->releases ?? new ReleaseStore())->withdraw($version, Plugin::VERSION)) {
if (! $this->releases()->withdraw($version, Plugin::VERSION)) {
$action->redirect(self::SLUG, 'divi_withdraw_failed');
return;
@@ -514,7 +516,7 @@ final class DiviTab implements AdminTab
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->version,
$report->pluginVersion,
date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt))
))
@@ -530,7 +532,7 @@ final class DiviTab implements AdminTab
. ' aria-hidden="true"></span><span class="screen-reader-text">%2$s:</span> %3$s',
esc_attr($icon),
esc_html($word),
esc_html($step->label())
esc_html(DiviCheckSteps::label($step))
);
if ($step->skipped) {
echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')';
@@ -569,8 +571,13 @@ final class DiviTab implements AdminTab
return ($this->stateKey ?? static fn (string $v): string => ReleaseResolver::stateKey($v))($version);
}
private function releases(): ReleaseStore
{
return $this->releases ?? new ReleaseStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY);
}
private function reports(): CheckReportStore
{
return $this->reports ?? new CheckReportStore();
return $this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY);
}
}
@@ -7,10 +7,12 @@ namespace LIT\MCP\Integrations\Divi\Cli;
use Closure;
use LIT\MCP\Compatibility\LineDiff;
use LIT\MCP\Compatibility\SourceFingerprint;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Compatibility\CheckReport;
use LIT\MCP\Compatibility\CheckReportStore;
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\DiviEnvironment;
@@ -109,10 +111,10 @@ final class DiviCheckCommand
}
$report = ($this->check)()->run(get_current_user_id());
($this->reports ?? new CheckReportStore())->save($report);
($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->diviVersion ?: '?', $report->pluginVersion));
($this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY))->save($report);
($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->version ?: '?', $report->pluginVersion));
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) {
($this->out)(' ' . $problem);
}
+11 -3
View File
@@ -9,6 +9,8 @@ use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Admin\SettingsPage;
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\Divi\Admin\DiviTab;
@@ -73,7 +75,6 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
@@ -190,7 +191,7 @@ final class DiviIntegration implements Integration, Bootable, ProvidesCompatibil
public function compatibilities(): array
{
return [DiviProfile::profile()];
return [$this->profile()];
}
/**
@@ -261,7 +262,8 @@ final class DiviIntegration implements Integration, Bootable, ProvidesCompatibil
// Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no
// Tools-tab gate: the check tests Divi, not this site's tool switches.
$check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->shortForm(null));
$profile = $this->profile();
$check = static fn (): CompatibilityCheck => CompatibilityCheck::forProfile($profile);
(new DiviCheckRoute($check))->register();
DiviCheckCommand::register($check);
@@ -429,6 +431,12 @@ final class DiviIntegration implements Integration, Bootable, ProvidesCompatibil
}
/** @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
{
return new DiviContentComposer($this->schema, $this->composition(), new DiviSaver(), $this->design, $writable);
@@ -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,93 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Compatibility\ManifestCheck;
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(
DiviManifestCheck::create(),
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()];
}
}
}
@@ -6,6 +6,8 @@ namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Compatibility\CheckReportStore;
use LIT\MCP\Compatibility\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Admin\DiviTab;
use LIT\MCP\Log;
use Throwable;
@@ -62,7 +64,8 @@ final class DiviCheckRoute
if (self::lock()) {
try {
$report = ($this->check)()->run(get_current_user_id());
($this->reports ?? new CheckReportStore())->save($report);
$reports = $this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY);
$reports->save($report);
ReleaseGate::invalidate();
$notice = 'divi_checked';
} catch (Throwable $e) {
@@ -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;
}
}
+32 -7
View File
@@ -4,24 +4,49 @@ 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 static function profile(): Profile
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(
'divi',
'Divi',
Tier::ReleasePerVersion,
DiviFeature::cases(),
array_values(DiviAdapters::manifests()),
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
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(...),
reportVersionKey: self::REPORT_VERSION_KEY
);
}
}
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Release;
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;
@@ -53,7 +54,8 @@ final class ReleaseResolver
if (in_array($version, DiviReleases::TESTED, true)) {
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) {
return ReleaseStatus::of(
$version,
@@ -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
));
}
}
+4 -1
View File
@@ -8,6 +8,7 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use LIT\MCP\Log;
use Throwable;
use WP_Error;
use WP_Post;
@@ -101,7 +102,9 @@ class WriteProbe
try {
$problems = $this->exercise($id);
} catch (Throwable $e) {
$problems = ['Schreibtest abgebrochen: ' . $e->getMessage()];
// The report reaches MCP clients through the lock reason; the exception text stays in the log.
Log::failure('divi write test', $e);
$problems = ['Schreibtest abgebrochen (Details im PHP-Error-Log).'];
} finally {
if (! self::delete($id)) {
$problems[] = sprintf(
+4 -1
View File
@@ -130,7 +130,10 @@ final class IntegrationRegistry
*/
public function compatibility(): Compatibility
{
return $this->compatibility ??= new Compatibility($this->compatibilities());
return $this->compatibility ??= new Compatibility(
$this->compatibilities(),
report: Compatibility::storedReport(...)
);
}
/** @return list<string> Ids of all known integrations, available or not. */
+9 -8
View File
@@ -24,11 +24,11 @@ final class PodsCompatibility
$all = PodsFeature::cases();
return new Profile(
'pods',
'Pods',
Tier::LockOnFailure,
$all,
[new Manifest(
id: 'pods',
label: 'Pods',
tier: Tier::LockOnFailure,
capabilities: $all,
manifests: [new Manifest(
'Pods',
[
new Call('pods_api', 0, [PodsFeature::SchemaRead]),
@@ -41,9 +41,10 @@ final class PodsCompatibility
],
[new ConstantRequirement('PODS_VERSION', $all)]
)],
static fn (): bool => defined('PODS_VERSION'),
static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
'2.8'
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']
);
}
}
+1 -1
View File
@@ -231,7 +231,7 @@ final class Plugin
// The sources exist before the registry, so Pods gets its own Compatibility here – a deliberate, cheap
// duplicate of the registry's (a few function_exists checks, memoised per request).
$sources = self::schemaSources(
new Compatibility([PodsCompatibility::profile()]),
new Compatibility([PodsCompatibility::profile()], report: Compatibility::storedReport(...)),
defined('PODS_VERSION')
);
+6 -3
View File
@@ -17,9 +17,11 @@ defined('ABSPATH') || exit;
* so the Composer/Jetpack autoloader is not guaranteed to have run. The option/hook/table
* names below are therefore duplicated from Access::OPTION, Abilities\ExposurePolicy::OPTION,
* Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\
* ReleaseStore::OPTION, Integrations\Divi\Release\CheckReportStore::OPTION, Integrations\
* Divi\Release\DiviCheckRoute::LOCK, Integrations\Divi\Design\DesignLock::option(),
* Admin\ConnectionStatus::ENDPOINT_TRANSIENT, Integrations\Divi\Release\ReleaseResolver::CACHE,
* lit_mcp_<profile>_releases (Compatibility\ReleaseStore), lit_mcp_<profile>_check_report
* (Compatibility\CheckReportStore) and lit_mcp_<profile>_check_lock (Divi\Release\DiviCheckRoute::LOCK) for the
* profiles divi and pods (an explicit list: a LIKE pattern could hit foreign options),
* Integrations\Divi\Design\DesignLock::option(), Admin\ConnectionStatus::ENDPOINT_TRANSIENT,
* Integrations\Divi\Release\ReleaseResolver::CACHE,
* ChangeLog\ChangeLogCleanup::HOOK, ChangeLog\ChangeLog::TABLE, Admin\LogTab::PER_PAGE_OPTION and
* Updater\RecheckOnInstall::OPTION on purpose;
* tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in sync with
@@ -35,6 +37,7 @@ final class Uninstaller
private const OPTIONS = [
'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version',
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock',
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
'lit_mcp_lock_divi_options', 'lit_mcp_lock_variables', 'lit_mcp_lock_presets', 'lit_mcp_lock_custom_css',