feat: Divi release status, store, resolver and gate

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 01:46:05 +02:00
co-authored by Claude Opus 5.5
parent 6b93df94c3
commit cf3c5dfe76
14 changed files with 838 additions and 1 deletions
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use RuntimeException;
use Throwable;
use WP_Error;
defined('ABSPATH') || exit;
/** A write refused because the installed Divi is not released (spec §5.5); AbilityRegistrar returns toError(). */
final class DiviLocked extends RuntimeException
{
public function __construct(public readonly ReleaseStatus $status)
{
parent::__construct(sprintf(
'%s is %s for LIT MCP – write refused',
$status->versionLabel(),
$status->state->value
));
}
public function toError(): WP_Error
{
return $this->status->refusal();
}
/** The lock anywhere in $e's chain of previous exceptions (callers wrap failures in WriteFailed). */
public static function in(Throwable $e): ?self
{
for ($cause = $e; $cause !== null; $cause = $cause->getPrevious()) {
if ($cause instanceof self) {
return $cause;
}
}
return null;
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* Divi versions checked with this plugin version (spec §5.3, source "code"): add a version after
* wp lit-mcp divi-check ran green against it. Any other version needs a release in the Divi tab of each site.
*/
final class DiviReleases
{
public const TESTED = ['5.2.1'];
}
@@ -16,8 +16,10 @@ defined('ABSPATH') || exit;
* The checks of all adapter manifests (spec §4.3), as label => check. Code checks (calls with the argument count
* the adapter passes, constants) and mirror checks decide the release gate; registration checks (post types,
* taxonomies, block types) depend on the request context (stage3 finding 6) and only show in reports.
*
* Not final so the resolver can be tested with a mock.
*/
final class ManifestCheck
class ManifestCheck
{
/** @var array<string, Manifest> */
private array $manifests;
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Log;
use Throwable;
defined('ABSPATH') || exit;
/**
* The release status of this request and the lock every writing Divi path asks (spec §5.5). whileProbing() lets the
* admin check write its throwaway page whatever the status – only that post, only during the call, and only from
* code (there is no parameter or option for it).
*/
final class ReleaseGate
{
private static ?ReleaseStatus $status = null;
/** @var (Closure(): ReleaseStatus)|null */
private static ?Closure $resolver = null;
private static ?int $probe = null;
/** Spec §9: a status that cannot be determined locks writes like an incompatible Divi (and is logged). */
public static function status(): ReleaseStatus
{
if (self::$status !== null) {
return self::$status;
}
try {
self::$status = (self::$resolver ?? static fn (): ReleaseStatus => (new ReleaseResolver())->resolve())();
} catch (Throwable $e) {
Log::failure('divi release status', $e);
self::$status = new ReleaseStatus(
ReleaseState::Incompatible,
'',
null,
['Der Freigabe-Zustand ließ sich nicht ermitteln (Details im PHP-Error-Log).']
);
}
return self::$status;
}
/** @param (Closure(): ReleaseStatus)|null $resolver null = ReleaseResolver; tests set their own. */
public static function useResolver(?Closure $resolver): void
{
self::$resolver = $resolver;
self::$status = null;
self::$probe = null;
}
/** The next status() computes again (after a check, a release or a withdrawal). */
public static function forget(): void
{
self::$status = null;
}
/** @throws DiviLocked */
public static function assertWritable(?int $postId = null): void
{
if (self::$probe !== null && $postId === self::$probe) {
return;
}
$status = self::status();
if (! $status->writable()) {
throw new DiviLocked($status);
}
}
public static function whileProbing(int $postId, callable $write): mixed
{
$previous = self::$probe;
self::$probe = $postId;
try {
return $write();
} finally {
self::$probe = $previous;
}
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Plugin;
defined('ABSPATH') || exit;
/**
* Computes the release status (spec §5.2–§5.4): code checks live (cheap), mirror checks cached in a transient under
* stateKey() – Divi version, plugin version, fingerprints.json and the modification times of the mirrored Divi
* files, so a Divi update or hotfix is checked again at once.
*/
final class ReleaseResolver
{
public const CACHE = 'lit_mcp_divi_state';
/**
* @param (Closure(): ?string)|null $version null = DiviEnvironment::version().
* @param (Closure(string): string)|null $stateKey null = self::stateKey().
*/
public function __construct(
private ?ManifestCheck $check = null,
private ?ReleaseStore $store = null,
private ?Closure $version = null,
private ?Closure $stateKey = null
) {
}
public function resolve(): ReleaseStatus
{
$version = ($this->version ?? DiviEnvironment::version(...))() ?? '';
$check = $this->check ?? new ManifestCheck();
$problems = array_merge(
ManifestCheck::failed($check->codeChecks()),
$this->mirrorProblems($check, $version)
);
if ($problems !== []) {
return new ReleaseStatus(ReleaseState::Incompatible, $version, null, $problems);
}
if (in_array($version, DiviReleases::TESTED, true)) {
return ReleaseStatus::released($version);
}
$entry = ($this->store ?? new ReleaseStore())->find($version, Plugin::VERSION);
return $entry !== null
? new ReleaseStatus(ReleaseState::Released, $version, ReleaseStatus::SOURCE_DATABASE, [], $entry)
: new ReleaseStatus(ReleaseState::Untested, $version);
}
/** What the cached mirror results and a check report are valid for. */
public static function stateKey(string $version, ?string $themeDir = null): string
{
$themeDir ??= DiviEnvironment::themeDir();
$parts = [
$version,
Plugin::VERSION,
is_readable(DiviReference::FILE) ? (string) md5_file(DiviReference::FILE) : '',
];
foreach (DiviAdapters::mirrors() as $mirror) {
$file = $themeDir === null ? '' : $themeDir . '/' . $mirror->file;
$parts[] = $mirror->file . '@' . ($file !== '' && is_file($file) ? (string) filemtime($file) : '-');
}
return hash('sha256', implode('|', $parts));
}
/** @return list<string> */
private function mirrorProblems(ManifestCheck $check, string $version): array
{
$key = ($this->stateKey ?? self::stateKey(...))($version);
$cached = get_transient(self::CACHE);
if (is_array($cached) && ($cached['key'] ?? null) === $key && is_array($cached['problems'] ?? null)) {
return array_values(array_filter($cached['problems'], 'is_string'));
}
$problems = ManifestCheck::failed($check->mirrorChecks());
set_transient(self::CACHE, ['key' => $key, 'problems' => $problems], DAY_IN_SECONDS);
return $problems;
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** Spec §5.2. */
enum ReleaseState: string
{
case Released = 'released';
case Untested = 'untested';
case Incompatible = 'incompatible';
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\Errors;
use LogicException;
use WP_Error;
defined('ABSPATH') || exit;
/** Whether LIT MCP may write with the installed Divi (spec §5.2), with the texts of spec §5.6. */
final class ReleaseStatus
{
public const SOURCE_CODE = 'code';
public const SOURCE_DATABASE = 'database';
private const UNTESTED = 'Achtung: %s ist auf dieser Website noch nicht für LIT MCP freigegeben. Schreibende '
. 'Divi-Werkzeuge sind gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung '
. 'ausführt und die Version freigibt. Es wurde nichts geändert.';
private const UNTESTED_HINT = 'Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben; '
. 'lesende Divi-Werkzeuge funktionieren weiter.';
private const INCOMPATIBLE = '%s hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind '
. 'gesperrt, bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.';
private const INCOMPATIBLE_HINT = 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss; lesende '
. 'Divi-Werkzeuge funktionieren weiter.';
private const NOTICE = 'Achtung: %s ist für LIT MCP %s; Lesen funktioniert, Schreiben ist gesperrt.';
/**
* @param string $version Divi version, '' when unknown (Divi is not the active template).
* @param string|null $source SOURCE_CODE or SOURCE_DATABASE for a released version.
* @param list<string> $problems Failed code and mirror checks (ManifestCheck labels).
* @param array<string, mixed>|null $release The database entry of a SOURCE_DATABASE release.
*/
public function __construct(
public readonly ReleaseState $state,
public readonly string $version,
public readonly ?string $source = null,
public readonly array $problems = [],
public readonly ?array $release = null
) {
}
public static function released(string $version, string $source = self::SOURCE_CODE): self
{
return new self(ReleaseState::Released, $version, $source);
}
public function writable(): bool
{
return $this->state === ReleaseState::Released;
}
public function versionLabel(): string
{
return $this->version === '' ? 'Divi (Version unbekannt)' : 'Divi ' . $this->version;
}
/** The error of a refused write. @throws LogicException For a released status. */
public function refusal(): WP_Error
{
return match ($this->state) {
ReleaseState::Untested => Errors::make(
'divi_untested',
sprintf(self::UNTESTED, $this->versionLabel()),
self::UNTESTED_HINT,
423
),
ReleaseState::Incompatible => Errors::make(
'divi_incompatible',
sprintf(self::INCOMPATIBLE, $this->versionLabel()),
self::INCOMPATIBLE_HINT,
423,
['problems' => $this->problems]
),
ReleaseState::Released => throw new LogicException('a released Divi refuses nothing'),
};
}
/** The warning a reading Divi tool carries while writes are locked. */
public function notice(): string
{
return sprintf(
self::NOTICE,
$this->versionLabel(),
$this->state === ReleaseState::Incompatible ? 'inkompatibel' : 'ungetestet'
);
}
/**
* @return array{state: string, version: string, source: ?string, released_at: ?string, released_by: ?int,
* problems: list<string>}
*/
public function toArray(): array
{
return [
'state' => $this->state->value,
'version' => $this->version,
'source' => $this->source,
'released_at' => is_string($this->release['released_at'] ?? null)
? $this->release['released_at']
: null,
'released_by' => is_int($this->release['user_id'] ?? null) ? $this->release['user_id'] : null,
'problems' => $this->problems,
];
}
}
@@ -0,0 +1,99 @@
<?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
));
}
}