From 5e6d5ff3e19e73795082f185c747457718fd08d7 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 00:46:16 +0200 Subject: [PATCH] feat: Divi manifests, adapter register and boundary test Co-Authored-By: Claude Sonnet 5 --- src/Integrations/Divi/Release/Call.php | 36 ++++++ src/Integrations/Divi/Release/DiviAdapter.php | 13 ++ .../Divi/Release/DiviAdapters.php | 36 ++++++ src/Integrations/Divi/Release/DiviTouches.php | 112 ++++++++++++++++ src/Integrations/Divi/Release/Manifest.php | 65 ++++++++++ src/Integrations/Divi/Release/Mirror.php | 38 ++++++ .../Divi/Release/DiviBoundaryTest.php | 121 ++++++++++++++++++ .../Divi/Release/DiviTouchesTest.php | 66 ++++++++++ .../Divi/Release/ManifestTest.php | 56 ++++++++ 9 files changed, 543 insertions(+) create mode 100644 src/Integrations/Divi/Release/Call.php create mode 100644 src/Integrations/Divi/Release/DiviAdapter.php create mode 100644 src/Integrations/Divi/Release/DiviAdapters.php create mode 100644 src/Integrations/Divi/Release/DiviTouches.php create mode 100644 src/Integrations/Divi/Release/Manifest.php create mode 100644 src/Integrations/Divi/Release/Mirror.php create mode 100644 tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/DiviTouchesTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/ManifestTest.php diff --git a/src/Integrations/Divi/Release/Call.php b/src/Integrations/Divi/Release/Call.php new file mode 100644 index 0000000..1f558c6 --- /dev/null +++ b/src/Integrations/Divi/Release/Call.php @@ -0,0 +1,36 @@ +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() . '()'; + } +} diff --git a/src/Integrations/Divi/Release/DiviAdapter.php b/src/Integrations/Divi/Release/DiviAdapter.php new file mode 100644 index 0000000..9f0387b --- /dev/null +++ b/src/Integrations/Divi/Release/DiviAdapter.php @@ -0,0 +1,13 @@ +> */ + public const CLASSES = []; + + /** @return array, Manifest> */ + public static function manifests(): array + { + $manifests = []; + foreach (self::CLASSES as $class) { + $manifests[$class] = $class::manifest(); + } + + return $manifests; + } + + /** @return list */ + public static function mirrors(): array + { + $mirrors = []; + foreach (self::manifests() as $manifest) { + array_push($mirrors, ...$manifest->mirrors); + } + + return $mirrors; + } +} diff --git a/src/Integrations/Divi/Release/DiviTouches.php b/src/Integrations/Divi/Release/DiviTouches.php new file mode 100644 index 0000000..4aa80da --- /dev/null +++ b/src/Integrations/Divi/Release/DiviTouches.php @@ -0,0 +1,112 @@ +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 */ + 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 */ + 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 $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; + } +} diff --git a/src/Integrations/Divi/Release/Manifest.php b/src/Integrations/Divi/Release/Manifest.php new file mode 100644 index 0000000..7819a0e --- /dev/null +++ b/src/Integrations/Divi/Release/Manifest.php @@ -0,0 +1,65 @@ + $calls Divi functions and static methods the adapter calls. + * @param list $constants Divi constants it reads. + * @param list $postTypes Divi post types it needs registered. + * @param list $taxonomies Divi taxonomies it needs registered. + * @param list $blockTypes Divi block types it needs (registered or in Divi's module metadata). + * @param list $keys Other Divi names it uses as strings: meta keys, options, internal post + * types that need no registration check, query arguments. + * @param list $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 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 + ))); + } +} diff --git a/src/Integrations/Divi/Release/Mirror.php b/src/Integrations/Divi/Release/Mirror.php new file mode 100644 index 0000000..95bc431 --- /dev/null +++ b/src/Integrations/Divi/Release/Mirror.php @@ -0,0 +1,38 @@ +.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 . '()'; + } +} diff --git a/tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php b/tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php new file mode 100644 index 0000000..a772d27 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviBoundaryTest.php @@ -0,0 +1,121 @@ + $path) { + $touches = DiviTouches::inFile($path); + if ($touches === [] || in_array($relative, self::LEGACY, true)) { + continue; + } + if (! in_array(self::classOf($relative), DiviAdapters::CLASSES, true)) { + $offenders[] = $relative . ': ' . implode(', ', array_unique(array_column($touches, 'name'))); + } + } + + self::assertSame([], $offenders, "Divi darf nur ein Adapter aus DiviAdapters::CLASSES berühren:\n" + . implode("\n", $offenders)); + } + + public function testLegacyFilesStillTouchDivi(): void + { + $files = self::sourceFiles(); + foreach (self::LEGACY as $relative) { + self::assertArrayHasKey($relative, $files, $relative . ' gibt es nicht mehr – aus LEGACY streichen'); + self::assertNotSame([], DiviTouches::inFile($files[$relative]), $relative . ' ist sauber – aus LEGACY streichen'); + self::assertNotContains(self::classOf($relative), DiviAdapters::CLASSES, $relative . ' ist ein Adapter'); + } + } + + public function testAdaptersDeclareExactlyTheDiviNamesTheyUse(): void + { + foreach (DiviAdapters::CLASSES as $class) { + self::assertTrue(is_subclass_of($class, DiviAdapter::class), $class . ' implementiert DiviAdapter nicht'); + $declared = $class::manifest()->names(); + $used = array_values(array_unique(array_column( + DiviTouches::inFile((string) (new ReflectionClass($class))->getFileName()), + 'name' + ))); + + self::assertSame([], array_values(array_diff($used, $declared)), $class . ': nicht im Manifest'); + self::assertSame( + [], + array_values(array_diff(array_filter($declared, [DiviTouches::class, 'detects']), $used)), + $class . ': im Manifest, aber nicht benutzt' + ); + } + } + + public function testMirrorIdsAreUniqueAndFilesRelative(): void + { + $ids = []; + foreach (DiviAdapters::mirrors() as $mirror) { + self::assertNotContains($mirror->id, $ids, 'doppelte Mirror-ID ' . $mirror->id); + self::assertStringStartsNotWith('/', $mirror->file); + $ids[] = $mirror->id; + } + } + + /** @return array Path below src/ => absolute path. */ + private static function sourceFiles(): array + { + $root = dirname(__DIR__, 5) . '/src/'; + $files = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + if ($file->getExtension() === 'php') { + $files[substr((string) $file->getPathname(), strlen($root))] = (string) $file->getPathname(); + } + } + ksort($files); + + return $files; + } + + private static function classOf(string $relative): string + { + return 'LIT\\MCP\\' . str_replace(['/', '.php'], ['\\', ''], $relative); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/DiviTouchesTest.php b/tests/Unit/Integrations/Divi/Release/DiviTouchesTest.php new file mode 100644 index 0000000..7ffcb2e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviTouchesTest.php @@ -0,0 +1,66 @@ +et_method(); + Foo::et_static(); + get_option('lit_mcp_limits'); + } +} +PHP; + + $touches = array_map( + static fn (array $t): string => $t['kind'] . ':' . $t['name'], + DiviTouches::inSource($source) + ); + + self::assertSame( + [ + 'class:ET\Builder\Packages\GlobalData\GlobalData', + 'class:ET\Builder\Packages\Module\DynamicContentOptions', + 'function:et_pb_is_allowed', + 'function:et_theme_builder_clear_wp_cache', + 'class:ET_Core_PageResource', + 'constant:ET_BUILDER_PRODUCT_VERSION', + 'constant:ET_BUILDER_PRODUCT_VERSION', + 'string:_et_pb_use_builder', + 'string:et_', + ], + $touches + ); + } + + public function testDetectsOnlyNamesTheScannerReports(): void + { + self::assertTrue(DiviTouches::detects('et_pb_layout')); + self::assertTrue(DiviTouches::detects('_et_template')); + self::assertTrue(DiviTouches::detects('ET\Builder\Packages\GlobalData\GlobalData')); + self::assertTrue(DiviTouches::detects('ET_Core_PageResource')); + self::assertTrue(DiviTouches::detects('ET_BUILDER_PRODUCT_VERSION')); + self::assertFalse(DiviTouches::detects('scope')); + self::assertFalse(DiviTouches::detects('divi/global-layout')); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/ManifestTest.php b/tests/Unit/Integrations/Divi/Release/ManifestTest.php new file mode 100644 index 0000000..49807fb --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/ManifestTest.php @@ -0,0 +1,56 @@ +className()); + self::assertSame('et_pb_is_allowed', $function->member()); + self::assertSame('et_pb_is_allowed()', $function->label()); + self::assertSame('ET\Builder\Packages\GlobalData\GlobalData', $method->className()); + self::assertSame('get_global_colors', $method->member()); + self::assertSame('GlobalData::get_global_colors()', $method->label()); + self::assertSame('ET_Core_PageResource', (new Call('\ET_Core_PageResource::remove_static_resources', 3))->className()); + } + + public function testMirrorLabel(): void + { + $function = new Mirror('tb-api-save', 'includes/api.php', 'et_theme_builder_api_save', null, 'Vorlagenliste'); + $method = new Mirror('vb-flags', 'x.php', 'update', 'SyncToServerController', 'Kennzeichen'); + + self::assertSame('et_theme_builder_api_save()', $function->label()); + self::assertSame('SyncToServerController::update()', $method->label()); + } + + public function testNamesListEveryDeclaredDiviName(): void + { + $manifest = new Manifest( + Manifest::LIBRARY, + calls: [new Call('et_pb_is_allowed', 1), new Call('ET\Builder\GlobalLayout::is_global_layout_template', 1)], + constants: ['ET_BUILDER_PRODUCT_VERSION'], + postTypes: ['et_pb_layout'], + taxonomies: ['scope'], + blockTypes: ['divi/global-layout'], + keys: ['_et_pb_module_type'] + ); + + self::assertSame( + ['et_pb_is_allowed', 'ET\Builder\GlobalLayout', 'ET_BUILDER_PRODUCT_VERSION', 'et_pb_layout', 'scope', + '_et_pb_module_type'], + $manifest->names() + ); + self::assertSame('Bibliothek', $manifest->area); + } +}