feat: fingerprint Divi functions from source tokens
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user