feat: manifest checks, Divi compatibility from manifests, reference fingerprints

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 01:37:51 +02:00
co-authored by Claude Opus 5.5
parent 1a215fd7bf
commit 6b93df94c3
15 changed files with 1295 additions and 97 deletions
+9 -51
View File
@@ -4,21 +4,14 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use Throwable;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
defined('ABSPATH') || exit;
/**
* Live check of the Divi internals LIT MCP relies on (decisions note §5, E15): classes,
* functions, the library post type and taxonomies, the instance block type. lit-site-status shows
* the failed checks, so a Divi update that moves one of them is visible before a tool reports
* "unsupported".
* Live check of the Divi internals LIT MCP relies on (spec §4.3): every check of every adapter manifest – calls
* with their argument count, constants, registrations and the fingerprints of the mirrors. lit-site-status shows
* the failed checks, so a Divi update that moves one of them is visible before a tool reports "unsupported".
*/
final class DiviCompatibility
{
@@ -30,49 +23,14 @@ final class DiviCompatibility
/** @return list<string> Labels of the checks that failed or threw. */
public function problems(): array
{
$problems = [];
foreach ($this->checks ?? self::defaults() as $label => $check) {
try {
$ok = (bool) $check();
} catch (Throwable) {
$ok = false;
}
if (! $ok) {
$problems[] = (string) $label;
}
}
return $problems;
return ManifestCheck::failed($this->checks ?? self::defaults());
}
/** @return array<string, callable(): bool> */
public static function defaults(): array
public static function defaults(?ManifestCheck $check = null): array
{
return [
'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(),
'DynamicContentOptions (Dynamic Content)' =>
static fn (): bool => class_exists(DynamicContentMap::REGISTRY),
'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'),
'Post-Typ et_pb_layout (Bibliothek)' =>
static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE),
'Taxonomien scope, layout_type, module_width (Bibliothek)' =>
static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [],
'GlobalLayout::is_global_layout_template (globale Module)' =>
static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'),
'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'),
'Block-Typ divi/global-layout (Instanzen globaler Module)' =>
static fn (): bool => DiviModules::knowsBlock(GlobalInstance::BLOCK),
'Taxonomien layout_category, layout_tag (Bibliothek)' =>
static fn (): bool => taxonomy_exists('layout_category') && taxonomy_exists('layout_tag'),
// Includes et_theme_builder_clear_wp_cache (Theme Builder cache of every site-wide clear, review M-2).
'Theme Builder: Funktionen et_theme_builder_* und et_()' => static fn (): bool => array_filter(
DiviThemeBuilder::manifest()->calls,
static fn (Call $call): bool => $call->className() === null && ! function_exists($call->member())
) === [],
'Theme Builder: Post-Typen et_template und Layouts' => static fn (): bool => array_filter(
DiviThemeBuilder::postTypes(),
static fn (string $type): bool => ! post_type_exists($type)
) === [],
];
$check ??= new ManifestCheck();
return array_merge($check->codeChecks(), $check->registrationChecks(), $check->mirrorChecks());
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* The installed Divi theme: version (the parent's for a child theme) and directory; null when Divi is not the
* template.
*/
final class DiviEnvironment
{
public const TEMPLATE = 'Divi';
public static function version(): ?string
{
$theme = wp_get_theme();
if ($theme->get_template() !== self::TEMPLATE) {
return null;
}
$parent = $theme->parent();
return (string) ($parent ? $parent->get('Version') : $theme->get('Version'));
}
public static function themeDir(): ?string
{
return wp_get_theme()->get_template() === self::TEMPLATE ? (string) get_template_directory() : null;
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* The expected fingerprints of the mirrors (fingerprints.json, shipped with the plugin) and their reference sources
* (tests/Fixtures/divi/reference/<id>.txt, development only – not in the plugin package). Written by
* wp lit-mcp divi-check --update-reference.
*/
final class DiviReference
{
public const FILE = __DIR__ . '/fingerprints.json';
/** @return array<string, string> Mirror ID => sha256; [] when the file is missing or broken. */
public static function expected(?string $file = null): array
{
$file ??= self::FILE;
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin file.
$raw = is_file($file) && is_readable($file) ? file_get_contents($file) : false;
$data = is_string($raw) ? json_decode($raw, true) : null;
if (! is_array($data)) {
return [];
}
$expected = [];
foreach ($data as $id => $hash) {
if (is_string($id) && is_string($hash)) {
$expected[$id] = $hash;
}
}
ksort($expected);
return $expected;
}
/**
* @param array<string, string> $hashes Mirror ID => sha256.
* @throws RuntimeException
*/
public static function writeExpected(array $hashes, ?string $file = null): void
{
ksort($hashes);
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP, no WordPress.
$json = json_encode($hashes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- dev fixture.
if (! is_string($json) || file_put_contents($file ?? self::FILE, $json . "\n") === false) {
throw new RuntimeException('fingerprints.json not written');
}
}
/** tests/Fixtures/divi/reference of a development checkout; null in an installed plugin. */
public static function sourceDir(): ?string
{
$dir = dirname(__DIR__, 4) . '/tests/Fixtures/divi/reference';
return is_dir($dir) ? $dir : null;
}
public static function readSource(string $id, string $dir): ?string
{
$file = $dir . '/' . $id . '.txt';
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local dev fixture.
$source = is_file($file) ? file_get_contents($file) : false;
return is_string($source) ? $source : null;
}
/** @throws RuntimeException */
public static function writeSource(string $id, string $source, string $dir): void
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- dev fixture.
if (file_put_contents($dir . '/' . $id . '.txt', $source . "\n") === false) {
throw new RuntimeException(sprintf('reference source %s not written', $id));
}
}
/**
* Fingerprints of every mirror in the installed Divi.
*
* @return array<string, SourceFingerprint>
* @throws RuntimeException When a mirrored function cannot be read.
*/
public static function current(string $themeDir): array
{
$prints = [];
foreach (DiviAdapters::mirrors() as $mirror) {
$file = $themeDir . '/' . $mirror->file;
$prints[$mirror->id] = SourceFingerprint::of($file, $mirror->function, $mirror->class);
}
return $prints;
}
}
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use ReflectionFunction;
use ReflectionMethod;
use Throwable;
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.
*/
final class ManifestCheck
{
/** @var array<string, Manifest> */
private array $manifests;
/** @var Closure(Mirror): ?string */
private Closure $current;
/** @var Closure(string): bool */
private Closure $knowsBlock;
/**
* @param array<string, Manifest>|null $manifests null = DiviAdapters::manifests().
* @param array<string, string>|null $expected Mirror ID => sha256; null = DiviReference::expected().
* @param (Closure(Mirror): ?string)|null $current Hash of the installed mirror, null = unreadable;
* null = SourceFingerprint in the Divi theme directory.
* @param (Closure(string): bool)|null $knowsBlock null = DiviModules::knowsBlock().
*/
public function __construct(
?array $manifests = null,
private ?array $expected = null,
?Closure $current = null,
?Closure $knowsBlock = null
) {
$this->manifests = $manifests ?? DiviAdapters::manifests();
$this->current = $current ?? self::installed(...);
$this->knowsBlock = $knowsBlock ?? DiviModules::knowsBlock(...);
}
/** @return array<string, Closure(): bool> */
public function codeChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->calls as $call) {
$checks[$manifest->area . ': ' . $call->label()] = static fn (): bool => self::accepts($call);
}
foreach ($manifest->constants as $constant) {
$checks[$manifest->area . ': Konstante ' . $constant] = static fn (): bool => defined($constant);
}
}
return $checks;
}
/** @return array<string, Closure(): bool> */
public function mirrorChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->mirrors as $mirror) {
$label = sprintf('%s: Nachbau von %s – %s', $manifest->area, $mirror->label(), $mirror->what);
$checks[$label] = function () use ($mirror): bool {
$expected = ($this->expected ??= DiviReference::expected())[$mirror->id] ?? null;
return $expected !== null && ($this->current)($mirror) === $expected;
};
}
}
return $checks;
}
/** @return array<string, Closure(): bool> */
public function registrationChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->postTypes as $type) {
$checks[$manifest->area . ': Post-Typ ' . $type] = static fn (): bool => post_type_exists($type);
}
foreach ($manifest->taxonomies as $taxonomy) {
$checks[$manifest->area . ': Taxonomie ' . $taxonomy] =
static fn (): bool => taxonomy_exists($taxonomy);
}
foreach ($manifest->blockTypes as $block) {
$checks[$manifest->area . ': Block-Typ ' . $block] = fn (): bool => ($this->knowsBlock)($block);
}
}
return $checks;
}
/**
* @param array<string, callable(): bool> $checks
* @return list<string> Labels of the checks that failed or threw.
*/
public static function failed(array $checks): array
{
$failed = [];
foreach ($checks as $label => $check) {
try {
$ok = (bool) $check();
} catch (Throwable) {
$ok = false;
}
if (! $ok) {
$failed[] = (string) $label;
}
}
return $failed;
}
/** The function or method exists and takes the number of arguments the adapter passes. */
public static function accepts(Call $call): bool
{
$class = $call->className();
if ($class === null) {
if (! function_exists($call->member())) {
return false;
}
$reflection = new ReflectionFunction($call->member());
} else {
if (! method_exists($class, $call->member())) {
return false;
}
$reflection = new ReflectionMethod($class, $call->member());
}
return $call->args === null
|| ($reflection->getNumberOfRequiredParameters() <= $call->args
&& ($reflection->isVariadic() || $reflection->getNumberOfParameters() >= $call->args));
}
private static function installed(Mirror $mirror): ?string
{
$dir = DiviEnvironment::themeDir();
return $dir === null
? null
: SourceFingerprint::of($dir . '/' . $mirror->file, $mirror->function, $mirror->class)->hash;
}
}
@@ -0,0 +1,7 @@
{
"library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02",
"tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4",
"tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e",
"tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d",
"vb-sync-flags": "f8e85421197b0f823219dad6081af7e47f9b4e388c45e162e9188f892766fde9"
}