.json`. * - **Worker, replay**: nothing to do; args already narrowed. * * Guardrails * ---------- * - `--tia` combined with `--coverage` is refused: both paths drive the * same coverage driver and would corrupt each other's data. * - If no coverage driver is available during record, we skip gracefully; * the suite still runs normally. * - A stale recording SHA (rebase / force-push) triggers a rebuild. * * @internal */ final class Tia implements AddsOutput, HandlesArguments, Terminable { use Concerns\HandleArguments; private const string OPTION = '--tia'; private const string REBUILD_OPTION = '--tia-rebuild'; /** * TIA cache lives inside Pest's `.temp/` directory (same location as * PHPUnit's result cache). This directory is gitignored by default in * Pest's own `.gitignore`, so the graph is never committed. */ private const string TEMP_DIR = __DIR__ .DIRECTORY_SEPARATOR.'..' .DIRECTORY_SEPARATOR.'..' .DIRECTORY_SEPARATOR.'.temp'; private const string CACHE_FILE = 'tia.json'; private const string AFFECTED_FILE = 'tia-affected.json'; private const string WORKER_PREFIX = 'tia-worker-'; /** * Global flag toggled by the parent process so workers know to record. */ private const string RECORDING_GLOBAL = 'TIA_RECORDING'; /** * Global flag that tells workers to install the TIA filter (replay mode). * Workers read the affected set from `.temp/tia-affected.json`. */ private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; private bool $graphWritten = false; private bool $replayRan = false; /** * Counts cache hits during a replay run. Incremented each time * `getCachedResult()` returns a non-null status so the end-of-run * summary reflects what actually happened, not a graph-level estimate. */ private int $replayedCount = 0; /** * Captured at replay setup so the end-of-run summary can report the * scope of the changes that drove the run. */ private int $changedFileCount = 0; /** * Captured at replay setup — number of tests the graph flagged as * affected (i.e. should re-execute). May overshoot the actually- * executed count when the user narrows with a path filter. */ private int $affectedTestCount = 0; /** * Holds the graph during replay so `beforeEach` can look up cached * results without re-loading from disk on every test. */ private ?Graph $replayGraph = null; /** * Current git branch (or `HEAD` SHA when detached). Resolved once per * run so all graph accesses use the same branch key. */ private string $branch = 'main'; /** * Test files that are affected (should re-execute). Keyed by * project-relative path. Set during `enterReplayMode`. * * @var array */ private array $affectedFiles = []; private static function tempDir(): string { $dir = (string) realpath(self::TEMP_DIR); if ($dir === '' || $dir === '.') { // .temp doesn't exist yet — create it. @mkdir(self::TEMP_DIR, 0755, true); $dir = (string) realpath(self::TEMP_DIR); } return $dir; } private static function cachePath(): string { return self::tempDir().DIRECTORY_SEPARATOR.self::CACHE_FILE; } private static function affectedPath(): string { return self::tempDir().DIRECTORY_SEPARATOR.self::AFFECTED_FILE; } private static function workerPath(string $token): string { return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.$token.'.json'; } private static function workerGlob(): string { return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.'*.json'; } public function __construct( private readonly OutputInterface $output, private readonly Recorder $recorder, private readonly WatchPatterns $watchPatterns, ) {} /** * Returns the cached result for the given test, or `null` if the test * must run (affected, unknown, or no replay mode active). */ public function getCachedResult(string $filename, string $testId): ?TestStatus { if ($this->replayGraph === null) { return null; } // Resolve file to project-relative path. $projectRoot = TestSuite::getInstance()->rootPath; $real = @realpath($filename); $rel = $real !== false ? str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen(rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR))) : null; // Affected files must re-execute. if ($rel !== null && isset($this->affectedFiles[$rel])) { return null; } // Unknown files (not in graph) must execute — they're new. if ($rel === null || ! $this->replayGraph->knowsTest($rel)) { return null; } // Known + unaffected: return cached result if we have one for this // branch (falls back to main if branch is fresh). $result = $this->replayGraph->getResult($this->branch, $testId); if ($result !== null) { $this->replayedCount++; } return $result; } /** * {@inheritDoc} */ public function handleArguments(array $arguments): array { $isWorker = Parallel::isWorker(); $recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'; $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1'; $enabled = $this->hasArgument(self::OPTION, $arguments); $forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments); if (! $enabled && ! $forceRebuild && ! $recordingGlobal && ! $replayingGlobal) { return $arguments; } $arguments = $this->popArgument(self::OPTION, $arguments); $arguments = $this->popArgument(self::REBUILD_OPTION, $arguments); if ($this->coverageReportActive()) { if (! $isWorker) { $this->output->writeln( ' TIA `--coverage` is active — TIA disabled to avoid '. 'conflicting with PHPUnit\'s own coverage collection.', ); } return $arguments; } $projectRoot = TestSuite::getInstance()->rootPath; if ($isWorker) { return $this->handleWorker($arguments, $projectRoot, $recordingGlobal, $replayingGlobal); } return $this->handleParent($arguments, $projectRoot, $forceRebuild); } public function terminate(): void { if ($this->graphWritten) { return; } $recorder = $this->recorder; if (! $recorder->isActive()) { return; } $this->graphWritten = true; $projectRoot = TestSuite::getInstance()->rootPath; $perTest = $recorder->perTestFiles(); if ($perTest === []) { $recorder->reset(); return; } if (Parallel::isWorker()) { $this->flushWorkerPartial($projectRoot, $perTest); $recorder->reset(); return; } // Non-parallel record path: straight into the main cache. $cachePath = self::cachePath(); $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); $graph->setFingerprint(Fingerprint::compute($projectRoot)); $graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha()); $graph->replaceEdges($perTest); $graph->pruneMissingTests(); if (! $graph->save($cachePath)) { $this->output->writeln(' TIA failed to write graph to '.$cachePath); $recorder->reset(); return; } $this->output->writeln(sprintf( ' TIA graph recorded (%d test files) at %s', count($perTest), self::CACHE_FILE, )); $recorder->reset(); } /** * Runs after paratest finishes in the parent process. If we were * recording across workers, merge their partial graphs into the main * cache now. */ public function addOutput(int $exitCode): int { if (Parallel::isWorker()) { return $exitCode; } // After a successful replay run, advance the recorded SHA to HEAD // so the next run only diffs against what changed since NOW, not // since the original recording. Without this, re-running `--tia` // twice in a row would re-execute the same affected tests both // times even though nothing new changed. if ($this->replayRan) { $this->bumpRecordedSha(); $this->emitReplaySummary(); } // Snapshot per-test results (status + message) from PHPUnit's result // cache into our graph so future replay runs can faithfully reproduce // pass/fail/skip/todo/incomplete for unaffected tests. $this->snapshotTestResults(); if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') { return $exitCode; } $projectRoot = TestSuite::getInstance()->rootPath; $partials = $this->collectWorkerPartials($projectRoot); if ($partials === []) { return $exitCode; } $cachePath = self::cachePath(); $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); $graph->setFingerprint(Fingerprint::compute($projectRoot)); $graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha()); $merged = []; foreach ($partials as $partialPath) { $data = $this->readPartial($partialPath); if ($data === null) { continue; } foreach ($data as $testFile => $sources) { if (! isset($merged[$testFile])) { $merged[$testFile] = []; } foreach ($sources as $source) { $merged[$testFile][$source] = true; } } @unlink($partialPath); } $finalised = []; foreach ($merged as $testFile => $sourceSet) { $finalised[$testFile] = array_keys($sourceSet); } $graph->replaceEdges($finalised); $graph->pruneMissingTests(); if (! $graph->save($cachePath)) { $this->output->writeln(' TIA failed to write graph to '.$cachePath); return $exitCode; } $this->output->writeln(sprintf( ' TIA graph recorded (%d test files, %d worker partials) at %s', count($finalised), count($partials), self::CACHE_FILE, )); return $exitCode; } /** * @param array $arguments * @return array */ private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array { // Initialise watch patterns (defaults + any user additions from // tests/Pest.php which has already been loaded by BootFiles at // this point). $this->watchPatterns->useDefaults($projectRoot); // Resolve current branch once per run so every baseline lookup uses // the same key. Detached HEAD (or no git) falls back to `main` as // the implicit branch identity. $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main'; $cachePath = self::cachePath(); $fingerprint = Fingerprint::compute($projectRoot); $graph = $forceRebuild ? null : Graph::load($projectRoot, $cachePath); 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) { $changedFiles = new ChangedFiles($projectRoot); $branchSha = $graph->recordedAtSha($this->branch); if ($changedFiles->gitAvailable() && $branchSha !== null && $changedFiles->since($branchSha) === null) { $this->output->writeln( ' TIA recorded commit is no longer reachable — graph will be rebuilt.', ); $graph = null; } } if ($graph instanceof Graph) { return $this->enterReplayMode($graph, $projectRoot, $arguments); } return $this->enterRecordMode($projectRoot, $arguments); } /** * @param array $arguments * @return array */ private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array { $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main'; if ($replayingGlobal) { // Replay in a worker: load the graph and the affected set that // the parent persisted, then install the per-file filter so // whichever tests paratest happens to hand this worker are // accepted / rejected consistently with the series path. $this->installWorkerReplay($projectRoot); return $arguments; } if (! $recordingGlobal) { return $arguments; } $recorder = $this->recorder; if (! $recorder->driverAvailable()) { // Driver availability is per-process. If the driver is missing // here, silently skip — the parent has already warned during // its own boot. return $arguments; } $recorder->activate(); return $arguments; } /** * Wires worker-side replay. Mirrors the series path: sets `replayGraph` * + `affectedFiles` so the `BeforeEachable` hook in `beforeEach()` can * answer per-test. Unaffected tests replay their cached status (pass, * fail, skip, todo, incomplete) so the user sees the full suite report * in parallel runs exactly like in series. */ private function installWorkerReplay(string $projectRoot): void { $cachePath = self::cachePath(); $affectedPath = self::affectedPath(); $graph = Graph::load($projectRoot, $cachePath); if (! $graph instanceof Graph) { return; } $raw = @file_get_contents($affectedPath); if ($raw === false) { return; } $decoded = json_decode($raw, true); if (! is_array($decoded)) { return; } $affectedSet = []; foreach ($decoded as $rel) { if (is_string($rel)) { $affectedSet[$rel] = true; } } $this->replayGraph = $graph; $this->affectedFiles = $affectedSet; } /** * @param array $arguments * @return array */ private function enterReplayMode(Graph $graph, string $projectRoot, array $arguments): array { $changedFiles = new ChangedFiles($projectRoot); if (! $changedFiles->gitAvailable()) { $this->output->writeln( ' TIA git unavailable — running full suite.', ); return $arguments; } $changed = $changedFiles->since($graph->recordedAtSha($this->branch)) ?? []; // Drop files whose content hash matches the last-run snapshot. This // is the "dirty but identical" filter: if a file is uncommitted but // its content hasn't moved since the last `--tia` invocation, its // dependents already re-ran last time and don't need re-running // again. $changed = $changedFiles->filterUnchangedSinceLastRun($changed, $graph->lastRunTree($this->branch)); $affected = $changed === [] ? [] : $graph->affected($changed); $this->changedFileCount = count($changed); $this->affectedTestCount = count($affected); $affectedSet = array_fill_keys($affected, true); $this->replayRan = true; $this->replayGraph = $graph; $this->affectedFiles = $affectedSet; if (! Parallel::isEnabled()) { return $arguments; } // Parallel: persist affected set so workers can install the filter. if (! $this->persistAffectedSet($projectRoot, $affected)) { $this->output->writeln( ' TIA failed to persist affected set — running full suite.', ); return $arguments; } Parallel::setGlobal(self::REPLAYING_GLOBAL, '1'); return $arguments; } /** * @param array $affected Project-relative paths. */ private function persistAffectedSet(string $projectRoot, array $affected): bool { $path = self::affectedPath(); $dir = dirname($path); if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { return false; } $json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES); if ($json === false) { return false; } $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; if (@file_put_contents($tmp, $json) === false) { return false; } if (! @rename($tmp, $path)) { @unlink($tmp); return false; } return true; } /** * @param array $arguments * @return array */ private function enterRecordMode(string $projectRoot, array $arguments): array { if (Parallel::isEnabled()) { // Parent driving `--parallel`: workers will do the actual // recording. We only advertise the intent through a global. // Clean up any stale partial files from a previous interrupted // run so the merge step doesn't confuse itself. $this->purgeWorkerPartials($projectRoot); Parallel::setGlobal(self::RECORDING_GLOBAL, '1'); $this->output->writeln( ' TIA recording dependency graph in parallel (first run) — '. 'subsequent `--tia` runs will only re-execute affected tests.', ); return $arguments; } $recorder = $this->recorder; if (! $recorder->driverAvailable()) { $this->output->writeln([ '', ' ERROR No coverage driver is available.', '', ' TIA requires ext-pcov or Xdebug with coverage mode enabled to', ' record the dependency graph. Install one and rerun with `--tia`.', '', ]); exit(1); } $recorder->activate(); $this->output->writeln(sprintf( ' TIA recording dependency graph via %s (first run) — '. 'subsequent `--tia` runs will only re-execute affected tests.', $recorder->driver(), )); return $arguments; } /** * @param array> $perTest */ private function flushWorkerPartial(string $projectRoot, array $perTest): void { $token = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? getmypid(); // Defensive: token might arrive as int or string depending on paratest // version. Cast + filter to keep filenames sane. $token = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $token); if ($token === '') { $token = (string) getmypid(); } $path = self::workerPath($token); $dir = dirname($path); if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { return; } $json = json_encode($perTest, JSON_UNESCAPED_SLASHES); if ($json === false) { return; } $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; if (@file_put_contents($tmp, $json) === false) { return; } if (! @rename($tmp, $path)) { @unlink($tmp); } } /** * @return array */ private function collectWorkerPartials(string $projectRoot): array { $pattern = self::workerGlob(); $matches = glob($pattern); return $matches === false ? [] : $matches; } private function purgeWorkerPartials(string $projectRoot): void { foreach ($this->collectWorkerPartials($projectRoot) as $path) { @unlink($path); } } /** * @return array>|null */ private function readPartial(string $path): ?array { $raw = @file_get_contents($path); if ($raw === false) { return null; } $data = json_decode($raw, true); if (! is_array($data)) { return null; } $out = []; foreach ($data as $test => $sources) { if (! is_string($test)) { continue; } if (! is_array($sources)) { continue; } $clean = []; foreach ($sources as $source) { if (is_string($source)) { $clean[] = $source; } } $out[$test] = $clean; } return $out; } /** * After a successful replay, bump the graph's `recorded_at_sha` to the * current HEAD. This way the next `--tia` run diffs only against what * changed since THIS run, not since the original recording. * * The graph edges themselves are untouched — only the SHA marker moves. */ /** * After a successful replay, advance the baseline: bump `recorded_at_sha` * to the current HEAD (handles committed changes) and snapshot the * working tree's content hashes (handles uncommitted changes). Next run * compares against this baseline so identical files are skipped even if * git still reports them as modified. */ /** * Prints the post-run TIA summary. Runs after the test report so the * replayed count reflects what actually happened (cache hits counted * inside `getCachedResult`) rather than a graph-level estimate that * ignores any CLI path filter the user passed in. */ private function emitReplaySummary(): void { $this->output->writeln(sprintf( ' TIA %d changed file(s) → %d affected, %d replayed.', $this->changedFileCount, $this->affectedTestCount, $this->replayedCount, )); } private function bumpRecordedSha(): void { $projectRoot = TestSuite::getInstance()->rootPath; $cachePath = self::cachePath(); $graph = Graph::load($projectRoot, $cachePath); if (! $graph instanceof Graph) { return; } $changedFiles = new ChangedFiles($projectRoot); $currentSha = $changedFiles->currentSha(); if ($currentSha !== null) { $graph->setRecordedAtSha($this->branch, $currentSha); } // Snapshot the working tree: hash every currently-modified file. // On next run, files still appearing as modified but whose hash // matches this snapshot are treated as unchanged. $workingTreeFiles = $changedFiles->since($currentSha) ?? []; $graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles)); $graph->save($cachePath); } /** * Merges per-test status + message from the `ResultCollector` into the * TIA graph. Runs after every `--tia` invocation so the graph always has * fresh results for faithful replay (pass, fail, skip, todo, etc.). */ private function snapshotTestResults(): void { /** @var ResultCollector $collector */ $collector = Container::getInstance()->get(ResultCollector::class); $results = $collector->all(); if ($results === []) { return; } $cachePath = self::cachePath(); $projectRoot = TestSuite::getInstance()->rootPath; $graph = Graph::load($projectRoot, $cachePath); if (! $graph instanceof Graph) { return; } foreach ($results as $testId => $result) { $graph->setResult($this->branch, $testId, $result['status'], $result['message'], $result['time']); } $graph->save($cachePath); $collector->reset(); } private function coverageReportActive(): bool { try { /** @var Coverage $coverage */ $coverage = Container::getInstance()->get(Coverage::class); } catch (Throwable) { return false; } return $coverage->coverage === true; } }