diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 4dc439e0..87eb8691 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -503,6 +503,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $finalised[$testFile] = array_keys($sourceSet); } + // Empty-edges guard: if every worker returned no edges it almost + // always means the coverage driver wasn't loaded in the workers + // (common footgun with custom PHP ini scan dirs, Herd profiles, + // stripped CI runners). Writing the empty graph would silently + // seed a broken baseline; fail loud instead. + if ($finalised === []) { + $this->output->writeln([ + '', + ' ERROR TIA recorded zero edges — coverage driver likely missing.', + ' Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.', + '', + ]); + + return $exitCode; + } + $graph->replaceEdges($finalised); $graph->pruneMissingTests(); @@ -527,6 +543,56 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return $exitCode; } + /** + * Compares a loaded graph's fingerprint to the current one and decides + * how much of the graph is still usable. + * + * - **Structural drift** (composer.lock, Pest.php, factory codegen, + * schema bump): edges themselves are potentially wrong → discard + * the whole graph + coverage cache and return null. Caller falls + * through to record mode. + * - **Environmental drift** (PHP minor, extension set, Pest version): + * edges describe the code correctly; only the cached per-test + * results were captured against a different runtime and might not + * reproduce. Drop `baselines[branch].results` + coverage cache, + * bump the fingerprint to the current env, persist. Caller uses + * the graph for edges; results refill naturally during this run's + * replay (every test misses cache, runs normally, seeds results). + * - **Match**: return the graph untouched. + * + * @param array{structural: array, environmental: array} $current + */ + private function reconcileFingerprint(Graph $graph, array $current): ?Graph + { + $stored = $graph->fingerprint(); + + if (! Fingerprint::structuralMatches($stored, $current)) { + $this->output->writeln( + ' TIA graph structure outdated — rebuilding.', + ); + $this->state->delete(self::KEY_GRAPH); + $this->state->delete(self::KEY_COVERAGE_CACHE); + + return null; + } + + $drift = Fingerprint::environmentalDrift($stored, $current); + + if ($drift !== []) { + $this->output->writeln(sprintf( + ' TIA env differs from baseline (%s) — results dropped, edges reused.', + implode(', ', $drift), + )); + + $graph->clearResults($this->branch); + $graph->setFingerprint($current); + $this->saveGraph($graph); + $this->state->delete(self::KEY_COVERAGE_CACHE); + } + + return $graph; + } + /** * @param array $arguments * @return array @@ -547,11 +613,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $graph = $forceRebuild ? null : $this->loadGraph($projectRoot); - if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) { - $this->output->writeln( - ' TIA environment fingerprint changed — graph will be rebuilt.', - ); - $graph = null; + if ($graph instanceof Graph) { + $graph = $this->reconcileFingerprint($graph, $fingerprint); } if ($graph instanceof Graph) { @@ -571,18 +634,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable // No local graph and not being forced to rebuild from scratch: try // to pull a team-shared baseline so fresh checkouts (new devs, CI // containers) don't pay the full record cost. If the pull succeeds - // the graph is re-read and re-validated against the local env. + // the graph is re-read and reconciled against the local env. if ($graph === null && ! $forceRebuild) { if ($this->baselineSync->fetchIfAvailable($projectRoot)) { $graph = $this->loadGraph($projectRoot); - if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) { - $this->output->writeln( - ' TIA pulled baseline fingerprint mismatch — discarding.', - ); - $this->state->delete(self::KEY_GRAPH); - $this->state->delete(self::KEY_COVERAGE_CACHE); - $graph = null; + if ($graph instanceof Graph) { + $graph = $this->reconcileFingerprint($graph, $fingerprint); } } } diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index a4a2de34..0cc743a2 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -5,54 +5,177 @@ declare(strict_types=1); namespace Pest\Plugins\Tia; /** - * Captures environmental inputs that, when changed, make the TIA graph stale. + * Captures environmental inputs that, when changed, may make the TIA graph + * or its recorded results stale. The fingerprint is split into two buckets: * - * Any drift in PHP version, Composer lock, or Pest/PHPUnit config can change - * what a test actually exercises, so the graph must be rebuilt in those cases. + * - **structural** — describes what the graph's *edges* were recorded + * against. If any of these drift (`composer.lock`, `tests/Pest.php`, + * Pest's factory codegen, etc.) the edges themselves are potentially + * wrong and the graph must rebuild from scratch. + * - **environmental** — describes the *runtime* the results were captured + * on (PHP minor, extension set, Pest version). Drift here means the + * edges are still trustworthy, but the cached per-test results (pass/ + * fail/time) may not reproduce on this machine. Tia's handler drops the + * branch's results + coverage cache and re-runs to freshen them, rather + * than re-recording from scratch. + * + * Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and + * rebuilt on first load; the schema bump in the structural bucket takes + * care of that automatically. * * @internal */ final readonly class Fingerprint { - // Bump this whenever the set of inputs or the hash algorithm changes, so - // older graphs are invalidated automatically. - private const int SCHEMA_VERSION = 3; + // Bump this whenever the set of inputs or the hash algorithm changes, + // so older graphs are invalidated automatically. + private const int SCHEMA_VERSION = 4; /** - * @return array + * @return array{ + * structural: array, + * environmental: array, + * } */ public static function compute(string $projectRoot): array { return [ - 'schema' => self::SCHEMA_VERSION, - 'php' => PHP_VERSION, - // Loaded extensions + their versions. Guards against the - // "recorded without pcov/xdebug → subsequent run has the - // driver but graph has no edges" trap where the fingerprint - // matches but the graph is effectively empty. Sorted so two - // processes with the same extensions in different load order - // still produce the same hash. - 'extensions' => self::extensionsFingerprint(), - 'pest' => self::readPestVersion($projectRoot), - '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'), + '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. Captures both presence (pcov - * disappeared? graph must rebuild) and version changes (xdebug minor - * bump with coverage-mode semantics). + * sorted alphabetically and joined. */ private static function extensionsFingerprint(): string { @@ -69,29 +192,6 @@ final readonly class Fingerprint return hash('xxh128', implode("\n", $parts)); } - /** - * @param array $a - * @param array $b - */ - public static function matches(array $a, array $b): bool - { - ksort($a); - ksort($b); - - return $a === $b; - } - - private static function hashIfExists(string $path): ?string - { - if (! is_file($path)) { - return null; - } - - $hash = @hash_file('xxh128', $path); - - return $hash === false ? null : $hash; - } - private static function readPestVersion(string $projectRoot): string { $installed = $projectRoot.'/vendor/composer/installed.json'; diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 64356312..d4ee674b 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -223,7 +223,7 @@ final class Graph } /** - * @param array $fingerprint + * @param array $fingerprint */ public function setFingerprint(array $fingerprint): void { @@ -231,7 +231,7 @@ final class Graph } /** - * @return array + * @return array */ public function fingerprint(): array { @@ -323,6 +323,20 @@ final class Graph $this->baselines[$branch]['tree'] = $tree; } + /** + * Wipes cached per-test results for the given branch. Edges and tree + * snapshot stay intact — the graph still describes the code correctly, + * only the "what happened last time" data is reset. Used on + * environmental fingerprint drift: the edges were recorded elsewhere + * (e.g. CI) so they're still valid, but the results aren't trustworthy + * on this machine until the tests re-run here. + */ + public function clearResults(string $branch): void + { + $this->ensureBaseline($branch); + $this->baselines[$branch]['results'] = []; + } + /** * @return array */