feat: admin check steps with throwaway write test and reports

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 02:21:09 +02:00
co-authored by Claude Opus 5.5
parent c6a1996be6
commit 47040aa43d
14 changed files with 1026 additions and 26 deletions
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
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 $diviVersion,
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 Divi version, plugin version and Divi files (ReleaseResolver::stateKey()). */
public function freshFor(string $diviVersion, string $pluginVersion, string $stateKey): bool
{
return $this->green()
&& $this->diviVersion === $diviVersion
&& $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;
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'id' => $this->id,
'created_at' => $this->createdAt,
'user_id' => $this->userId,
'divi_version' => $this->diviVersion,
'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
{
if (! is_array($data)) {
return null;
}
foreach (['id', 'created_at', 'divi_version', '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['divi_version'],
$data['plugin_version'],
$data['state_key'],
$steps
);
}
}
@@ -0,0 +1,23 @@
<?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);
}
}
@@ -0,0 +1,62 @@
<?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
);
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Plugin;
use Throwable;
defined('ABSPATH') || exit;
/**
* The admin check (spec §6.2) – also wp lit-mcp divi-check: manifests (code and registrations), fingerprints,
* module catalog, format round trip and the write test. A step that throws is a red step; the write test is skipped
* only when Divi functions are missing (it would fail anyway), not when only fingerprints changed – so
* --update-reference can see whether everything else still works. Not final so the route and the command can be
* tested with a mock.
*/
class CompatibilityCheck
{
/**
* @param Closure(): list<string> $catalog Short-form problems against Divi's live catalog.
* @param Closure(): string $version Installed Divi version ('' = unknown).
* @param Closure(string): string $stateKey ReleaseResolver::stateKey().
*/
public function __construct(
private ManifestCheck $manifests,
private Closure $catalog,
private FormatCheck $format,
private WriteProbe $probe,
private Closure $version,
private Closure $stateKey
) {
}
public static function create(ContentComposer $composer): self
{
return new self(
new ManifestCheck(),
static fn (): array => DiviModules::isAvailable()
? ShortformCheck::problems((new DiviModules())->catalog())
: ['Divi 5 ist in dieser Anfrage nicht geladen.'],
new FormatCheck(),
new WriteProbe($composer),
static fn (): string => DiviEnvironment::version() ?? '',
static fn (string $version): string => ReleaseResolver::stateKey($version)
);
}
public function run(int $userId): CheckReport
{
$manifest = self::guarded(fn (): array => ManifestCheck::failed(
array_merge($this->manifests->codeChecks(), $this->manifests->registrationChecks())
));
$steps = [
new CheckStep(CheckStep::MANIFEST, $manifest),
new CheckStep(CheckStep::MIRRORS, self::guarded(
fn (): array => ManifestCheck::failed($this->manifests->mirrorChecks())
)),
new CheckStep(CheckStep::CATALOG, self::guarded($this->catalog)),
new CheckStep(CheckStep::FORMAT, self::guarded(fn (): array => $this->format->problems())),
$manifest === []
? new CheckStep(CheckStep::WRITE, self::guarded(fn (): array => $this->probe->run()))
: new CheckStep(CheckStep::WRITE, [], true),
];
$version = ($this->version)();
return new CheckReport(
wp_generate_uuid4(),
gmdate('c'),
$userId,
$version,
Plugin::VERSION,
($this->stateKey)($version),
$steps
);
}
/** @return list<string> */
private static function guarded(Closure $step): array
{
try {
return array_values(array_filter((array) $step(), 'is_string'));
} catch (Throwable $e) {
return ['Prüfschritt abgebrochen: ' . $e->getMessage()];
}
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
defined('ABSPATH') || exit;
/**
* Spec §6.2 step 3: a builder-made Divi 5 page (reference-page.html, from the converted fixture) parses and
* serializes byte-identically, and WordPress' parse_blocks() sees the same block names and nesting as the plugin's
* parser. Nothing is written. Not final so CompatibilityCheck can be tested with a mock.
*/
class FormatCheck
{
public const REFERENCE = __DIR__ . '/reference-page.html';
/** @param (Closure(string): array<int, mixed>)|null $wpBlocks null = parse_blocks(). */
public function __construct(private ?string $file = null, private ?Closure $wpBlocks = null)
{
}
/** @return list<string> */
public function problems(): array
{
$file = $this->file ?? self::REFERENCE;
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin file.
$content = is_file($file) ? file_get_contents($file) : false;
if (! is_string($content) || $content === '') {
return ['Der Referenzinhalt der Prüfung fehlt im Plugin.'];
}
$document = (new Parser())->parse($content);
$problems = [];
if (Serializer::document($document) !== $content) {
$problems[] = 'Parser und Serializer geben den Referenzinhalt nicht byte-gleich zurück.';
}
$wordPress = ($this->wpBlocks ?? static fn (string $markup): array => parse_blocks($markup))($content);
if (self::structure($document->children()) !== self::wpStructure($wordPress)) {
$problems[] = 'WordPress liest eine andere Blockstruktur als LIT MCP.';
}
return $problems;
}
/**
* @param list<Block> $blocks
* @return list<array{0: string, 1: list<mixed>}>
*/
private static function structure(array $blocks): array
{
return array_map(
static fn (Block $block): array => [$block->name(), self::structure($block->children())],
$blocks
);
}
/**
* @param array<int, mixed> $blocks parse_blocks() result; freeform parts (blockName null) are left out.
* @return list<array{0: string, 1: list<mixed>}>
*/
private static function wpStructure(array $blocks): array
{
$structure = [];
foreach ($blocks as $block) {
if (is_array($block) && is_string($block['blockName'] ?? null)) {
$inner = is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : [];
$structure[] = [$block['blockName'], self::wpStructure($inner)];
}
}
return $structure;
}
}
@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
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 Throwable;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Spec §6.2 step 4: a throwaway draft page written through the normal Divi write path (ContentComposer → Composer
* → Serializer → DiviSaver, with the release lock opened for this page only), checked (content, builder flags,
* Divi renders every module without a PHP warning from Divi's files) and always deleted for good – in finally,
* not to the trash, with its revisions and meta. Pages a dead check left behind carry MARKER; removeOrphans()
* deletes them. No change log entry. Not final so CompatibilityCheck can be tested with a mock.
*/
class WriteProbe
{
public const MARKER = '_lit_mcp_check_page';
public const TITLE = '[LIT MCP Prüfung]';
/** Everyday modules without media, each with a text to find in the rendered page. */
public const SECTIONS = [[
'type' => 'section',
'label' => 'LIT MCP Prüfung',
'rows' => [
[
'columns' => '1_2,1_2',
'modules' => [
[
['type' => 'heading', 'text' => 'litcheck-heading', 'level' => 'h2'],
['type' => 'text', 'content' => '<p>litcheck-text</p>'],
['type' => 'button', 'text' => 'litcheck-button', 'url' => '/'],
],
[
['type' => 'blurb', 'title' => 'litcheck-blurb', 'content' => '<p>litcheck-blurb-text</p>'],
[
'type' => 'cta',
'title' => 'litcheck-cta',
'button_text' => 'litcheck-cta-button',
'button_url' => '/',
],
['type' => 'divider'],
],
],
],
[
'columns' => '4_4',
'modules' => [
['type' => 'toggle', 'title' => 'litcheck-toggle', 'content' => '<p>litcheck-toggle-text</p>'],
['type' => 'accordion', 'items' => [['title' => 'litcheck-accordion', 'content' => '<p>a</p>']]],
['type' => 'tabs', 'items' => [['title' => 'litcheck-tab', 'content' => '<p>t</p>']]],
['type' => 'icon-list', 'items' => [['text' => 'litcheck-icon-list']]],
],
],
],
]];
public const TEXTS = [
'litcheck-heading', 'litcheck-text', 'litcheck-button', 'litcheck-blurb', 'litcheck-cta', 'litcheck-toggle',
'litcheck-accordion', 'litcheck-tab', 'litcheck-icon-list',
];
/** Only pages older than this are orphans – a running check's page is younger. */
private const ORPHAN_AGE = '10 minutes ago';
/**
* @param string|null $diviDir PHP warnings from files below it fail the check; null = DiviEnvironment::themeDir().
*/
public function __construct(
private ContentComposer $composer,
private ?PageRenderer $renderer = null,
private ?string $diviDir = null
) {
}
/** @return list<string> German problems; [] = green. */
public function run(): array
{
$id = wp_insert_post([
'post_type' => 'page',
'post_status' => 'draft',
'post_title' => self::TITLE . ' ' . gmdate('Y-m-d H:i'),
'post_content' => '',
'meta_input' => [self::MARKER => '1'],
], true);
if ($id instanceof WP_Error || (int) $id <= 0) {
return ['Die Testseite konnte nicht angelegt werden: '
. ($id instanceof WP_Error ? $id->get_error_message() : 'keine ID')];
}
$id = (int) $id;
try {
$problems = $this->exercise($id);
} catch (Throwable $e) {
$problems = ['Schreibtest abgebrochen: ' . $e->getMessage()];
} finally {
if (! self::delete($id)) {
$problems[] = sprintf(
'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.',
$id
);
}
}
return $problems;
}
/** @return list<int> IDs of check pages a dead check left behind, deleted now. */
public static function removeOrphans(): array
{
$ids = get_posts([
'post_type' => 'page',
'post_status' => 'any',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked pages at most.
'meta_key' => self::MARKER,
'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']],
'fields' => 'ids',
'posts_per_page' => 20,
'no_found_rows' => true,
'suppress_filters' => true,
]);
$removed = [];
foreach (is_array($ids) ? $ids : [] as $id) {
if (self::delete((int) $id)) {
$removed[] = (int) $id;
}
}
return $removed;
}
/** @return list<string> */
private function exercise(int $id): array
{
$written = ReleaseGate::whileProbing(
$id,
fn (): array|WP_Error => $this->composer->write($id, 'page', self::SECTIONS)
);
if ($written instanceof WP_Error) {
return ['Die Kurzform der Testseite wurde abgelehnt: ' . Errors::clientMessage($written)];
}
$post = get_post($id);
if (! $post instanceof WP_Post) {
return ['Die Testseite ist nach dem Schreiben nicht lesbar.'];
}
$problems = [];
if (! str_contains((string) $post->post_content, '<!-- wp:divi/')) {
$problems[] = 'Es wurde kein Divi-Inhalt gespeichert.';
}
foreach (DiviPages::FLAGS as $key => $value) {
if (get_post_meta($id, $key, true) !== $value) {
$problems[] = sprintf('Builder-Kennzeichen %s fehlt.', $key);
}
}
[$html, $warnings] = $this->render($post);
foreach (self::TEXTS as $text) {
if (! str_contains($html, $text)) {
$problems[] = sprintf('„%s“ fehlt in der gerenderten Seite.', $text);
}
}
foreach ($warnings as $warning) {
$problems[] = 'PHP-Warnung aus Divi beim Rendern: ' . $warning;
}
return $problems;
}
/** @return array{0: string, 1: list<string>} The rendered content and PHP warnings raised in Divi's files. */
private function render(WP_Post $post): array
{
$dir = $this->diviDir ?? DiviEnvironment::themeDir();
$warnings = [];
set_error_handler(
static function (
int $level,
string $message,
string $file = '',
int $line = 0
) use (
$dir,
&$warnings
): bool {
if ($dir !== null && str_starts_with($file, $dir)) {
$warnings[] = sprintf('%s (%s:%d)', $message, basename($file), $line);
}
return false;
},
E_WARNING | E_USER_WARNING
);
try {
$html = ($this->renderer ?? new PageRenderer())->render($post);
} finally {
restore_error_handler();
}
return [$html, $warnings];
}
private static function delete(int $id): bool
{
return wp_delete_post($id, true) instanceof WP_Post;
}
}
File diff suppressed because one or more lines are too long