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:
co-authored by
Claude Sonnet 5.5
parent
392fe7c569
commit
710444e88d
@@ -35,6 +35,8 @@ final class DiviCheckCommand
|
||||
* @param (Closure(): ?string)|null $themeDir null = DiviEnvironment::themeDir().
|
||||
* @param string|null $expectedFile null = DiviReference::FILE.
|
||||
* @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(
|
||||
private Closure $check,
|
||||
@@ -42,7 +44,8 @@ final class DiviCheckCommand
|
||||
?Closure $out = null,
|
||||
private ?Closure $themeDir = 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 {
|
||||
\WP_CLI::line($line);
|
||||
@@ -67,7 +70,8 @@ final class DiviCheckCommand
|
||||
* : Zeigt für jede geänderte nachgebaute Divi-Funktion den Unterschied zur Referenz.
|
||||
*
|
||||
* [--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
|
||||
*
|
||||
@@ -179,6 +183,16 @@ final class DiviCheckCommand
|
||||
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 {
|
||||
// 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.
|
||||
@@ -186,7 +200,7 @@ final class DiviCheckCommand
|
||||
DiviReference::writeSource($id, $print->source, $sourceDir);
|
||||
}
|
||||
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
|
||||
);
|
||||
} catch (RuntimeException $e) {
|
||||
@@ -197,8 +211,9 @@ final class DiviCheckCommand
|
||||
delete_transient(ReleaseResolver::CACHE);
|
||||
ReleaseGate::forget();
|
||||
($this->out)(sprintf(
|
||||
'Referenz aktualisiert (%d Fingerabdrücke). Smoke-Skripte laufen lassen, dann committen.',
|
||||
count($prints)
|
||||
'Referenz aktualisiert (%d Fingerabdrücke, %d Signaturen). Smoke-Skripte laufen lassen, dann committen.',
|
||||
count($prints),
|
||||
count($signatures)
|
||||
));
|
||||
|
||||
return 0;
|
||||
|
||||
@@ -42,6 +42,19 @@ final class DiviAdapters
|
||||
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> */
|
||||
public static function mirrors(): array
|
||||
{
|
||||
|
||||
@@ -9,15 +9,22 @@ use RuntimeException;
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The expected fingerprints of the mirrors (fingerprints.json, shipped with the plugin) and their reference sources
|
||||
* (tests/Fixtures/divi/reference/<id>.txt, development only – not in the plugin package). Written by
|
||||
* wp lit-mcp divi-check --update-reference.
|
||||
* The expected fingerprints (fingerprints.json, shipped with the plugin): the source fingerprint of every mirror under
|
||||
* its ID, and the signature fingerprint of every Divi call under "sig:<symbol>" (SignatureFingerprint) – plus the
|
||||
* 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
|
||||
{
|
||||
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
|
||||
{
|
||||
$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
|
||||
*/
|
||||
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. */
|
||||
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",
|
||||
"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-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e",
|
||||
"tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d",
|
||||
|
||||
@@ -13,6 +13,7 @@ use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviReference;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use RuntimeException;
|
||||
|
||||
final class DiviCheckCommandTest extends TestCase
|
||||
{
|
||||
@@ -23,10 +24,18 @@ final class DiviCheckCommandTest extends TestCase
|
||||
private string $sourceDir = '';
|
||||
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
|
||||
{
|
||||
parent::setUp();
|
||||
$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();
|
||||
$this->themeDir = $base . '/Divi';
|
||||
$this->sourceDir = $base . '/reference';
|
||||
@@ -75,7 +84,14 @@ final class DiviCheckCommandTest extends TestCase
|
||||
},
|
||||
fn (): ?string => $this->themeDir,
|
||||
$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();
|
||||
|
||||
@@ -185,11 +218,16 @@ final class DiviCheckCommandTest extends TestCase
|
||||
self::assertSame(0, $code);
|
||||
self::assertSame(
|
||||
[
|
||||
'library-submit-layout', 'preset-sync', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template',
|
||||
'vb-sync-flags',
|
||||
'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed', 'tb-api-save',
|
||||
'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags',
|
||||
],
|
||||
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(
|
||||
'function et_pb_submit_layout(',
|
||||
(string) DiviReference::readSource('library-submit-layout', $this->sourceDir)
|
||||
|
||||
@@ -4,25 +4,99 @@ declare(strict_types=1);
|
||||
|
||||
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\DiviReference;
|
||||
use LIT\MCP\Integrations\Divi\Release\SignatureFingerprint;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class DiviReferenceTest extends TestCase
|
||||
{
|
||||
public function testEveryMirrorHasAnExpectedFingerprintAndAReferenceSource(): void
|
||||
public function testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource(): void
|
||||
{
|
||||
$ids = array_map(static fn ($mirror): string => $mirror->id, DiviAdapters::mirrors());
|
||||
sort($ids);
|
||||
$mirrorIds = array_map(static fn ($mirror): string => $mirror->id, DiviAdapters::mirrors());
|
||||
$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();
|
||||
|
||||
self::assertSame($ids, array_keys($expected));
|
||||
foreach ($expected as $id => $hash) {
|
||||
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash, $id);
|
||||
self::assertSame($keys, array_keys($expected));
|
||||
foreach ($expected as $key => $hash) {
|
||||
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash, $key);
|
||||
}
|
||||
foreach ($mirrorIds as $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
|
||||
{
|
||||
$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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user