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:
co-authored by
Claude Opus 5.5
parent
c85131603b
commit
7d6a97a54e
@@ -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
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user