feat: Signatur-Fingerabdruck der Divi-Aufrufe (Reflection); fingerprints.json enthält sig:-Einträge, divi-check --update-reference schreibt sie; Referenz für Divi 5.15.1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 22:01:56 +02:00
co-authored by Claude Sonnet 5.5
parent 392fe7c569
commit 710444e88d
8 changed files with 525 additions and 20 deletions
+20 -5
View File
@@ -35,6 +35,8 @@ final class DiviCheckCommand
* @param (Closure(): ?string)|null $themeDir null = DiviEnvironment::themeDir(). * @param (Closure(): ?string)|null $themeDir null = DiviEnvironment::themeDir().
* @param string|null $expectedFile null = DiviReference::FILE. * @param string|null $expectedFile null = DiviReference::FILE.
* @param (Closure(): ?string)|null $sourceDir null = DiviReference::sourceDir(). * @param (Closure(): ?string)|null $sourceDir null = DiviReference::sourceDir().
* @param (Closure(): array<string, string>)|null $signatures "sig:<symbol>" => sha256 of every Divi call in the
* running process; null = DiviReference::currentSignatures().
*/ */
public function __construct( public function __construct(
private Closure $check, private Closure $check,
@@ -42,7 +44,8 @@ final class DiviCheckCommand
?Closure $out = null, ?Closure $out = null,
private ?Closure $themeDir = null, private ?Closure $themeDir = null,
private ?string $expectedFile = null, private ?string $expectedFile = null,
private ?Closure $sourceDir = null private ?Closure $sourceDir = null,
private ?Closure $signatures = null
) { ) {
$this->out = $out ?? static function (string $line): void { $this->out = $out ?? static function (string $line): void {
\WP_CLI::line($line); \WP_CLI::line($line);
@@ -67,7 +70,8 @@ final class DiviCheckCommand
* : Zeigt für jede geänderte nachgebaute Divi-Funktion den Unterschied zur Referenz. * : Zeigt für jede geänderte nachgebaute Divi-Funktion den Unterschied zur Referenz.
* *
* [--update-reference] * [--update-reference]
* : Schreibt Soll-Fingerabdrücke und Referenzquellen neu – nur wenn alle übrigen Schritte grün sind. * : Schreibt die Soll-Fingerabdrücke (Nachbauten und Signaturen der Divi-Aufrufe) und die Referenzquellen neu –
* nur wenn alle übrigen Schritte grün sind.
* *
* ## EXAMPLES * ## EXAMPLES
* *
@@ -179,6 +183,16 @@ final class DiviCheckCommand
return 1; return 1;
} }
try {
$signatures = ($this->signatures ?? DiviReference::currentSignatures(...))();
} catch (RuntimeException $e) {
($this->out)(
'Referenz nicht aktualisiert – die Signatur eines Divi-Aufrufs ist nicht lesbar: ' . $e->getMessage()
);
return 1;
}
try { try {
// Sources first, fingerprints.json last: the fingerprints decide the release gate, so a write that // Sources first, fingerprints.json last: the fingerprints decide the release gate, so a write that
// fails before this last one leaves the gate exactly as it was. // fails before this last one leaves the gate exactly as it was.
@@ -186,7 +200,7 @@ final class DiviCheckCommand
DiviReference::writeSource($id, $print->source, $sourceDir); DiviReference::writeSource($id, $print->source, $sourceDir);
} }
DiviReference::writeExpected( DiviReference::writeExpected(
array_map(static fn (SourceFingerprint $print): string => $print->hash, $prints), array_map(static fn (SourceFingerprint $print): string => $print->hash, $prints) + $signatures,
$this->expectedFile $this->expectedFile
); );
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
@@ -197,8 +211,9 @@ final class DiviCheckCommand
delete_transient(ReleaseResolver::CACHE); delete_transient(ReleaseResolver::CACHE);
ReleaseGate::forget(); ReleaseGate::forget();
($this->out)(sprintf( ($this->out)(sprintf(
'Referenz aktualisiert (%d Fingerabdrücke). Smoke-Skripte laufen lassen, dann committen.', 'Referenz aktualisiert (%d Fingerabdrücke, %d Signaturen). Smoke-Skripte laufen lassen, dann committen.',
count($prints) count($prints),
count($signatures)
)); ));
return 0; return 0;
@@ -42,6 +42,19 @@ final class DiviAdapters
return $manifests; return $manifests;
} }
/** @return list<Call> Every Divi call of every manifest, each symbol once (the first entry wins). */
public static function calls(): array
{
$calls = [];
foreach (self::manifests() as $manifest) {
foreach ($manifest->calls as $call) {
$calls[ltrim($call->symbol, '\\')] ??= $call;
}
}
return array_values($calls);
}
/** @return list<Mirror> */ /** @return list<Mirror> */
public static function mirrors(): array public static function mirrors(): array
{ {
@@ -9,15 +9,22 @@ use RuntimeException;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* The expected fingerprints of the mirrors (fingerprints.json, shipped with the plugin) and their reference sources * The expected fingerprints (fingerprints.json, shipped with the plugin): the source fingerprint of every mirror under
* (tests/Fixtures/divi/reference/<id>.txt, development only – not in the plugin package). Written by * its ID, and the signature fingerprint of every Divi call under "sig:<symbol>" (SignatureFingerprint) – plus the
* wp lit-mcp divi-check --update-reference. * reference sources of the mirrors (tests/Fixtures/divi/reference/<id>.txt, development only – not in the plugin
* package). Written by wp lit-mcp divi-check --update-reference.
*/ */
final class DiviReference final class DiviReference
{ {
public const FILE = __DIR__ . '/fingerprints.json'; public const FILE = __DIR__ . '/fingerprints.json';
/** @return array<string, string> Mirror ID => sha256; [] when the file is missing or broken. */ /** Key prefix of the signature fingerprints; no mirror ID starts with it. */
public const SIGNATURE_PREFIX = 'sig:';
/**
* @return array<string, string> Mirror ID or "sig:<symbol>" => sha256, sorted by key; [] when the file is
* missing or broken.
*/
public static function expected(?string $file = null): array public static function expected(?string $file = null): array
{ {
$file ??= self::FILE; $file ??= self::FILE;
@@ -40,7 +47,7 @@ final class DiviReference
} }
/** /**
* @param array<string, string> $hashes Mirror ID => sha256. * @param array<string, string> $hashes Mirror ID or "sig:<symbol>" => sha256; written sorted by key.
* @throws RuntimeException * @throws RuntimeException
*/ */
public static function writeExpected(array $hashes, ?string $file = null): void public static function writeExpected(array $hashes, ?string $file = null): void
@@ -54,6 +61,34 @@ final class DiviReference
} }
} }
/** The key of $call's signature fingerprint: "sig:" and the symbol without a leading backslash. */
public static function signatureKey(Call $call): string
{
return self::SIGNATURE_PREFIX . ltrim($call->symbol, '\\');
}
/**
* Signature fingerprints of the calls in the running PHP process (Divi loaded).
*
* @param list<Call>|null $calls null = DiviAdapters::calls().
* @return array<string, string> "sig:<symbol>" => sha256, sorted by key.
* @throws RuntimeException When a symbol is not loaded.
*/
public static function currentSignatures(?array $calls = null): array
{
$signatures = [];
foreach ($calls ?? DiviAdapters::calls() as $call) {
$hash = SignatureFingerprint::of($call);
if ($hash === null) {
throw new RuntimeException(sprintf('signature of %s: symbol is not loaded', $call->label()));
}
$signatures[self::signatureKey($call)] = $hash;
}
ksort($signatures);
return $signatures;
}
/** tests/Fixtures/divi/reference of a development checkout; null in an installed plugin. */ /** tests/Fixtures/divi/reference of a development checkout; null in an installed plugin. */
public static function sourceDir(): ?string public static function sourceDir(): ?string
{ {
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use ReflectionException;
use ReflectionFunction;
use ReflectionFunctionAbstract;
use ReflectionMethod;
use ReflectionParameter;
use ReflectionType;
defined('ABSPATH') || exit;
/**
* Fingerprint of the signature of one Divi function or static method (spec §4.2, E1 of the plan "Divi-Fähigkeiten"),
* read with Reflection from the running PHP process – so it needs the symbol to be loaded (in WordPress Divi is) and
* never includes a file. The class of a method is autoloaded the way ManifestCheck::accepts() already does it.
*
* What the hash covers: function or method; for a method its visibility and whether it is static; per parameter the
* type (nullable and union included; no type reads as "mixed"), by reference, variadic and whether it is optional
* (has a default); the return type (none reads as "mixed"). What it leaves out:
* - Parameter names: the plugin passes every argument to Divi positionally (PHP 8 named arguments are never used),
* so a rename cannot break a call, and a harmless rename must not lock a feature.
* - Default values: they are expressions (constants, arrays) that Reflection may be unable to evaluate in some
* requests and that change spelling without changing meaning; only "has a default" matters for the number of
* arguments the plugin may omit.
* - The body: a changed body is no change of the interface (mirrors cover the code the plugin repeats).
*/
final class SignatureFingerprint
{
/** sha256 of canonical(), null when the symbol does not exist or cannot be reflected. */
public static function of(Call $call): ?string
{
$canonical = self::canonical($call);
return $canonical === null ? null : hash('sha256', $canonical);
}
/**
* The text the hash is built from, e.g. "public static (int, array|string=, &mixed=, ...mixed): ?bool".
* Public for tests and diagnostics; null when the symbol does not exist.
*/
public static function canonical(Call $call): ?string
{
try {
$reflection = self::reflect($call);
} catch (ReflectionException) {
return null;
}
if ($reflection === null) {
return null;
}
return sprintf(
'%s (%s): %s',
self::kind($reflection),
implode(', ', array_map(self::parameter(...), $reflection->getParameters())),
self::type($reflection->getReturnType())
);
}
/** "function", or a method's visibility and "static" – a method that turns private or non-static breaks a call. */
private static function kind(ReflectionFunctionAbstract $reflection): string
{
if (! $reflection instanceof ReflectionMethod) {
return 'function';
}
$visibility = $reflection->isPublic() ? 'public' : ($reflection->isProtected() ? 'protected' : 'private');
return $reflection->isStatic() ? $visibility . ' static' : $visibility;
}
/** @throws ReflectionException */
private static function reflect(Call $call): ?ReflectionFunctionAbstract
{
$class = $call->className();
if ($class === null) {
return function_exists($call->member()) ? new ReflectionFunction($call->member()) : null;
}
return method_exists($class, $call->member()) ? new ReflectionMethod($class, $call->member()) : null;
}
private static function parameter(ReflectionParameter $parameter): string
{
return ($parameter->isPassedByReference() ? '&' : '')
. ($parameter->isVariadic() ? '...' : '')
. self::type($parameter->getType())
. ($parameter->isOptional() && ! $parameter->isVariadic() ? '=' : '');
}
private static function type(?ReflectionType $type): string
{
return $type === null ? 'mixed' : (string) $type;
}
}
@@ -1,6 +1,35 @@
{ {
"library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02", "library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02",
"preset-sync": "9586f8005b8065893c0350977a58ee9171bf715fff21ac0c84663cc512f45522", "preset-sync": "9586f8005b8065893c0350977a58ee9171bf715fff21ac0c84663cc512f45522",
"sig:ET\\Builder\\Framework\\UserRole\\UserRole::can_current_user_use_visual_builder": "76d199c13adc6600c8a456225a8eb9db8037064f20eca9db597c4e45ccecf8d5",
"sig:ET\\Builder\\Packages\\Conversion\\Conversion::get_preset_attrs": "5b0ace397bc5e171b73b9c50df5d27942719dffe5344fb4bc36af0f35616a75b",
"sig:ET\\Builder\\Packages\\Conversion\\Conversion::get_preset_attrs_mapping": "b5c3403666ff92be1dd7bfa6e6589c32f6662b37282f2fd40922f8d50151745d",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::get_global_colors": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::get_global_variables": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::get_data": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::get_deleted_preset_ids": "d36bfe7698116a94f7c6bc5ff00e3f66f3cd4267b68b5e61f0a5ec01e051b450",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::get_group_preset_default_attr": "427ee58858537cf02a9d6dbbf58aafd779f8424c9c840806b5d7af8f25fbbda5",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::prepare_data": "94f109150a8d6db57c309de00f4f4e369b4cd4a7e2a0682d5612dddeb65c8861",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::remove_preset_from_legacy_option": "11f8d1b50b8f113f96c9d8b2fb79454d45938a9d1613f42f5089e35767597aed",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::save_data": "7cfacdb2d6231f4baff0bea176081bf3237b5c3fb2a3f8e76fcd8b7d156d4375",
"sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::validate_preset_count": "09765c551d2f4fb3d5841644f79aa304b7406d92aed6a1cc33821f80dc8b6592",
"sig:ET\\Builder\\Packages\\GlobalLayout\\GlobalLayout::is_global_layout_template": "6fe723bf98c5fa7580f9f52af29adf08a037520081ced8363063125270ae3384",
"sig:ET\\Builder\\Packages\\ModuleLibrary\\ModuleRegistration::get_all_core_modules_metadata": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae",
"sig:ET\\Builder\\Packages\\ModuleLibrary\\ModuleRegistration::get_default_attrs": "728c9cddae0184ffccaff5b22a392b7a4e9c5f078b5d12d4c9717aca6af70e69",
"sig:ET\\Builder\\Packages\\ModuleLibrary\\ModuleRegistration::get_module_settings": "f61b3693b0df17be1f068b8be424dcf56a67324372a92f576724cd564d992036",
"sig:ET\\Builder\\Packages\\Module\\Layout\\Components\\DynamicContent\\DynamicContentOptions::get_options": "6d3932cbf7921be4f353ab71d2bd7200c2bf500577b401b32e9ca31809f07489",
"sig:ET_Core_PageResource::remove_static_resources": "eaa39fd2c037c2d399578f71862da5c04b68d58a30657916482f95e94e5d8a96",
"sig:et_": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_builder_enabled_for_post_type": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_builder_get_public_post_types": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_pb_is_allowed": "b7561d91c3e696079a598474517118c3e9809cbf1f08b1321babe7af11d15799",
"sig:et_theme_builder_clear_wp_cache": "3ac3132305f3818c13df78416ae13e34c36af17a0ba9b66d652fe9babc6c051d",
"sig:et_theme_builder_get_flat_template_settings_options": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_theme_builder_get_template": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_theme_builder_get_theme_builder_post_id": "b7561d91c3e696079a598474517118c3e9809cbf1f08b1321babe7af11d15799",
"sig:et_theme_builder_get_theme_builder_template_ids": "b7561d91c3e696079a598474517118c3e9809cbf1f08b1321babe7af11d15799",
"sig:et_theme_builder_insert_layout": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_theme_builder_store_template": "6c84ec472739a2dbb87bcf21777c4dc977fd2233142eacfefe60eba462a4c219",
"tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4", "tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4",
"tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e", "tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e",
"tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d", "tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d",
+42 -4
View File
@@ -13,6 +13,7 @@ use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviReference; use LIT\MCP\Integrations\Divi\Release\DiviReference;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
use RuntimeException;
final class DiviCheckCommandTest extends TestCase final class DiviCheckCommandTest extends TestCase
{ {
@@ -23,10 +24,18 @@ final class DiviCheckCommandTest extends TestCase
private string $sourceDir = ''; private string $sourceDir = '';
private string $expectedFile = ''; private string $expectedFile = '';
/** @var array<string, string> What the command reads as the signatures of the running Divi. */
private array $signatures = [];
/** Set to true to make the signature read fail like a Divi call that is not loaded. */
private bool $signaturesUnreadable = false;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->lines = []; $this->lines = [];
$this->signatures = ['sig:et_pb_is_allowed' => str_repeat('a', 64), 'sig:A::b' => str_repeat('b', 64)];
$this->signaturesUnreadable = false;
$base = sys_get_temp_dir() . '/lit-cli-' . uniqid(); $base = sys_get_temp_dir() . '/lit-cli-' . uniqid();
$this->themeDir = $base . '/Divi'; $this->themeDir = $base . '/Divi';
$this->sourceDir = $base . '/reference'; $this->sourceDir = $base . '/reference';
@@ -75,7 +84,14 @@ final class DiviCheckCommandTest extends TestCase
}, },
fn (): ?string => $this->themeDir, fn (): ?string => $this->themeDir,
$this->expectedFile, $this->expectedFile,
$noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir $noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir,
function (): array {
if ($this->signaturesUnreadable) {
throw new RuntimeException('signature of et_pb_is_allowed(): symbol is not loaded');
}
return $this->signatures;
}
); );
} }
@@ -176,7 +192,24 @@ final class DiviCheckCommandTest extends TestCase
} }
} }
public function testUpdateReferenceWritesFingerprintsAndSources(): void public function testUpdateReferenceWritesNothingWhenASignatureIsUnreadable(): void
{
$this->writeDivi();
$this->signaturesUnreadable = true;
$code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]);
self::assertSame(1, $code);
self::assertStringContainsString(
'Referenz nicht aktualisiert – die Signatur eines Divi-Aufrufs ist nicht lesbar: '
. 'signature of et_pb_is_allowed(): symbol is not loaded',
implode("\n", $this->lines)
);
self::assertFileDoesNotExist($this->expectedFile, 'all or nothing');
self::assertSame([], glob($this->sourceDir . '/*.txt'), 'no source written either');
}
public function testUpdateReferenceWritesFingerprintsSignaturesAndSources(): void
{ {
$this->writeDivi(); $this->writeDivi();
@@ -185,11 +218,16 @@ final class DiviCheckCommandTest extends TestCase
self::assertSame(0, $code); self::assertSame(0, $code);
self::assertSame( self::assertSame(
[ [
'library-submit-layout', 'preset-sync', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed', 'tb-api-save',
'vb-sync-flags', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags',
], ],
array_keys(DiviReference::expected($this->expectedFile)) array_keys(DiviReference::expected($this->expectedFile))
); );
self::assertSame(str_repeat('b', 64), DiviReference::expected($this->expectedFile)['sig:A::b']);
self::assertContains(
'Referenz aktualisiert (6 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.',
$this->lines
);
self::assertStringStartsWith( self::assertStringStartsWith(
'function et_pb_submit_layout(', 'function et_pb_submit_layout(',
(string) DiviReference::readSource('library-submit-layout', $this->sourceDir) (string) DiviReference::readSource('library-submit-layout', $this->sourceDir)
@@ -4,25 +4,99 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters; use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviReference; use LIT\MCP\Integrations\Divi\Release\DiviReference;
use LIT\MCP\Integrations\Divi\Release\SignatureFingerprint;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use RuntimeException;
final class DiviReferenceTest extends TestCase final class DiviReferenceTest extends TestCase
{ {
public function testEveryMirrorHasAnExpectedFingerprintAndAReferenceSource(): void public function testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource(): void
{ {
$ids = array_map(static fn ($mirror): string => $mirror->id, DiviAdapters::mirrors()); $mirrorIds = array_map(static fn ($mirror): string => $mirror->id, DiviAdapters::mirrors());
sort($ids); $signatureKeys = [];
foreach (DiviAdapters::manifests() as $manifest) {
foreach ($manifest->calls as $call) {
$signatureKeys[] = 'sig:' . ltrim($call->symbol, '\\');
}
}
$keys = array_values(array_unique(array_merge($mirrorIds, $signatureKeys)));
sort($keys);
$expected = DiviReference::expected(); $expected = DiviReference::expected();
self::assertSame($ids, array_keys($expected)); self::assertSame($keys, array_keys($expected));
foreach ($expected as $id => $hash) { foreach ($expected as $key => $hash) {
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash, $id); self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash, $key);
}
foreach ($mirrorIds as $id) {
self::assertNotNull(DiviReference::readSource($id, (string) DiviReference::sourceDir()), $id); self::assertNotNull(DiviReference::readSource($id, (string) DiviReference::sourceDir()), $id);
} }
} }
public function testEverySignatureKeyStartsWithThePrefixNoMirrorIdUses(): void
{
foreach (DiviAdapters::mirrors() as $mirror) {
self::assertStringStartsNotWith(DiviReference::SIGNATURE_PREFIX, $mirror->id);
}
self::assertSame('sig:et_pb_is_allowed', DiviReference::signatureKey(new Call('et_pb_is_allowed', 1)));
self::assertSame(
'sig:ET_Core_PageResource::remove_static_resources',
DiviReference::signatureKey(new Call('\\ET_Core_PageResource::remove_static_resources', 3))
);
}
public function testCallsListsEverySymbolOnceAcrossTheManifests(): void
{
$symbols = array_map(static fn (Call $call): string => ltrim($call->symbol, '\\'), DiviAdapters::calls());
self::assertSame($symbols, array_values(array_unique($symbols)));
self::assertContains('ET_Core_PageResource::remove_static_resources', $symbols);
self::assertCount(1, array_keys($symbols, 'ET_Core_PageResource::remove_static_resources', true));
self::assertContains('et_pb_is_allowed', $symbols);
}
public function testCurrentSignaturesAreSortedAndKeyedLikeTheReference(): void
{
$signatures = DiviReference::currentSignatures([
new Call('strlen', 1),
new Call('\\str_replace', 3),
new Call('strlen', 1),
]);
self::assertSame(['sig:str_replace', 'sig:strlen'], array_keys($signatures));
self::assertSame(SignatureFingerprint::of(new Call('strlen')), $signatures['sig:strlen']);
}
public function testCurrentSignaturesRefuseASymbolThatIsNotLoaded(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('signature of lit_mcp_missing_function(): symbol is not loaded');
DiviReference::currentSignatures([new Call('strlen', 1), new Call('lit_mcp_missing_function', 0)]);
}
public function testSignatureKeysAreWrittenAndReadLikeMirrorIds(): void
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit');
try {
DiviReference::writeExpected([
'sig:Z\\Klass::run' => str_repeat('c', 64),
'tb-api-save' => str_repeat('b', 64),
'sig:a_function' => str_repeat('a', 64),
], $file);
self::assertSame(
['sig:Z\\Klass::run', 'sig:a_function', 'tb-api-save'],
array_keys(DiviReference::expected($file))
);
} finally {
unlink($file);
}
}
public function testWritesAndReadsExpectedFingerprintsSorted(): void public function testWritesAndReadsExpectedFingerprintsSorted(): void
{ {
$file = (string) tempnam(sys_get_temp_dir(), 'lit'); $file = (string) tempnam(sys_get_temp_dir(), 'lit');
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use ArrayObject;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\SignatureFingerprint;
use PHPUnit\Framework\TestCase;
/** Stand-in for a Divi class: one baseline method and one variant per property the fingerprint covers. */
final class SignatureFixture
{
public static function base(int $id, string|array $data = [], &$ref = null, ...$rest): ?bool
{
return null;
}
/** Same signature, other parameter names and other default values: no change. */
public static function renamed(int $post, string|array $payload = '', &$out = 5, ...$more): ?bool
{
return null;
}
public static function extraRequired(int $id, string|array $data, &$ref = null, ...$rest): ?bool
{
return null;
}
public static function fewer(int $id, string|array $data = []): ?bool
{
return null;
}
public static function typeChanged(string $id, string|array $data = [], &$ref = null, ...$rest): ?bool
{
return null;
}
public static function unionWidened(int $id, string|array|int $data = [], &$ref = null, ...$rest): ?bool
{
return null;
}
public static function notNullable(int $id, string|array $data = [], &$ref = null, ...$rest): bool
{
return true;
}
public static function byValue(int $id, string|array $data = [], $ref = null, ...$rest): ?bool
{
return null;
}
public static function notVariadic(int $id, string|array $data = [], &$ref = null, $rest = null): ?bool
{
return null;
}
public static function defaultRemoved(int $id, string|array $data, &$ref, ...$rest): ?bool
{
return null;
}
public static function returnChanged(int $id, string|array $data = [], &$ref = null, ...$rest): ?int
{
return null;
}
public function notStatic(int $id, string|array $data = [], &$ref = null, ...$rest): ?bool
{
return null;
}
protected static function notPublic(int $id, string|array $data = [], &$ref = null, ...$rest): ?bool
{
return null;
}
public static function untyped($id, $data = [])
{
return null;
}
public static function mixedTyped(mixed $id, mixed $data = []): mixed
{
return null;
}
}
final class SignatureFingerprintTest extends TestCase
{
private const FIXTURE = SignatureFixture::class;
private static function method(string $name): Call
{
return new Call(self::FIXTURE . '::' . $name);
}
public function testIsASha256OfAnExistingFunctionAndStable(): void
{
$hash = SignatureFingerprint::of(new Call('strlen', 1));
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', (string) $hash);
self::assertSame($hash, SignatureFingerprint::of(new Call('strlen', 1)));
self::assertNotSame($hash, SignatureFingerprint::of(new Call('str_replace', 3)));
}
public function testMissingSymbolsGiveNull(): void
{
self::assertNull(SignatureFingerprint::of(new Call('lit_mcp_missing_function')));
self::assertNull(SignatureFingerprint::of(new Call('Missing\Klass::run')));
self::assertNull(SignatureFingerprint::of(new Call(ArrayObject::class . '::missing')));
self::assertNull(SignatureFingerprint::canonical(new Call('lit_mcp_missing_function')));
}
public function testCanonicalTextDescribesTheSignature(): void
{
self::assertSame(
'public static (int, array|string=, &mixed=, ...mixed): ?bool',
SignatureFingerprint::canonical(self::method('base'))
);
self::assertSame('function (string): int', SignatureFingerprint::canonical(new Call('strlen')));
self::assertSame(
'public (int, array|string=, &mixed=, ...mixed): ?bool',
SignatureFingerprint::canonical(self::method('notStatic'))
);
}
/** @return array<string, array{string}> */
public static function changedSignatures(): array
{
return [
'extra required parameter' => ['extraRequired'],
'fewer parameters' => ['fewer'],
'parameter type' => ['typeChanged'],
'wider union' => ['unionWidened'],
'return type loses null' => ['notNullable'],
'by reference lost' => ['byValue'],
'variadic lost' => ['notVariadic'],
'default removed' => ['defaultRemoved'],
'return type' => ['returnChanged'],
'no longer static' => ['notStatic'],
'no longer public' => ['notPublic'],
];
}
/** @dataProvider changedSignatures */
public function testAnyChangeOfTheInterfaceChangesTheHash(string $variant): void
{
self::assertNotSame(
SignatureFingerprint::of(self::method('base')),
SignatureFingerprint::of(self::method($variant)),
$variant
);
}
public function testRenamedParametersAndOtherDefaultValuesKeepTheHash(): void
{
self::assertSame(
SignatureFingerprint::of(self::method('base')),
SignatureFingerprint::of(self::method('renamed'))
);
}
public function testNoTypeReadsAsMixed(): void
{
self::assertSame(
SignatureFingerprint::of(self::method('untyped')),
SignatureFingerprint::of(self::method('mixedTyped'))
);
}
public function testTheCallsOwnArgumentCountAndFeaturesAreNotPartOfIt(): void
{
self::assertSame(
SignatureFingerprint::of(new Call(self::FIXTURE . '::base', 2)),
SignatureFingerprint::of(new Call('\\' . self::FIXTURE . '::base', null, []))
);
}
public function testAFunctionAndAMethodOfTheSameShapeDiffer(): void
{
self::assertNotSame(
SignatureFingerprint::of(new Call('strlen')),
SignatureFingerprint::of(new Call(ArrayObject::class . '::count'))
);
}
public function testTheClassNeverIncludesAFile(): void
{
$source = (string) file_get_contents(
dirname(__DIR__, 5) . '/src/Integrations/Divi/Release/SignatureFingerprint.php'
);
$forbidden = [T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE, T_EVAL];
foreach (token_get_all($source) as $token) {
self::assertFalse(is_array($token) && in_array($token[0], $forbidden, true), 'no include, require or eval');
}
}
}