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);
}
}