, * environmental: array, * } */ public static function compute(string $projectRoot): array { return [ 'structural' => [ 'schema' => self::SCHEMA_VERSION, 'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), 'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'), // Pest's generated classes bake the code-generation logic // in — if TestCaseFactory changes (new attribute, different // method signature, etc.) every previously-recorded edge is // stale. Hashing the factory sources makes path-repo / // dev-main installs automatically rebuild their graphs when // Pest itself is edited. 'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'), 'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'), ], 'environmental' => [ // PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch // almost never matches a dev's Herd/Homebrew install, and // the patch rarely changes anything test-visible. 'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION, 'extensions' => self::extensionsFingerprint(), 'pest' => self::readPestVersion($projectRoot), ], ]; } /** * True when the structural buckets match. Drift here means the edges * are potentially wrong; caller should discard the graph and rebuild. * * @param array $a * @param array $b */ public static function structuralMatches(array $a, array $b): bool { $aStructural = self::structuralOnly($a); $bStructural = self::structuralOnly($b); ksort($aStructural); ksort($bStructural); return $aStructural === $bStructural; } /** * Returns a list of field names that drifted between the stored and * current environmental fingerprints. Empty list = no drift. Caller * uses this to print a human-readable warning and to decide whether * per-test results should be dropped (any drift → yes). * * @param array $stored * @param array $current * @return list */ public static function environmentalDrift(array $stored, array $current): array { $a = self::environmentalOnly($stored); $b = self::environmentalOnly($current); $drifts = []; foreach ($a as $key => $value) { if (($b[$key] ?? null) !== $value) { $drifts[] = $key; } } foreach ($b as $key => $value) { if (! array_key_exists($key, $a) && $value !== null) { $drifts[] = $key; } } return array_values(array_unique($drifts)); } /** * @param array $fingerprint * @return array */ private static function structuralOnly(array $fingerprint): array { return self::bucket($fingerprint, 'structural'); } /** * @param array $fingerprint * @return array */ private static function environmentalOnly(array $fingerprint): array { return self::bucket($fingerprint, 'environmental'); } /** * Returns `$fingerprint[$key]` as an `array` if it exists * and is an array, otherwise empty. Legacy flat-shape fingerprints * (schema ≤ 3) return empty here, which makes `structuralMatches` fail * and the caller rebuild — the clean migration path. * * @param array $fingerprint * @return array */ private static function bucket(array $fingerprint, string $key): array { $raw = $fingerprint[$key] ?? null; if (! is_array($raw)) { return []; } $normalised = []; foreach ($raw as $k => $v) { if (is_string($k)) { $normalised[$k] = $v; } } return $normalised; } private static function hashIfExists(string $path): ?string { if (! is_file($path)) { return null; } $hash = @hash_file('xxh128', $path); return $hash === false ? null : $hash; } /** * Deterministic hash of the PHP extension set: `ext-name@version` pairs * sorted alphabetically and joined. */ private static function extensionsFingerprint(): string { $extensions = get_loaded_extensions(); sort($extensions); $parts = []; foreach ($extensions as $name) { $version = phpversion($name); $parts[] = $name.'@'.($version === false ? '?' : $version); } return hash('xxh128', implode("\n", $parts)); } private static function readPestVersion(string $projectRoot): string { $installed = $projectRoot.'/vendor/composer/installed.json'; if (! is_file($installed)) { return 'unknown'; } $raw = @file_get_contents($installed); if ($raw === false) { return 'unknown'; } $data = json_decode($raw, true); if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) { return 'unknown'; } foreach ($data['packages'] as $package) { if (is_array($package) && ($package['name'] ?? null) === 'pestphp/pest') { return (string) ($package['version'] ?? 'unknown'); } } return 'unknown'; } }