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
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Compatibility;
defined('ABSPATH') || exit;
/** The result of one admin check (spec §6.3); a release needs a fresh green one. */
final class CheckReport
{
/** @param list<CheckStep> $steps */
public function __construct(
public readonly string $id,
public readonly string $createdAt,
public readonly int $userId,
public readonly string $version,
public readonly string $pluginVersion,
public readonly string $stateKey,
public readonly array $steps
) {
}
public function green(): bool
{
if ($this->steps === []) {
return false;
}
foreach ($this->steps as $step) {
if (! $step->green()) {
return false;
}
}
return true;
}
/** 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->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;
}
public function step(string $key): ?CheckStep
{
foreach ($this->steps as $step) {
if ($step->key === $key) {
return $step;
}
}
return null;
}
/**
* @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,
$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, string $versionKey = 'version'): ?self
{
if (! is_array($data)) {
return null;
}
foreach (['id', 'created_at', $versionKey, 'plugin_version', 'state_key'] as $key) {
if (! is_string($data[$key] ?? null)) {
return null;
}
}
if (! is_int($data['user_id'] ?? null) || ! is_array($data['steps'] ?? null)) {
return null;
}
$steps = [];
foreach ($data['steps'] as $step) {
$parsed = CheckStep::fromArray($step);
if ($parsed === null) {
return null;
}
$steps[] = $parsed;
}
return new self(
$data['id'],
$data['created_at'],
$data['user_id'],
$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';
}
}