From 7250185423a239212c31f65bf21ba828f8698faa Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 27 Apr 2026 12:22:05 +0100 Subject: [PATCH] wip --- src/Plugins/Tia.php | 218 ++++++++++++++++++++++++++- src/Plugins/Tia/Fingerprint.php | 258 ++++++++++++++++++++++++++++++-- 2 files changed, 459 insertions(+), 17 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index c293eb60..38d3c45f 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -22,6 +22,7 @@ use Pest\Support\Container; use Pest\TestSuite; use PHPUnit\Framework\TestStatus\TestStatus; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; use Throwable; /** @@ -658,9 +659,50 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $stored = $graph->fingerprint(); if (! Fingerprint::structuralMatches($stored, $current)) { - $this->output->writeln( - ' TIA graph structure outdated — rebuilding.', - ); + $drift = Fingerprint::structuralDrift($stored, $current); + + $this->output->writeln(sprintf( + ' TIA graph structure outdated (%s).', + $this->formatStructuralDrift($drift), + )); + + // For composer.lock specifically, surface the actual + // package-version deltas. Saves the user a `git diff + // composer.lock | grep -E "name|version"` round-trip when + // a routine `composer update` invalidates the graph. + if (in_array('composer_lock', $drift, true)) { + $branchSha = $graph->recordedAtSha($this->branch); + if ($branchSha !== null) { + $summary = $this->composerLockDelta( + TestSuite::getInstance()->rootPath, + $branchSha, + ); + if ($summary !== '') { + $this->output->writeln(' '.$summary.''); + } + } + } + + // Try the remote baseline before paying for a local + // rebuild. CI runs the baseline workflow against every + // push to main, so the most common cause of structural + // drift (`composer update` landed on main, you pulled it, + // your branch hasn't diverged yet) is recoverable in + // ~5–30s of network instead of minutes of recording. + // + // Revalidation is the safety: even if the fetch succeeds, + // we only adopt the result when its stored fingerprint + // structurally matches the *current* one. A stale CI + // baseline (workflow hasn't run since the drift) gets + // dropped and we fall through to the local rebuild path. + $rebuilt = $this->tryRemoteBaselineForDrift($current); + + if ($rebuilt instanceof Graph) { + return $this->reconcileFingerprint($rebuilt, $current); + } + + $this->output->writeln(' TIA rebuilding graph from scratch.'); + $this->state->delete(self::KEY_GRAPH); $this->state->delete(self::KEY_COVERAGE_CACHE); @@ -1356,4 +1398,174 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return $coverage->coverage === true; } + + /** + * Attempts to short-circuit a structural-drift rebuild by fetching + * a fresh CI-recorded baseline. Returns the loaded `Graph` only if + * the fetched payload structurally matches the *current* fingerprint + * — i.e., CI has already recorded against the new shape and we can + * safely use those edges. Any other outcome (no GitHub remote, fetch + * cooldown, no successful CI run, fetched-graph-still-drifts) → null, + * caller falls back to local rebuild. + * + * @param array{structural: array, environmental: array} $current + */ + private function tryRemoteBaselineForDrift(array $current): ?Graph + { + $projectRoot = TestSuite::getInstance()->rootPath; + + if (! $this->baselineSync->fetchIfAvailable($projectRoot, false)) { + return null; + } + + $fetched = $this->loadGraph($projectRoot); + + if (! $fetched instanceof Graph) { + return null; + } + + if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) { + $this->output->writeln( + ' TIA fetched baseline still drifts — discarding.', + ); + + return null; + } + + $this->output->writeln( + ' TIA fetched baseline matches — skipping local rebuild.', + ); + + return $fetched; + } + + /** + * Maps `Fingerprint::structuralDrift()` field names to a human + * label suitable for the `(reason)` part of the rebuild banner. + * + * @param list $drift + */ + private function formatStructuralDrift(array $drift): string + { + static $labels = [ + 'composer_lock' => 'composer.lock', + 'composer_json' => 'composer.json', + 'phpunit_xml' => 'phpunit.xml', + 'phpunit_xml_dist' => 'phpunit.xml.dist', + 'vite_config' => 'vite.config', + 'pest_factory' => 'Pest internals', + 'pest_method_factory' => 'Pest internals', + ]; + + $seen = []; + foreach ($drift as $key) { + $seen[$labels[$key] ?? $key] = true; + } + + if ($seen === []) { + return 'unknown'; + } + + return implode(', ', array_keys($seen)); + } + + /** + * Diffs `composer.lock` between the recorded SHA and the current + * working tree, returns a one-line summary like: + * + * "laravel/framework 12.30 → 12.31, + pestphp/pest 4.7" + * + * Empty string when git is unavailable, the sha doesn't have the + * file, the file can't be parsed, or there are no version + * deltas (a content-hash-only edit, vendor URL change, etc.). + */ + private function composerLockDelta(string $projectRoot, string $sha): string + { + $current = @file_get_contents($projectRoot.'/composer.lock'); + if ($current === false) { + return ''; + } + + $process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot); + $process->setTimeout(5.0); + $process->run(); + + if (! $process->isSuccessful()) { + return ''; + } + + $oldVersions = $this->lockVersions($process->getOutput()); + $newVersions = $this->lockVersions($current); + + if ($oldVersions === [] && $newVersions === []) { + return ''; + } + + $changes = []; + foreach ($newVersions as $name => $version) { + if (! isset($oldVersions[$name])) { + $changes[] = '+ '.$name.' '.$version; + } elseif ($oldVersions[$name] !== $version) { + $changes[] = $name.' '.$oldVersions[$name].' → '.$version; + } + } + foreach ($oldVersions as $name => $version) { + if (! isset($newVersions[$name])) { + $changes[] = '− '.$name.' '.$version; + } + } + + if ($changes === []) { + return ''; + } + + sort($changes); + + // Cap at a sensible number — a wholesale `composer update` + // could list 50+ packages and bury the prompt. + $maxShown = 8; + if (count($changes) > $maxShown) { + $extra = count($changes) - $maxShown; + $changes = array_slice($changes, 0, $maxShown); + $changes[] = sprintf('… +%d more', $extra); + } + + return implode(', ', $changes); + } + + /** + * @return array package name → version + */ + private function lockVersions(string $json): array + { + $data = json_decode($json, true); + + if (! is_array($data)) { + return []; + } + + $out = []; + + foreach (['packages', 'packages-dev'] as $section) { + if (! isset($data[$section])) { + continue; + } + if (! is_array($data[$section])) { + continue; + } + foreach ($data[$section] as $package) { + if (! is_array($package)) { + continue; + } + $name = $package['name'] ?? null; + $version = $package['version'] ?? null; + + if (is_string($name) && is_string($version)) { + $out[$name] = $version; + } + } + } + + return $out; + } } diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 312bcdb9..470e99c1 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -73,7 +73,13 @@ final readonly class Fingerprint // watch pattern + `Recorder::linkAncestorFiles` reflection // walk, which gives precise per-test invalidation rather // than a wholesale rebuild that trashes the entire graph. - private const int SCHEMA_VERSION = 11; + // v12: PHP/JS structural inputs (pest_factory*, vite.config.*) + // now hash via `ContentHash::of()` so cosmetic comment + + // whitespace edits don't fire rebuilds. composer.json and + // composer.lock hash a behavioural subset — description, + // keywords, scripts, authors, install timestamps, dist + // URLs etc. no longer drift the structural fingerprint. + private const int SCHEMA_VERSION = 12; /** * @return array{ @@ -86,28 +92,35 @@ final readonly class Fingerprint return [ 'structural' => [ 'schema' => self::SCHEMA_VERSION, - 'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'), + // `composer.lock` hashed against a *behavioural* + // subset (per-package version + reference + autoload + + // extra). Skips per-package install timestamps, dist + // URLs, support links, descriptions — none of which + // affect what code runs. + 'composer_lock' => self::composerLockHash($projectRoot), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), // 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'), + // stale. Hashing via `ContentHash::of()` so cosmetic edits + // (comments, formatting) don't drift the fingerprint. + 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'), + 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'), // `vite.config.*` reshapes the module graph // `JsModuleGraph` records at the next `--tia` run; if // the config drifts without a rebuild, the stored // `$jsFileToComponents` map is silently stale. + // `viteConfigHash` itself uses `ContentHash::of()` so + // a comment-only edit to vite.config doesn't rebuild. 'vite_config' => self::viteConfigHash($projectRoot), - // `composer.json` carries `autoload-dev`, `extra.laravel` - // package discovery, etc. — any change reshapes which - // classes Pest can resolve at boot. Hashing the whole - // file is over-conservative (cosmetic edits force - // rebuild) but cheap, and over-rebuild is always safe. - 'composer_json' => self::hashIfExists($projectRoot.'/composer.json'), + // `composer.json` hashed against a behavioural subset: + // autoload(-dev), require(-dev), extra (Laravel + // package discovery), repositories, minimum-stability, + // and the platform / allow-plugins entries from + // `config`. Cosmetic fields (description, keywords, + // scripts, authors, funding, support) are excluded. + 'composer_json' => self::composerJsonHash($projectRoot), ], 'environmental' => [ // PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch @@ -137,6 +150,45 @@ final readonly class Fingerprint return $aStructural === $bStructural; } + /** + * Returns the list of structural field names that drifted between + * the stored and current fingerprints. Empty list = no drift. + * Caller uses this to tell the user *why* the graph rebuilt — a + * generic "graph outdated" message leaves people staring at + * unrelated diffs. + * + * @param array $stored + * @param array $current + * @return list + */ + public static function structuralDrift(array $stored, array $current): array + { + $a = self::structuralOnly($stored); + $b = self::structuralOnly($current); + + $drifts = []; + + foreach ($a as $key => $value) { + if ($key === 'schema') { + continue; + } + if (($b[$key] ?? null) !== $value) { + $drifts[] = $key; + } + } + + foreach ($b as $key => $value) { + if ($key === 'schema') { + continue; + } + if (! array_key_exists($key, $a) && $value !== null) { + $drifts[] = $key; + } + } + + return array_values(array_unique($drifts)); + } + /** * Returns a list of field names that drifted between the stored and * current environmental fingerprints. Empty list = no drift. Caller @@ -227,7 +279,7 @@ final readonly class Fingerprint $parts = []; foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) { - $hash = self::hashIfExists($projectRoot.'/'.$name); + $hash = self::contentHashOrNull($projectRoot.'/'.$name); if ($hash !== null) { $parts[] = $name.':'.$hash; @@ -237,6 +289,184 @@ final readonly class Fingerprint return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); } + /** + * Behavioural subset of `composer.json`. Keeps the keys that + * actually move test outcomes (autoload, require, extra, + * repositories, minimum-stability, platform / allow-plugins + * config) and drops cosmetic ones (description, keywords, + * scripts, authors, funding, homepage, support). Falls back to + * a raw hash on parse errors so any change still rebuilds. + */ + private static function composerJsonHash(string $projectRoot): ?string + { + $path = $projectRoot.'/composer.json'; + + if (! is_file($path)) { + return null; + } + + $raw = @file_get_contents($path); + + if ($raw === false) { + return null; + } + + $data = json_decode($raw, true); + + if (! is_array($data)) { + $hash = @hash_file('xxh128', $path); + + return $hash === false ? null : $hash; + } + + $config = is_array($data['config'] ?? null) ? $data['config'] : []; + $relevantConfig = array_intersect_key($config, [ + 'platform' => true, + 'allow-plugins' => true, + ]); + + $relevant = [ + 'autoload' => $data['autoload'] ?? null, + 'autoload-dev' => $data['autoload-dev'] ?? null, + 'require' => $data['require'] ?? null, + 'require-dev' => $data['require-dev'] ?? null, + 'extra' => $data['extra'] ?? null, + 'repositories' => $data['repositories'] ?? null, + 'minimum-stability' => $data['minimum-stability'] ?? null, + 'prefer-stable' => $data['prefer-stable'] ?? null, + 'config' => $relevantConfig === [] ? null : $relevantConfig, + ]; + + self::sortRecursively($relevant); + + $json = json_encode($relevant); + + return $json === false ? null : hash('xxh128', $json); + } + + /** + * Behavioural subset of `composer.lock`. For every package in + * `packages` and `packages-dev`, keeps version + dist/source + * reference (commit SHA — catches dev-branch updates that don't + * bump the version string) + autoload(-dev) + extra (Laravel + * package discovery). Drops install timestamps, dist URLs, + * support links, descriptions, etc. — none of which change what + * code runs. + */ + private static function composerLockHash(string $projectRoot): ?string + { + $path = $projectRoot.'/composer.lock'; + + if (! is_file($path)) { + return null; + } + + $raw = @file_get_contents($path); + + if ($raw === false) { + return null; + } + + $data = json_decode($raw, true); + + if (! is_array($data)) { + $hash = @hash_file('xxh128', $path); + + return $hash === false ? null : $hash; + } + + $relevant = [ + 'platform' => $data['platform'] ?? null, + 'platform-dev' => $data['platform-dev'] ?? null, + ]; + + foreach (['packages', 'packages-dev'] as $section) { + if (! isset($data[$section])) { + continue; + } + if (! is_array($data[$section])) { + continue; + } + $packages = []; + + foreach ($data[$section] as $package) { + if (! is_array($package)) { + continue; + } + + $name = $package['name'] ?? null; + + if (! is_string($name)) { + continue; + } + + $packages[$name] = [ + 'version' => $package['version'] ?? null, + 'reference' => self::lockReference($package), + 'autoload' => $package['autoload'] ?? null, + 'autoload-dev' => $package['autoload-dev'] ?? null, + 'extra' => $package['extra'] ?? null, + ]; + } + + ksort($packages); + $relevant[$section] = $packages; + } + + self::sortRecursively($relevant); + + $json = json_encode($relevant); + + return $json === false ? null : hash('xxh128', $json); + } + + /** + * @param array $package + */ + private static function lockReference(array $package): ?string + { + $dist = is_array($package['dist'] ?? null) ? $package['dist'] : []; + $source = is_array($package['source'] ?? null) ? $package['source'] : []; + + $reference = $dist['reference'] ?? $source['reference'] ?? null; + + return is_string($reference) ? $reference : null; + } + + /** + * Recursively sorts associative arrays by key so semantically + * equivalent JSON produces the same hash regardless of key + * ordering. Lists (numeric arrays) keep their order — they're + * meaningful in `repositories`, `autoload.files`, etc. + */ + private static function sortRecursively(mixed &$value): void + { + if (! is_array($value)) { + return; + } + + $isAssoc = ! array_is_list($value); + + if ($isAssoc) { + ksort($value); + } + + foreach ($value as &$child) { + self::sortRecursively($child); + } + } + + private static function contentHashOrNull(string $path): ?string + { + if (! is_file($path)) { + return null; + } + + $hash = ContentHash::of($path); + + return $hash === false ? null : $hash; + } + private static function hashIfExists(string $path): ?string { if (! is_file($path)) {