feat: fingerprint Divi functions from source tokens

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 00:39:22 +02:00
co-authored by Claude Haiku 4.5
parent ca40e39b18
commit 905bb8c2f3
2 changed files with 316 additions and 0 deletions
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* Fingerprint of one Divi function or method (spec §4.2), read with token_get_all(): the file is never included,
* so functions Divi loads only in some requests (theme-builder/api.php, admin-ajax only) are readable in every
* request. The hash covers the tokens from "function" to the closing brace without comments and whitespace –
* new comments or reformatting keep it, any code change changes it. $source is the verbatim text of those
* tokens for the human diff (wp lit-mcp divi-check --diff).
*/
final class SourceFingerprint
{
private const IGNORED = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
private const TYPES = [T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM];
private function __construct(
public readonly string $hash,
public readonly string $source,
public readonly int $startLine,
public readonly int $endLine
) {
}
/** @throws RuntimeException When the file is unreadable or holds no such function. */
public static function of(string $file, string $function, ?string $class = null): self
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading local PHP file, not remote content.
$source = is_file($file) && is_readable($file) ? file_get_contents($file) : false;
if (! is_string($source)) {
throw new RuntimeException(sprintf('%s is not readable', $file));
}
return self::fromSource($source, $function, $class) ?? throw new RuntimeException(sprintf(
'%s not found in %s',
$class === null ? $function . '()' : self::shortName($class) . '::' . $function . '()',
$file
));
}
/**
* The function $function outside every class body, or – with $class – the method $function declared directly
* in the class (trait, interface, enum) named like $class's last segment. The namespace is not compared: the
* file path of the manifest fixes where to look.
*/
public static function fromSource(string $source, string $function, ?string $class = null): ?self
{
$tokens = token_get_all($source);
$wanted = $class === null ? null : self::shortName($class);
$depth = 0;
/** @var list<array{0: int, 1: string}> $bodies Open class bodies: [brace depth, name]. */
$bodies = [];
$pending = null;
foreach ($tokens as $i => $token) {
$id = is_array($token) ? $token[0] : null;
$text = is_array($token) ? $token[1] : $token;
if ($id !== null && in_array($id, self::TYPES, true) && self::previousId($tokens, $i) !== T_DOUBLE_COLON) {
$pending = self::nextName($tokens, $i) ?? '';
continue;
}
if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
++$depth;
if ($pending !== null && $text === '{') {
$bodies[] = [$depth, $pending];
$pending = null;
}
continue;
}
if ($text === '}') {
if ($bodies !== [] && $bodies[count($bodies) - 1][0] === $depth) {
array_pop($bodies);
}
--$depth;
continue;
}
if ($id !== T_FUNCTION || self::previousId($tokens, $i) === T_USE) {
continue;
}
$name = self::nextName($tokens, $i);
if ($name === null || strcasecmp($name, $function) !== 0) {
continue;
}
$body = $bodies === [] ? null : $bodies[count($bodies) - 1];
$matches = $wanted === null
? $body === null
: $body !== null && $body[0] === $depth && strcasecmp($body[1], $wanted) === 0;
if ($matches) {
return self::capture($tokens, $i);
}
}
return null;
}
/** @param array<int, mixed> $tokens */
private static function capture(array $tokens, int $start): ?self
{
$startLine = (int) $tokens[$start][2];
$parts = [];
$verbatim = '';
$depth = 0;
$opened = false;
$count = count($tokens);
for ($i = $start; $i < $count; ++$i) {
$token = $tokens[$i];
$id = is_array($token) ? $token[0] : null;
$text = is_array($token) ? $token[1] : $token;
$verbatim .= $text;
if ($id === null || ! in_array($id, self::IGNORED, true)) {
$parts[] = $text;
}
if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
++$depth;
$opened = true;
} elseif ($text === '}') {
--$depth;
if ($opened && $depth === 0) {
return new self(
hash('sha256', implode(' ', $parts)),
$verbatim,
$startLine,
$startLine + substr_count($verbatim, "\n")
);
}
} elseif ($text === ';' && ! $opened) {
return null;
}
}
return null;
}
/** The next name after $i (skipping whitespace, comments and "&"), null when something else follows. */
private static function nextName(array $tokens, int $i): ?string
{
$count = count($tokens);
for ($j = $i + 1; $j < $count; ++$j) {
$token = $tokens[$j];
if (is_array($token) && in_array($token[0], self::IGNORED, true)) {
continue;
}
if ($token === '&' || (is_array($token) && $token[1] === '&')) {
continue;
}
return is_array($token) && $token[0] === T_STRING ? $token[1] : null;
}
return null;
}
/** Token ID of the previous significant token, null for a single-character token or none. */
private static function previousId(array $tokens, int $i): ?int
{
for ($j = $i - 1; $j >= 0; --$j) {
$token = $tokens[$j];
if (is_array($token) && in_array($token[0], self::IGNORED, true)) {
continue;
}
return is_array($token) ? $token[0] : null;
}
return null;
}
public static function shortName(string $class): string
{
$position = strrpos($class, '\\');
return $position === false ? $class : substr($class, $position + 1);
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\SourceFingerprint;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class SourceFingerprintTest extends TestCase
{
/** Shaped like Divi's files: a guarded function, two classes with a method of the same name. */
private const SOURCE = <<<'PHP'
<?php
use function et_demo_save;
if ( ! function_exists( 'et_demo_save' ) ) {
/**
* Saves.
*/
function et_demo_save( $id, $data = array() ) {
$value = "{$data['x']}";
if ( $id ) {
update_post_meta( $id, '_et_demo', $value );
}
return Foo::class;
}
}
class Controller {
public static function update( $request ) {
return array( 'a' => 1 );
}
}
class Other {
public static function update( $request ) {
return array( 'b' => 2 );
}
}
PHP;
public function testFindsAFunctionInsideAFunctionExistsGuard(): void
{
$print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save');
self::assertNotNull($print);
self::assertSame(7, $print->startLine);
self::assertSame(13, $print->endLine);
self::assertStringStartsWith('function et_demo_save(', $print->source);
self::assertStringEndsWith('}', $print->source);
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $print->hash);
}
public function testClassConstantsAndInterpolationDoNotConfuseTheBraceCount(): void
{
$print = SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save');
self::assertNotNull($print);
self::assertStringContainsString('return Foo::class;', $print->source);
self::assertStringNotContainsString('class Controller', $print->source);
}
public function testMethodIsFoundInItsOwnClassOnly(): void
{
$controller = SourceFingerprint::fromSource(self::SOURCE, 'update', 'ET\Builder\VisualBuilder\Controller');
$other = SourceFingerprint::fromSource(self::SOURCE, 'update', 'Other');
self::assertNotNull($controller);
self::assertNotNull($other);
self::assertStringContainsString("'a' => 1", $controller->source);
self::assertStringContainsString("'b' => 2", $other->source);
self::assertNotSame($controller->hash, $other->hash);
self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'update'), 'a method is no top-level function');
self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save', 'Controller'));
self::assertNull(SourceFingerprint::fromSource(self::SOURCE, 'missing'));
}
public function testCommentsAndWhitespaceDoNotChangeTheHash(): void
{
$reformatted = str_replace(
["\t\t\$value = ", "if ( \$id ) {"],
["\t\t// Why: demo.\n\t\t\$value = ", "if (\$id)\n\t\t{"],
self::SOURCE
);
self::assertSame(
SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
SourceFingerprint::fromSource($reformatted, 'et_demo_save')?->hash
);
}
public function testACodeChangeChangesTheHash(): void
{
$changed = str_replace("'_et_demo'", "'_et_demo_v2'", self::SOURCE);
self::assertNotSame(
SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
SourceFingerprint::fromSource($changed, 'et_demo_save')?->hash
);
}
public function testOfReadsTheFileAndThrowsForMissingFunctions(): void
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit');
file_put_contents($file, self::SOURCE);
try {
self::assertSame(
SourceFingerprint::fromSource(self::SOURCE, 'et_demo_save')?->hash,
SourceFingerprint::of($file, 'et_demo_save')->hash
);
try {
SourceFingerprint::of($file, 'update', 'Missing');
self::fail('a missing method must throw');
} catch (RuntimeException $e) {
self::assertStringContainsString('Missing::update() not found', $e->getMessage());
}
} finally {
unlink($file);
}
}
public function testOfThrowsForAnUnreadableFile(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('is not readable');
SourceFingerprint::of('/nonexistent/lit-divi.php', 'et_demo_save');
}
}