feat: Divi manifests, adapter register and boundary test

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 00:46:16 +02:00
co-authored by Claude Sonnet 5
parent 905bb8c2f3
commit 5e6d5ff3e1
9 changed files with 543 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* One Divi function or static method an adapter calls (spec §4.1), with the number of arguments the plugin passes.
* $args null: only its existence matters (a function Divi itself needs, e.g. et_() inside store_template).
*/
final class Call
{
/** @param string $symbol "function_name" or "Fully\Qualified\Class::method". */
public function __construct(public readonly string $symbol, public readonly ?int $args = null)
{
}
public function className(): ?string
{
return str_contains($this->symbol, '::') ? ltrim(explode('::', $this->symbol, 2)[0], '\\') : null;
}
public function member(): string
{
return str_contains($this->symbol, '::') ? explode('::', $this->symbol, 2)[1] : $this->symbol;
}
public function label(): string
{
$class = $this->className();
return ($class === null ? '' : SourceFingerprint::shortName($class) . '::') . $this->member() . '()';
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** A class that may touch Divi (spec §3); it lists every Divi dependency in its manifest. */
interface DiviAdapter
{
public static function manifest(): Manifest;
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** The Divi boundary (spec §3): the only classes that touch Divi, in the order reports list them. */
final class DiviAdapters
{
/** @var list<class-string<DiviAdapter>> */
public const CLASSES = [];
/** @return array<class-string<DiviAdapter>, Manifest> */
public static function manifests(): array
{
$manifests = [];
foreach (self::CLASSES as $class) {
$manifests[$class] = $class::manifest();
}
return $manifests;
}
/** @return list<Mirror> */
public static function mirrors(): array
{
$mirrors = [];
foreach (self::manifests() as $manifest) {
array_push($mirrors, ...$manifest->mirrors);
}
return $mirrors;
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* Finds where PHP code touches Divi (DiviBoundaryTest, spec §3): calls of et_* functions, ET\… / ET_Core… /
* ET_Builder… classes (also as strings), ET_* constants (also as strings) and the strings 'et_…'/'_et_…' (post
* types, meta keys, options, function names for function_exists()). Comments are ignored; method calls named
* et_* ($x->et_a(), Foo::et_b()) are not Divi's.
*/
final class DiviTouches
{
private const IGNORED = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
private const NOT_A_CALL = [
T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_CONST,
];
/** @return list<array{kind: string, name: string, line: int}> */
public static function inFile(string $path): array
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local PHP source, not a remote request.
$source = file_get_contents($path);
return is_string($source) ? self::inSource($source) : [];
}
/** @return list<array{kind: string, name: string, line: int}> */
public static function inSource(string $source): array
{
$tokens = array_values(array_filter(
token_get_all($source),
static fn (mixed $token): bool => ! is_array($token) || ! in_array($token[0], self::IGNORED, true)
));
$touches = [];
foreach ($tokens as $i => $token) {
if (! is_array($token)) {
continue;
}
$touch = match ($token[0]) {
T_STRING, T_NAME_FULLY_QUALIFIED => self::name($tokens, $i, ltrim($token[1], '\\')),
T_NAME_QUALIFIED => str_starts_with($token[1], 'ET\\') ? ['class', $token[1]] : null,
T_CONSTANT_ENCAPSED_STRING => self::literal($token[1]),
default => null,
};
if ($touch !== null) {
$touches[] = ['kind' => $touch[0], 'name' => $touch[1], 'line' => (int) $token[2]];
}
}
return $touches;
}
/** Whether inSource() reports $name when the code uses it (DiviBoundaryTest's unused-entry check). */
public static function detects(string $name): bool
{
return self::classify($name) !== null || preg_match('/^_?et_[a-z0-9_]*$/', $name) === 1;
}
/**
* @param array<int, mixed> $tokens
* @return array{0: string, 1: string}|null
*/
private static function name(array $tokens, int $i, string $name): ?array
{
$classified = self::classify($name);
if ($classified !== null) {
return $classified;
}
if (preg_match('/^et_[a-z0-9_]*$/', $name) !== 1 || ($tokens[$i + 1] ?? null) !== '(') {
return null;
}
$previous = $tokens[$i - 1] ?? null;
return is_array($previous) && in_array($previous[0], self::NOT_A_CALL, true) ? null : ['function', $name];
}
/** @return array{0: string, 1: string}|null */
private static function literal(string $text): ?array
{
$value = substr($text, 1, -1);
if ($text[0] === "'") {
$value = str_replace(['\\\\', "\\'"], ['\\', "'"], $value);
}
$value = ltrim($value, '\\');
$classified = self::classify($value);
if ($classified !== null) {
return $classified;
}
return preg_match('/^_?et_[a-z0-9_]*$/', $value) === 1 ? ['string', $value] : null;
}
/** @return array{0: string, 1: string}|null A Divi class or constant name. */
private static function classify(string $name): ?array
{
if (
preg_match('/^ET\\\\[A-Za-z0-9_\\\\]+$/', $name) === 1
|| preg_match('/^ET_(?:Core|Builder)[A-Za-z0-9_]*$/', $name) === 1
) {
return ['class', $name];
}
return preg_match('/^ET_[A-Z0-9_]+$/', $name) === 1 ? ['constant', $name] : null;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* The Divi dependencies of one adapter (spec §4.1) – the single source for DiviCompatibility, the release gate,
* the admin check and DiviBoundaryTest. Pure data, no WordPress calls.
*/
final class Manifest
{
public const PAGES = 'Seiten';
public const LIBRARY = 'Bibliothek';
public const THEME_BUILDER = 'Theme Builder';
public const DESIGN = 'Designsystem';
public const CATALOG = 'Katalog';
/**
* @param string $area One of the constants above (the five areas of spec §3).
* @param list<Call> $calls Divi functions and static methods the adapter calls.
* @param list<string> $constants Divi constants it reads.
* @param list<string> $postTypes Divi post types it needs registered.
* @param list<string> $taxonomies Divi taxonomies it needs registered.
* @param list<string> $blockTypes Divi block types it needs (registered or in Divi's module metadata).
* @param list<string> $keys Other Divi names it uses as strings: meta keys, options, internal post
* types that need no registration check, query arguments.
* @param list<Mirror> $mirrors Divi functions whose storage logic it repeats.
*/
public function __construct(
public readonly string $area,
public readonly array $calls = [],
public readonly array $constants = [],
public readonly array $postTypes = [],
public readonly array $taxonomies = [],
public readonly array $blockTypes = [],
public readonly array $keys = [],
public readonly array $mirrors = []
) {
}
/**
* @return list<string> Every Divi name the adapter's code may contain (DiviBoundaryTest): called functions and
* classes, constants, post types, taxonomies, keys and the names of mirrored functions.
*/
public function names(): array
{
$names = [];
foreach ($this->calls as $call) {
$names[] = $call->className() ?? $call->member();
}
$mirrored = array_map(static fn (Mirror $mirror): string => $mirror->function, $this->mirrors);
return array_values(array_unique(array_merge(
$names,
$this->constants,
$this->postTypes,
$this->taxonomies,
$this->keys,
$mirrored
)));
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* A Divi function whose storage logic an adapter repeats instead of calling it (spec §4.1 "mirrors"). Its
* fingerprint (SourceFingerprint) must match the one in fingerprints.json, else Divi changed what we copy.
*/
final class Mirror
{
/**
* @param string $id Key in fingerprints.json and name of tests/Fixtures/divi/reference/<id>.txt.
* @param string $file Divi file, relative to the Divi theme directory.
* @param string $function Function or method name.
* @param string|null $class Class of a method – the short name is enough (the file fixes where it
* lives, and a short name is no Divi name for DiviBoundaryTest); null for a function.
* @param string $what What the plugin repeats (German, for reports).
*/
public function __construct(
public readonly string $id,
public readonly string $file,
public readonly string $function,
public readonly ?string $class,
public readonly string $what
) {
}
public function label(): string
{
$class = $this->class === null ? '' : SourceFingerprint::shortName($this->class) . '::';
return $class . $this->function . '()';
}
}