This commit is contained in:
nuno maduro
2026-04-22 08:25:38 -07:00
parent c6a42a2b28
commit 68527c996f

View File

@ -60,7 +60,7 @@ final readonly class Fingerprint
// almost never matches a dev's Herd/Homebrew install, and // almost never matches a dev's Herd/Homebrew install, and
// the patch rarely changes anything test-visible. // the patch rarely changes anything test-visible.
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION, 'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
'extensions' => self::extensionsFingerprint(), 'extensions' => self::extensionsFingerprint($projectRoot),
'pest' => self::readPestVersion($projectRoot), 'pest' => self::readPestVersion($projectRoot),
], ],
]; ];
@ -174,24 +174,83 @@ final readonly class Fingerprint
} }
/** /**
* Deterministic hash of the PHP extension set: `ext-name@version` pairs * Deterministic hash of the extensions the project actually depends on —
* sorted alphabetically and joined. * the `ext-*` entries in composer.json's `require` / `require-dev`. An
* incidental extension loaded on the developer's machine (or on CI) but
* not declared as a dependency can't affect correctness of the test
* suite, so we ignore it here to keep the drift signal quiet.
*
* Declared extensions that aren't currently loaded record as `missing`,
* which is itself a drift signal worth surfacing.
*/ */
private static function extensionsFingerprint(): string private static function extensionsFingerprint(string $projectRoot): string
{ {
$extensions = get_loaded_extensions(); $extensions = self::declaredExtensions($projectRoot);
if ($extensions === []) {
return hash('xxh128', '');
}
sort($extensions); sort($extensions);
$parts = []; $parts = [];
foreach ($extensions as $name) { foreach ($extensions as $name) {
$version = phpversion($name); $version = phpversion($name);
$parts[] = $name.'@'.($version === false ? '?' : $version); $parts[] = $name.'@'.($version === false ? 'missing' : $version);
} }
return hash('xxh128', implode("\n", $parts)); return hash('xxh128', implode("\n", $parts));
} }
/**
* Extension names (without the `ext-` prefix) that appear as keys under
* `require` or `require-dev` in the project's composer.json. Returns
* an empty list when composer.json is missing / unreadable / malformed,
* so the environmental fingerprint stays stable in those cases rather
* than flapping.
*
* @return list<string>
*/
private static function declaredExtensions(string $projectRoot): array
{
$path = $projectRoot.'/composer.json';
if (! is_file($path)) {
return [];
}
$raw = @file_get_contents($path);
if ($raw === false) {
return [];
}
$data = json_decode($raw, true);
if (! is_array($data)) {
return [];
}
$extensions = [];
foreach (['require', 'require-dev'] as $section) {
$packages = $data[$section] ?? null;
if (! is_array($packages)) {
continue;
}
foreach (array_keys($packages) as $package) {
if (is_string($package) && str_starts_with($package, 'ext-')) {
$extensions[] = substr($package, 4);
}
}
}
return array_values(array_unique($extensions));
}
private static function readPestVersion(string $projectRoot): string private static function readPestVersion(string $projectRoot): string
{ {
$installed = $projectRoot.'/vendor/composer/installed.json'; $installed = $projectRoot.'/vendor/composer/installed.json';