diff --git a/composer.json b/composer.json index 6557a253..4b2c4e28 100644 --- a/composer.json +++ b/composer.json @@ -123,6 +123,7 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php index 7877b237..749a6b5d 100644 --- a/src/Bootstrappers/BootSubscribers.php +++ b/src/Bootstrappers/BootSubscribers.php @@ -25,6 +25,8 @@ final readonly class BootSubscribers implements Bootstrapper Subscribers\EnsureIgnorableTestCasesAreIgnored::class, Subscribers\EnsureKernelDumpIsFlushed::class, Subscribers\EnsureTeamCityEnabled::class, + Subscribers\EnsureTiaCoverageIsRecorded::class, + Subscribers\EnsureTiaCoverageIsFlushed::class, ]; /** diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php new file mode 100644 index 00000000..db9d0978 --- /dev/null +++ b/src/Plugins/Tia.php @@ -0,0 +1,629 @@ +.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'; + + private const string CACHE_PATH = '.pest/cache/tia.json'; + + private const string AFFECTED_PATH = '.pest/cache/tia-affected.json'; + + private const string WORKER_CACHE_PREFIX = '.pest/cache/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 `.pest/cache/tia-affected.json`. + */ + private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; + + private bool $graphWritten = false; + + public function __construct(private readonly OutputInterface $output) {} + + /** + * {@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 = Recorder::instance(); + + 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 = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; + + $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); + $graph->setFingerprint(Fingerprint::compute($projectRoot)); + $graph->setRecordedAtSha((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_PATH, + )); + + $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; + } + + if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') { + return $exitCode; + } + + $projectRoot = TestSuite::getInstance()->rootPath; + $partials = $this->collectWorkerPartials($projectRoot); + + if ($partials === []) { + return $exitCode; + } + + $cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; + + $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); + $graph->setFingerprint(Fingerprint::compute($projectRoot)); + $graph->setRecordedAtSha((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_PATH, + )); + + return $exitCode; + } + + /** + * @param array $arguments + * @return array + */ + private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array + { + $cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; + $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); + + if ($changedFiles->gitAvailable() + && $graph->recordedAtSha() !== null + && $changedFiles->since($graph->recordedAtSha()) === 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 + { + 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->installWorkerReplayFilter($projectRoot); + + return $arguments; + } + + if (! $recordingGlobal) { + return $arguments; + } + + $recorder = Recorder::instance(); + + 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; + } + + private function installWorkerReplayFilter(string $projectRoot): void + { + $cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; + $affectedPath = $projectRoot.DIRECTORY_SEPARATOR.self::AFFECTED_PATH; + + $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; + } + } + + TestSuite::getInstance()->tests->addTestCaseFilter( + new TiaTestCaseFilter($projectRoot, $graph, $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()) ?? []; + + if ($changed === []) { + $this->output->writeln(' TIA no changes detected.'); + + Panic::with(new NoDirtyTestsFound); + } + + $affected = $graph->affected($changed); + + $testSuite = TestSuite::getInstance(); + + if (! Parallel::isEnabled()) { + // Series mode: install the TestCaseFilter so Pest/PHPUnit skips + // unaffected tests during discovery. Keep filter semantics + // identical to parallel mode: unknown/new tests always pass. + $affectedSet = array_fill_keys($affected, true); + + $testSuite->tests->addTestCaseFilter( + new TiaTestCaseFilter($projectRoot, $graph, $affectedSet), + ); + + $this->output->writeln(sprintf( + ' TIA %d changed file(s) → %d known test file(s) + any new/unknown tests.', + count($changed), + count($affected), + )); + + return $arguments; + } + + // Parallel mode. Paratest's CLI only accepts a single positional + // ``, so we cannot pass the affected set as multiple args. + // Instead, persist the affected set to a cache file and flip a + // global that tells each worker to install the TIA filter on boot. + // + // Cost trade-off: each worker still discovers the full test tree, + // but the filter drops unaffected tests before they ever run. Narrow + // CLI handoff would be ideal; it requires generating a temporary + // phpunit.xml and is out of scope for the MVP. + 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'); + + $this->output->writeln(sprintf( + ' TIA %d changed file(s) → %d known test file(s) + any new/unknown tests (parallel).', + count($changed), + count($affected), + )); + + return $arguments; + } + + /** + * @param array $affected Project-relative paths. + */ + private function persistAffectedSet(string $projectRoot, array $affected): bool + { + $path = $projectRoot.DIRECTORY_SEPARATOR.self::AFFECTED_PATH; + $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 = Recorder::instance(); + + if (! $recorder->driverAvailable()) { + $this->output->writeln( + ' TIA No coverage driver is available. '. + 'Install ext-pcov or enable Xdebug in coverage mode, then rerun with `--tia`.', + ); + + return $arguments; + } + + $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 = $projectRoot.DIRECTORY_SEPARATOR.self::WORKER_CACHE_PREFIX.$token.'.json'; + $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 = $projectRoot.DIRECTORY_SEPARATOR.self::WORKER_CACHE_PREFIX.'*.json'; + $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) || ! is_array($sources)) { + continue; + } + + $clean = []; + + foreach ($sources as $source) { + if (is_string($source)) { + $clean[] = $source; + } + } + + $out[$test] = $clean; + } + + return $out; + } + + private function coverageReportActive(): bool + { + try { + /** @var Coverage $coverage */ + $coverage = Container::getInstance()->get(Coverage::class); + } catch (Throwable) { + return false; + } + + return property_exists($coverage, 'coverage') && $coverage->coverage === true; + } +} diff --git a/src/Subscribers/EnsureTiaCoverageIsFlushed.php b/src/Subscribers/EnsureTiaCoverageIsFlushed.php new file mode 100644 index 00000000..0ccdddc1 --- /dev/null +++ b/src/Subscribers/EnsureTiaCoverageIsFlushed.php @@ -0,0 +1,23 @@ +endTest(); + } +} diff --git a/src/Subscribers/EnsureTiaCoverageIsRecorded.php b/src/Subscribers/EnsureTiaCoverageIsRecorded.php new file mode 100644 index 00000000..6be7bdd7 --- /dev/null +++ b/src/Subscribers/EnsureTiaCoverageIsRecorded.php @@ -0,0 +1,36 @@ +isActive()) { + return; + } + + $test = $event->test(); + + if (! $test instanceof TestMethod) { + return; + } + + $recorder->beginTest($test->className(), $test->methodName(), $test->file()); + } +} diff --git a/src/Support/Tia/ChangedFiles.php b/src/Support/Tia/ChangedFiles.php new file mode 100644 index 00000000..8249d9dd --- /dev/null +++ b/src/Support/Tia/ChangedFiles.php @@ -0,0 +1,219 @@ +..HEAD` captures committed + * changes on top of the recording point. + * 2. `git status --short` captures unstaged + staged + untracked changes on + * top of that. + * + * We return relative paths to the project root. Deletions are included so the + * caller can decide whether to invalidate: a deleted source file may still + * appear in the graph and should mark its dependents as affected. + * + * @internal + */ +final readonly class ChangedFiles +{ + public function __construct(private string $projectRoot) {} + + /** + * @return array|null `null` when git is unavailable, or when + * the recorded SHA is no longer reachable + * from HEAD (rebase / force-push) — in + * that case the graph should be rebuilt. + */ + public function since(?string $sha): ?array + { + if (! $this->gitAvailable()) { + return null; + } + + $files = []; + + if ($sha !== null && $sha !== '') { + if (! $this->shaIsReachable($sha)) { + return null; + } + + $files = array_merge($files, $this->diffSinceSha($sha)); + } + + $files = array_merge($files, $this->workingTreeChanges()); + + // Normalise + dedupe, filtering out paths that can never belong to the + // graph: vendor (caught by the fingerprint instead), cache dirs, and + // anything starting with a dot we don't care about. + $unique = []; + + foreach ($files as $file) { + if ($file === '') { + continue; + } + if ($this->shouldIgnore($file)) { + continue; + } + $unique[$file] = true; + } + + return array_keys($unique); + } + + private function shouldIgnore(string $path): bool + { + static $prefixes = [ + '.pest/', + '.phpunit.cache/', + '.phpunit.result.cache', + 'vendor/', + 'node_modules/', + ]; + + foreach ($prefixes as $prefix) { + if (str_starts_with($path, (string) $prefix)) { + return true; + } + } + + return false; + } + + public function gitAvailable(): bool + { + $process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot); + $process->run(); + + return $process->isSuccessful(); + } + + private function shaIsReachable(string $sha): bool + { + $process = new Process( + ['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'], + $this->projectRoot, + ); + $process->run(); + + // Exit 0 → ancestor; 1 → not ancestor; anything else → git error + // (e.g. unknown commit after a rebase/gc). Treat non-zero as + // "unreachable" and force a rebuild. + return $process->getExitCode() === 0; + } + + /** + * @return array + */ + private function diffSinceSha(string $sha): array + { + $process = new Process( + ['git', 'diff', '--name-only', $sha.'..HEAD'], + $this->projectRoot, + ); + $process->run(); + + if (! $process->isSuccessful()) { + return []; + } + + return $this->splitLines($process->getOutput()); + } + + /** + * @return array + */ + private function workingTreeChanges(): array + { + // `-z` produces NUL-terminated records with no path quoting, so paths + // that contain spaces, tabs, unicode or other special characters + // are passed through verbatim. Without `-z`, git wraps such paths in + // quotes with backslash escapes, which would corrupt our lookup keys. + // + // Record format: `XY ` for most entries, and + // `R ` for renames/copies (two NUL-separated + // fields). + $process = new Process( + ['git', 'status', '--porcelain', '-z', '--untracked-files=all'], + $this->projectRoot, + ); + $process->run(); + + if (! $process->isSuccessful()) { + return []; + } + + $output = $process->getOutput(); + + if ($output === '') { + return []; + } + + $records = explode("\x00", rtrim($output, "\x00")); + $files = []; + $count = count($records); + + for ($i = 0; $i < $count; $i++) { + $record = $records[$i]; + + if (strlen($record) < 4) { + continue; + } + + $status = substr($record, 0, 2); + $path = substr($record, 3); + + // Renames/copies emit two records: the new path first, then the + // original. Consume both. + if ($status[0] === 'R' || $status[0] === 'C') { + $files[] = $path; + + if (isset($records[$i + 1]) && $records[$i + 1] !== '') { + $files[] = $records[$i + 1]; + $i++; + } + + continue; + } + + $files[] = $path; + } + + return $files; + } + + public function currentSha(): ?string + { + if (! $this->gitAvailable()) { + return null; + } + + $process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + $sha = trim($process->getOutput()); + + return $sha === '' ? null : $sha; + } + + /** + * @return array + */ + private function splitLines(string $output): array + { + $lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY); + + return $lines === false ? [] : $lines; + } +} diff --git a/src/Support/Tia/Fingerprint.php b/src/Support/Tia/Fingerprint.php new file mode 100644 index 00000000..fe2cb9f0 --- /dev/null +++ b/src/Support/Tia/Fingerprint.php @@ -0,0 +1,95 @@ + self::SCHEMA_VERSION, + 'php' => PHP_VERSION, + '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'), + ]; + } + + /** + * @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'; + + 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'; + } +} diff --git a/src/Support/Tia/Graph.php b/src/Support/Tia/Graph.php new file mode 100644 index 00000000..91d7f6fc --- /dev/null +++ b/src/Support/Tia/Graph.php @@ -0,0 +1,320 @@ +` so that subsequent runs + * can skip tests whose dependencies have not changed. Paths are stored relative + * to the project root and source files are deduplicated via an index so that + * the on-disk JSON stays compact for large suites. + * + * @internal + */ +final class Graph +{ + /** + * Relative path of each known source file, indexed by numeric id. + * + * @var array + */ + private array $files = []; + + /** + * Reverse lookup: source file → numeric id. + * + * @var array + */ + private array $fileIds = []; + + /** + * Edges: test file (relative) → list of source file ids. + * + * @var array> + */ + private array $edges = []; + + /** + * Environment fingerprint captured at record time. + * + * @var array + */ + private array $fingerprint = []; + + /** + * Commit SHA the graph was recorded against (if in a git repo). + */ + private ?string $recordedAtSha = null; + + /** + * Canonicalised project root. Resolved through `realpath()` so paths + * captured by coverage drivers (always real filesystem targets) match + * regardless of whether the user's CWD is a symlink or has trailing + * separators. + */ + private readonly string $projectRoot; + + public function __construct(string $projectRoot) + { + $real = @realpath($projectRoot); + + $this->projectRoot = $real !== false ? $real : $projectRoot; + } + + /** + * Records that a test file depends on the given source file. + */ + public function link(string $testFile, string $sourceFile): void + { + $testRel = $this->relative($testFile); + $sourceRel = $this->relative($sourceFile); + + if ($sourceRel === null || $testRel === null) { + return; + } + + if (! isset($this->fileIds[$sourceRel])) { + $id = count($this->files); + $this->files[$id] = $sourceRel; + $this->fileIds[$sourceRel] = $id; + } + + $this->edges[$testRel][] = $this->fileIds[$sourceRel]; + } + + /** + * Returns the set of test files whose dependencies intersect $changedFiles. + * + * @param array $changedFiles Absolute or relative paths. + * @return array Relative test file paths. + */ + public function affected(array $changedFiles): array + { + $changedIds = []; + + foreach ($changedFiles as $file) { + $rel = $this->relative($file); + + if ($rel === null) { + continue; + } + + if (isset($this->fileIds[$rel])) { + $changedIds[$this->fileIds[$rel]] = true; + } + } + + $affected = []; + + foreach ($this->edges as $testFile => $ids) { + foreach ($ids as $id) { + if (isset($changedIds[$id])) { + $affected[] = $testFile; + + break; + } + } + } + + return $affected; + } + + /** + * Returns `true` if the given test file has any recorded dependencies. + */ + public function knowsTest(string $testFile): bool + { + $rel = $this->relative($testFile); + + return $rel !== null && isset($this->edges[$rel]); + } + + public function setFingerprint(array $fingerprint): void + { + $this->fingerprint = $fingerprint; + } + + public function fingerprint(): array + { + return $this->fingerprint; + } + + public function setRecordedAtSha(?string $sha): void + { + $this->recordedAtSha = $sha; + } + + public function recordedAtSha(): ?string + { + return $this->recordedAtSha; + } + + /** + * Replaces edges for the given test files. Used during a partial record + * run so that existing edges for other tests are preserved. + * + * @param array> $testToFiles + */ + public function replaceEdges(array $testToFiles): void + { + foreach ($testToFiles as $testFile => $sources) { + $testRel = $this->relative($testFile); + + if ($testRel === null) { + continue; + } + + $this->edges[$testRel] = []; + + foreach ($sources as $source) { + $this->link($testFile, $source); + } + + // Deduplicate ids for this test. + $this->edges[$testRel] = array_values(array_unique($this->edges[$testRel])); + } + } + + /** + * Drops edges whose test file no longer exists on disk. Prevents the graph + * from keeping stale entries for deleted / renamed tests that would later + * be flagged as affected and confuse PHPUnit's discovery. + */ + public function pruneMissingTests(): void + { + $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + foreach (array_keys($this->edges) as $testRel) { + if (! is_file($root.$testRel)) { + unset($this->edges[$testRel]); + } + } + } + + public static function load(string $projectRoot, string $path): ?self + { + 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) || ($data['schema'] ?? null) !== 1) { + return null; + } + + $graph = new self($projectRoot); + $graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : []; + $graph->recordedAtSha = is_string($data['recorded_at_sha'] ?? null) ? $data['recorded_at_sha'] : null; + $graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : []; + $graph->fileIds = array_flip($graph->files); + $graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : []; + + return $graph; + } + + public function save(string $path): bool + { + $dir = dirname($path); + + if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { + return false; + } + + $payload = [ + 'schema' => 1, + 'fingerprint' => $this->fingerprint, + 'recorded_at_sha' => $this->recordedAtSha, + 'files' => $this->files, + 'edges' => $this->edges, + ]; + + $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; + + $json = json_encode($payload, JSON_UNESCAPED_SLASHES); + + if ($json === false) { + return false; + } + + if (@file_put_contents($tmp, $json) === false) { + return false; + } + + if (! @rename($tmp, $path)) { + @unlink($tmp); + + return false; + } + + return true; + } + + /** + * Normalises a path to be relative to the project root; returns `null` for + * paths we should ignore (outside the project, unknown, virtual, vendor). + * + * Accepts both absolute paths (from Xdebug/PCOV coverage) and + * project-relative paths (from `git diff`) — we normalise without relying + * on `realpath()` of relative paths because the current working directory + * is not guaranteed to be the project root. + */ + private function relative(string $path): ?string + { + if ($path === '' || $path === 'unknown') { + return null; + } + + if (str_contains($path, "eval()'d")) { + return null; + } + + $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + $isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR) + || (strlen($path) >= 2 && $path[1] === ':'); // Windows drive + + if ($isAbsolute) { + $real = @realpath($path); + + if ($real === false) { + $real = $path; + } + + if (! str_starts_with($real, $root)) { + return null; + } + + // Always normalise to forward slashes. Windows' native separator + // would otherwise produce keys that never match paths reported + // by `git` (which always uses forward slashes). + $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); + } else { + // Normalise directory separators and strip any "./" prefix. + $relative = str_replace(DIRECTORY_SEPARATOR, '/', $path); + + while (str_starts_with($relative, './')) { + $relative = substr($relative, 2); + } + } + + // Vendor packages are pinned by composer.lock. Any upgrade bumps the + // fingerprint and invalidates the graph wholesale, so there is no + // reason to track individual vendor files — doing so inflates the + // graph by orders of magnitude on Laravel-style projects. + if (str_starts_with($relative, 'vendor/')) { + return null; + } + + return $relative; + } +} diff --git a/src/Support/Tia/Recorder.php b/src/Support/Tia/Recorder.php new file mode 100644 index 00000000..e18508b3 --- /dev/null +++ b/src/Support/Tia/Recorder.php @@ -0,0 +1,237 @@ +. + * + * @var array> + */ + private array $perTestFiles = []; + + /** + * Cached class → test file resolution. + * + * @var array + */ + private array $classFileCache = []; + + private bool $active = false; + + private bool $driverChecked = false; + + private bool $driverAvailable = false; + + private string $driver = 'none'; + + public static function instance(): self + { + return self::$instance ??= new self; + } + + public function activate(): void + { + $this->active = true; + } + + public function isActive(): bool + { + return $this->active; + } + + public function driverAvailable(): bool + { + if (! $this->driverChecked) { + if (function_exists('pcov\\start')) { + $this->driver = 'pcov'; + $this->driverAvailable = true; + } elseif (function_exists('xdebug_start_code_coverage')) { + // Probe: Xdebug silently emits a warning and refuses to start + // when not in coverage mode. Suppress + check for mode errors. + $ok = @\xdebug_start_code_coverage(); + + if ($ok === null || $ok) { + @\xdebug_stop_code_coverage(false); + $this->driver = 'xdebug'; + $this->driverAvailable = true; + } + } + + $this->driverChecked = true; + } + + return $this->driverAvailable; + } + + public function driver(): string + { + $this->driverAvailable(); + + return $this->driver; + } + + public function beginTest(string $className, string $methodName, string $fallbackFile): void + { + if (! $this->active || ! $this->driverAvailable()) { + return; + } + + $file = $this->resolveTestFile($className, $fallbackFile); + + if ($file === null) { + return; + } + + $this->currentTestFile = $file; + + if ($this->driver === 'pcov') { + \pcov\clear(); + \pcov\start(); + + return; + } + + // Xdebug + \xdebug_start_code_coverage(); + } + + public function endTest(): void + { + if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) { + return; + } + + if ($this->driver === 'pcov') { + \pcov\stop(); + /** @var array $data */ + $data = \pcov\collect(\pcov\inclusive); + } else { + /** @var array $data */ + $data = \xdebug_get_code_coverage(); + // `true` resets Xdebug's internal buffer so the next `start()` + // does not accumulate earlier tests' coverage into the current + // one — otherwise the graph becomes progressively polluted. + \xdebug_stop_code_coverage(true); + } + + foreach (array_keys($data) as $sourceFile) { + $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; + } + + $this->currentTestFile = null; + } + + /** + * @return array> absolute test file → list of absolute source files. + */ + public function perTestFiles(): array + { + $out = []; + + foreach ($this->perTestFiles as $testFile => $sources) { + $out[$testFile] = array_keys($sources); + } + + return $out; + } + + private function resolveTestFile(string $className, string $fallbackFile): ?string + { + if (array_key_exists($className, $this->classFileCache)) { + $file = $this->classFileCache[$className]; + } else { + $file = $this->readPestFilename($className); + $this->classFileCache[$className] = $file; + } + + if ($file !== null) { + return $file; + } + + if ($fallbackFile !== '' && $fallbackFile !== 'unknown' && ! str_contains($fallbackFile, "eval()'d")) { + return $fallbackFile; + } + + return null; + } + + /** + * Resolves the file that *defines* the test class. + * + * Order of preference: + * 1. Pest's generated `$__filename` static — the original `*.php` file + * containing the `test()` calls (the eval'd class itself has no file). + * 2. `ReflectionClass::getFileName()` — the concrete class's file. This + * is intentionally more specific than `ReflectionMethod::getFileName()` + * (which would return the *trait* file for methods brought in via + * `uses SharedTestBehavior`). + */ + private function readPestFilename(string $className): ?string + { + if (! class_exists($className, false)) { + return null; + } + + try { + $reflection = new ReflectionClass($className); + } catch (ReflectionException) { + return null; + } + + if ($reflection->hasProperty('__filename')) { + try { + $property = $reflection->getProperty('__filename'); + + if ($property->isStatic()) { + $value = $property->getValue(); + + if (is_string($value) && $value !== '') { + return $value; + } + } + } catch (ReflectionException) { + // fall through to getFileName() + } + } + + $file = $reflection->getFileName(); + + return $file !== false && $file !== '' ? $file : null; + } + + /** + * Clears all captured state. Useful for long-running hosts (daemons, + * PHP-FPM, watchers) that invoke Pest multiple times in a single process + * — without this, coverage from run N would bleed into run N+1. + */ + public function reset(): void + { + $this->currentTestFile = null; + $this->perTestFiles = []; + $this->classFileCache = []; + $this->active = false; + } +} diff --git a/src/TestCaseFilters/TiaTestCaseFilter.php b/src/TestCaseFilters/TiaTestCaseFilter.php new file mode 100644 index 00000000..7838f346 --- /dev/null +++ b/src/TestCaseFilters/TiaTestCaseFilter.php @@ -0,0 +1,65 @@ + $affectedTestFiles Keys are project-relative test file paths. + */ + public function __construct( + private string $projectRoot, + private Graph $graph, + private array $affectedTestFiles, + ) {} + + public function accept(string $testCaseFilename): bool + { + $rel = $this->relative($testCaseFilename); + + if ($rel === null) { + return true; + } + + if (! $this->graph->knowsTest($rel)) { + return true; + } + + return isset($this->affectedTestFiles[$rel]); + } + + private function relative(string $path): ?string + { + $real = @realpath($path); + + if ($real === false) { + $real = $path; + } + + $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + if (! str_starts_with($real, $root)) { + return null; + } + + return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); + } +} diff --git a/tests/Arch.php b/tests/Arch.php index d0565216..3eca267a 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -7,6 +7,9 @@ arch()->preset()->php()->ignoring([ 'debug_backtrace', 'var_export', 'xdebug_info', + 'xdebug_start_code_coverage', + 'xdebug_stop_code_coverage', + 'xdebug_get_code_coverage', ]); arch()->preset()->strict()->ignoring([