This commit is contained in:
nuno maduro
2026-04-30 20:45:36 +01:00
parent f355b99bbf
commit 6a434be0f6
13 changed files with 495 additions and 1336 deletions

View File

@ -288,6 +288,7 @@ trait Testable
if ($cached !== null) { if ($cached !== null) {
if ($cached->isSuccess()) { if ($cached->isSuccess()) {
$this->__cachedPass = true; $this->__cachedPass = true;
$this->__ran = true;
return; return;
} }
@ -299,6 +300,7 @@ trait Testable
// programmatic risky-marker API. // programmatic risky-marker API.
if ($cached->isRisky()) { if ($cached->isRisky()) {
$this->__cachedPass = true; $this->__cachedPass = true;
$this->__ran = true;
return; return;
} }
@ -313,6 +315,7 @@ trait Testable
if ($cached->isIncomplete()) { if ($cached->isIncomplete()) {
$this->markTestIncomplete($cached->message()); $this->markTestIncomplete($cached->message());
$this->__ran = true;
} }
throw new AssertionFailedError($cached->message() ?: 'Cached failure'); throw new AssertionFailedError($cached->message() ?: 'Cached failure');

View File

@ -20,6 +20,7 @@ use Pest\Plugins\Tia\ResultCollector;
use Pest\Plugins\Tia\TableExtractor; use Pest\Plugins\Tia\TableExtractor;
use Pest\Plugins\Tia\WatchPatterns; use Pest\Plugins\Tia\WatchPatterns;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\TestCaseFilters\TiaTestCaseFilter;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -27,45 +28,10 @@ use Symfony\Component\Process\Process;
use Throwable; use Throwable;
/** /**
* Test Impact Analysis (file-level, parallel-aware). * Test Impact Analysis plugin — record/replay, parallel-aware.
* *
* Modes * Must be registered before `Parallel` — Parallel exits on `--parallel`,
* ----- * so later plugins never execute.
* - **Record** — no graph (or fingerprint / recording commit drifted). The
* full suite runs with PCOV / Xdebug capture per test; the resulting
* `test → [source_file, …]` edges land in `.pest/tia/graph.json`.
* - **Replay** — graph valid. We diff the working tree against the recording
* commit, intersect changed files with graph edges, and run only the
* affected tests. Newly-added tests unknown to the graph are always
* accepted (skipping them would be a correctness hazard).
*
* Parallel integration
* --------------------
* This plugin MUST run before `Pest\Plugins\Parallel` in the registered
* plugin list — Parallel exits the process as soon as it sees `--parallel`,
* so later plugins never get their turn. With the correct order:
*
* - **Parent, replay**: narrow the CLI args down to the affected test
* files before Parallel hands them to paratest. Workers then only see
* the narrowed file set and nothing special is required of them.
* - **Parent, record**: flip a global recording flag (via
* `Parallel::setGlobal`) so every spawned worker activates its own
* coverage recorder. The parent does not itself record (paratest runs
* tests in workers); instead we register an `AddsOutput` hook that
* merges per-worker partial graphs after paratest finishes.
* - **Worker, record**: boots through `bin/worker.php`, which re-runs
* `CallsHandleArguments`. We detect the worker context + recording flag,
* activate the `Recorder`, and flush the partial graph on `terminate()`
* into `.pest/tia/worker-edges-<TEST_TOKEN>.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 * @internal
*/ */
@ -75,29 +41,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string OPTION = '--tia'; private const string OPTION = '--tia';
/**
* Discards any existing graph and re-records from scratch. Meant to
* be combined with `--tia`; the flag is shared with the rest of Pest
* (no `tia-` prefix) so a single `--tia --fresh` reads naturally as
* "TIA, fresh start".
*/
private const string FRESH_OPTION = '--fresh'; private const string FRESH_OPTION = '--fresh';
/**
* Bypasses `BaselineSync`'s post-failure cooldown. After a failed
* baseline fetch, subsequent `--tia` runs skip the fetch for 24h; this
* flag forces an immediate retry (e.g. right after publishing a
* baseline from CI for the first time).
*/
private const string REFETCH_OPTION = '--refetch'; private const string REFETCH_OPTION = '--refetch';
/** private const string FILTERED_OPTION = '--filtered';
* State keys under which TIA persists its blobs. Kept here as constants
* (rather than scattered strings) so the storage layout is visible in
* one place, and so `CoverageMerger` can reference the same keys. All
* files live under `.pest/tia/` — the `tia-` filename prefix is gone
* because the directory already namespaces them.
*/
public const string KEY_GRAPH = 'graph.json'; public const string KEY_GRAPH = 'graph.json';
public const string KEY_AFFECTED = 'affected.json'; public const string KEY_AFFECTED = 'affected.json';
@ -106,107 +55,43 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-'; private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-';
/** /** Sentinel dropped by a recording worker without a usable coverage driver. */
* Sentinel dropped by a recording worker that found no usable
* coverage driver in its own process. Workers can have a different
* PHP env from the parent (Herd profile, custom ini scandir, CI
* runners that strip extensions), so the parent's driver check
* doesn't catch this. The parent reads these at end-of-run and
* surfaces a single warning so partial coverage loss isn't
* silent.
*/
private const string KEY_WORKER_NO_DRIVER_PREFIX = 'worker-no-driver-'; private const string KEY_WORKER_NO_DRIVER_PREFIX = 'worker-no-driver-';
/**
* Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage`
* run. Stored as bytes so the backend stays JSON/file-agnostic — the
* merger un/serialises rather than `require`-ing a PHP file.
*/
public const string KEY_COVERAGE_CACHE = 'coverage.bin'; public const string KEY_COVERAGE_CACHE = 'coverage.bin';
/**
* Marker key dropped by `Tia` to tell `Support\Coverage` to apply the
* merge. Absent on plain `--coverage` runs so non-TIA usage keeps its
* current (narrow) behaviour.
*/
public const string KEY_COVERAGE_MARKER = 'coverage.marker'; public const string KEY_COVERAGE_MARKER = 'coverage.marker';
/**
* Cooldown marker keyed by `BaselineSync` after a failed fetch. Holds
* `{"until": <unix>}` — subsequent runs within the window skip the
* fetch attempt (and its `gh run list` network hop) until the
* cooldown expires or the user passes `--refetch`.
*/
public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json'; public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json';
/**
* Global flag toggled by the parent process so workers know to record.
*/
private const string RECORDING_GLOBAL = 'TIA_RECORDING'; 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/tia/affected.json`.
*/
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
/** /** Tells workers to apply TiaTestCaseFilter instead of cache short-circuiting. */
* Global flag that tells workers to piggyback on PHPUnit's coverage private const string FILTERED_GLOBAL = 'TIA_FILTERED';
* driver (set by the parent whenever `--tia --coverage` is used). Workers
* can't infer this from their own argv because paratest forwards only /** Workers can't detect `--coverage` from their own argv paratest strips it. */
* `--coverage-php=<path>` — not the `--coverage` flag Pest's Coverage
* plugin inspects.
*/
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE'; private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
private bool $graphWritten = false; private bool $graphWritten = false;
private bool $replayRan = 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; private int $replayedCount = 0;
/** private int $affectedCount = 0;
* Counter-part of `$replayedCount`: every time `getCachedResult()`
* decides the test must execute (affected, unknown, or no cached
* result), we bump this. Together the two counters let the summary
* show "affected + replayed" in units of test methods, not test
* files, matching the "Tests: N" total Pest prints above.
*/
private int $executedCount = 0; private int $executedCount = 0;
/** /** @var array<string, int> */
* Cached assertion count per test id for the current replay run. Keyed
* by `ClassName::methodName`; populated when `getCachedResult()` hits
* cache and drained by `Testable::__runTest()` on the short-circuit
* path so the emitted count matches the recorded run.
*
* @var array<string, int>
*/
private array $cachedAssertionsByTestId = []; private array $cachedAssertionsByTestId = [];
/**
* Holds the graph during replay so `beforeEach` can look up cached
* results without re-loading from disk on every test.
*/
private ?Graph $replayGraph = null; 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'; private string $branch = 'main';
/** /** @var array<string, true> */
* Test files that are affected (should re-execute). Keyed by
* project-relative path. Set during `enterReplayMode`.
*
* @var array<string, true>
*/
private array $affectedFiles = []; private array $affectedFiles = [];
private function workerEdgesKey(string $token): string private function workerEdgesKey(string $token): string
@ -219,47 +104,20 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json'; return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
} }
/**
* True when TIA is piggybacking on PHPUnit's own coverage driver. Toggled
* in `handleArguments` whenever `--tia` runs alongside `--coverage` so
* both the parent and workers read edges from the shared `CodeCoverage`
* instance instead of starting a second PCOV / Xdebug session.
*/
private bool $piggybackCoverage = false; private bool $piggybackCoverage = false;
/**
* True once we have committed to recording in this process — either by
* activating our own `Recorder` or by delegating to PHPUnit's coverage
* driver via `CoverageCollector`. `terminate()` only flushes when this
* is set, so runs that never entered record mode don't poke the graph.
*/
private bool $recordingActive = false; private bool $recordingActive = false;
/**
* True when `--refetch` is in the current argv — `BaselineSync`
* uses it to bypass the post-failure fetch cooldown.
*/
private bool $forceRefetch = false; private bool $forceRefetch = false;
/** /** Prevents fetching the same stale baseline twice after structural drift. */
* True once structural-drift recovery has already tried the remote
* baseline during this process. Prevents the later "no local graph" path
* from fetching the same stale baseline again and printing duplicate drift
* / rebuild messages.
*/
private bool $baselineFetchAttemptedForDrift = false; private bool $baselineFetchAttemptedForDrift = false;
/** /** Gates `Graph::pruneMissingTests()` — only safe on full `--fresh` rebuilds. */
* True when `--fresh` is in the current argv — record-mode paths
* use it to gate `Graph::pruneMissingTests()`. On a partial record
* (default `--tia` after a branch switch, etc.) the working tree may
* not contain every test the shared graph knows about, so pruning
* would silently delete edges for tests that exist on other
* branches. `--fresh` rebuilds from scratch anyway, so pruning
* there is both safe and useful for cleaning up stale entries.
*/
private bool $freshRebuild = false; private bool $freshRebuild = false;
private bool $filteredMode = false;
public function __construct( public function __construct(
private readonly OutputInterface $output, private readonly OutputInterface $output,
private readonly Recorder $recorder, private readonly Recorder $recorder,
@ -269,12 +127,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private readonly BaselineSync $baselineSync, private readonly BaselineSync $baselineSync,
) {} ) {}
/**
* Convenience wrapper: load + decode the graph, or return `null` if no
* graph has been stored. Any call that needs to mutate + re-save the
* graph also goes through `saveGraph()` to keep bytes flowing through
* the `State` abstraction rather than filesystem paths.
*/
private function loadGraph(string $projectRoot): ?Graph private function loadGraph(string $projectRoot): ?Graph
{ {
$json = $this->state->read(self::KEY_GRAPH); $json = $this->state->read(self::KEY_GRAPH);
@ -297,64 +149,44 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $this->state->write(self::KEY_GRAPH, $json); return $this->state->write(self::KEY_GRAPH, $json);
} }
/**
* 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 public function getCachedResult(string $filename, string $testId): ?TestStatus
{ {
if (! $this->replayGraph instanceof Graph) { if (! $this->replayGraph instanceof Graph) {
return null; return null;
} }
// Resolve file to project-relative path.
$projectRoot = TestSuite::getInstance()->rootPath; $projectRoot = TestSuite::getInstance()->rootPath;
$real = @realpath($filename); $real = @realpath($filename);
$rel = $real !== false $rel = $real !== false
? str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen(rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR))) ? str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen(rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR)))
: null; : null;
// Affected files must re-execute.
if ($rel !== null && isset($this->affectedFiles[$rel])) { if ($rel !== null && isset($this->affectedFiles[$rel])) {
$this->affectedCount++;
$this->executedCount++; $this->executedCount++;
return null; return null;
} }
// Unknown files (not in graph) must execute — they're new.
if ($rel === null || ! $this->replayGraph->knowsTest($rel)) { if ($rel === null || ! $this->replayGraph->knowsTest($rel)) {
$this->executedCount++; $this->executedCount++;
return null; 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); $result = $this->replayGraph->getResult($this->branch, $testId);
if ($result instanceof TestStatus) { if ($result instanceof TestStatus) {
$this->replayedCount++; $this->replayedCount++;
// Cache the assertion count alongside the status so `Testable`
// can emit the exact `addToAssertionCount()` at replay time
// without hitting the graph twice per test.
$assertions = $this->replayGraph->getAssertions($this->branch, $testId); $assertions = $this->replayGraph->getAssertions($this->branch, $testId);
$this->cachedAssertionsByTestId[$testId] = $assertions ?? 0; $this->cachedAssertionsByTestId[$testId] = $assertions ?? 0;
} else { } else {
// Graph knows the test file but has no stored result for this
// specific test id (new test, or first time seeing this method).
// It must execute.
$this->executedCount++; $this->executedCount++;
} }
return $result; return $result;
} }
/**
* Exact assertion count captured for the given test during its last
* recorded run. Returns `0` if unknown (new test, or old graph entry
* pre-dating assertion-count tracking). `Testable::__runTest` reads
* this to feed `addToAssertionCount()` instead of defaulting to 1.
*/
public function getCachedAssertions(string $testId): int public function getCachedAssertions(string $testId): int
{ {
return $this->cachedAssertionsByTestId[$testId] ?? 0; return $this->cachedAssertionsByTestId[$testId] ?? 0;
@ -369,15 +201,23 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'; $recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1'; $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
$enabled = $this->hasArgument(self::OPTION, $arguments); /** @var Tia\WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(Tia\WatchPatterns::class);
$cliEnabled = $this->hasArgument(self::OPTION, $arguments);
$alwaysEnabled = $watchPatterns->isAlways()
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
$enabled = $cliEnabled || $alwaysEnabled;
$this->filteredMode = $this->hasArgument(self::FILTERED_OPTION, $arguments) || $watchPatterns->isFiltered();
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments); $freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments); $this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
// `--fresh` only takes effect alongside `--tia` (or from a // Always strip TIA-owned flags so they never reach PHPUnit, even when
// worker that's already in TIA mode). Without `--tia`, Pest // TIA is not active for this run.
// users could be passing `--fresh` to an unrelated plugin — $arguments = $this->popArgument(self::OPTION, $arguments);
// silently ignore it here and let whatever else consumes it $arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
// handle it. The flag isn't popped in that branch. $arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
$arguments = $this->popArgument(self::FILTERED_OPTION, $arguments);
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal); $forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
$this->freshRebuild = $forceRebuild; $this->freshRebuild = $forceRebuild;
@ -385,18 +225,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $arguments; return $arguments;
} }
$arguments = $this->popArgument(self::OPTION, $arguments);
$arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
// When `--coverage` is active, piggyback on PHPUnit's CodeCoverage
// instead of starting our own PCOV / Xdebug session. Running two
// collectors against the same driver corrupts both — so we let
// PHPUnit drive, and read per-test edges from the shared instance
// at the end of the run via `CoverageCollector`. Workers can't
// detect `--coverage` from their own argv (paratest strips it,
// keeping only `--coverage-php=<path>`) so the parent broadcasts
// via a global.
$this->piggybackCoverage = $isWorker $this->piggybackCoverage = $isWorker
? (string) Parallel::getGlobal(self::PIGGYBACK_COVERAGE_GLOBAL) === '1' ? (string) Parallel::getGlobal(self::PIGGYBACK_COVERAGE_GLOBAL) === '1'
: $this->coverageReportActive(); : $this->coverageReportActive();
@ -416,12 +244,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return; return;
} }
// Flush the ResultCollector + replay counter from workers into a
// partial so the parent can merge them. Needed during replay so the
// summary is accurate, and also during the initial record run so
// the graph lands with results on first write — otherwise the next
// run would load a graph with edges but empty results, miss the
// cache for every test, and look pointlessly slow.
if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive)) { if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive)) {
$this->flushWorkerReplay(); $this->flushWorkerReplay();
} }
@ -450,15 +272,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$perTestInertia = $recorder->perTestInertiaComponents(); $perTestInertia = $recorder->perTestInertiaComponents();
$perTestUsesDatabase = $recorder->perTestUsesDatabase(); $perTestUsesDatabase = $recorder->perTestUsesDatabase();
// Tests that use Laravel's DB-resetting traits (`RefreshDatabase`,
// `DatabaseMigrations`, `DatabaseTransactions`) but recorded zero
// queries during their body — typical seeded-fixture / attribute-
// assertion tests — would otherwise have empty `$testTables` and
// get silently skipped on migration changes. The migrations and
// seed DML run during `parent::setUp()` before `TableTracker`
// arms, so we can't capture them. Instead, conservatively union
// the project-wide migration table set into those tests so any
// schema change re-runs them.
if ($perTestUsesDatabase !== []) { if ($perTestUsesDatabase !== []) {
$perTestTables = $this->augmentDatabaseTestTables( $perTestTables = $this->augmentDatabaseTestTables(
$perTestTables, $perTestTables,
@ -475,17 +288,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return; return;
} }
// Non-parallel record path: straight into the main cache.
$changedFiles = new ChangedFiles($projectRoot); $changedFiles = new ChangedFiles($projectRoot);
$currentSha = $changedFiles->currentSha(); $currentSha = $changedFiles->currentSha();
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot); $graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
$graph->setFingerprint(Fingerprint::compute($projectRoot)); $graph->setFingerprint(Fingerprint::compute($projectRoot));
$graph->setRecordedAtSha($this->branch, $currentSha); $graph->setRecordedAtSha($this->branch, $currentSha);
// Snapshot whatever is currently dirty in the working tree. Without
// this, the very first `--tia` replay would see those same files
// via `since()` and report them as "changed" — even though they're
// identical to what we just recorded against.
$graph->setLastRunTree( $graph->setLastRunTree(
$this->branch, $this->branch,
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
@ -495,25 +303,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->replaceTestInertiaComponents($perTestInertia); $graph->replaceTestInertiaComponents($perTestInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
// Pruning checks the local filesystem for each known test file —
// on a partial record (no `--fresh`) the current checkout may
// legitimately be missing tests that exist on other branches
// sharing this graph, so pruning would silently delete their
// edges. Stale entries for genuinely-deleted tests are harmless
// (test discovery never finds the file) and get cleaned up on
// the next `--fresh` rebuild.
if ($this->freshRebuild) { if ($this->freshRebuild) {
$graph->pruneMissingTests(); $graph->pruneMissingTests();
} }
// Fold in the results collected during this same record run. The
// `AddsOutput` pass that runs `snapshotTestResults` fires *before*
// `terminate()` in the shutdown chain, so by the time the graph
// lands on disk, the snapshot pass has already returned empty.
// Writing results here means a first `--tia` invocation produces
// a graph with edges *and* results — the immediate next run hits
// cache for every unchanged test rather than needing a "warm-up"
// pass.
$this->seedResultsInto($graph); $this->seedResultsInto($graph);
if (! $this->saveGraph($graph)) { if (! $this->saveGraph($graph)) {
@ -532,11 +325,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->coverageCollector->reset(); $this->coverageCollector->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 public function addOutput(int $exitCode): int
{ {
if (Parallel::isWorker()) { if (Parallel::isWorker()) {
@ -545,17 +333,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->reportMissingWorkerDrivers(); $this->reportMissingWorkerDrivers();
// 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.
// In parallel runs the workers executed the tests, so their
// ResultCollector + replay counter live in other processes. Pull
// those partials in first — both replay and record paths need them:
// replay to make the summary accurate, record so the initial graph
// lands with results instead of a second "warm-up" run being needed
// before replay is actually fast.
if (Parallel::isEnabled()) { if (Parallel::isEnabled()) {
$this->mergeWorkerReplayPartials(); $this->mergeWorkerReplayPartials();
} }
@ -565,8 +342,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') { if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') {
// Series path: graph was already written by `terminate()` (or
// nothing to record). Snapshot results now so they ride along.
$this->snapshotTestResults(); $this->snapshotTestResults();
return $exitCode; return $exitCode;
@ -654,11 +429,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$finalisedInertia[$testFile] = array_keys($componentSet); $finalisedInertia[$testFile] = array_keys($componentSet);
} }
// 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 === []) { if ($finalised === []) {
$this->output->writeln([ $this->output->writeln([
'', '',
@ -675,10 +445,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->replaceTestInertiaComponents($finalisedInertia); $graph->replaceTestInertiaComponents($finalisedInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
// See `terminate()` — same rationale: pruning by current
// working-tree presence would silently drop edges for tests
// owned by other branches sharing this graph. Only safe on
// `--fresh` rebuilds.
if ($this->freshRebuild) { if ($this->freshRebuild) {
$graph->pruneMissingTests(); $graph->pruneMissingTests();
} }
@ -695,31 +461,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
count($partialKeys), count($partialKeys),
)); ));
// Persist per-test results (merged from worker partials above) into
// the freshly-written graph. Without this the graph would ship with
// edges but no results, and the very next `--tia` run would miss
// cache for every test even though nothing changed.
$this->snapshotTestResults(); $this->snapshotTestResults();
return $exitCode; return $exitCode;
} }
/** /**
* Compares a loaded graph's fingerprint to the current one and decides * Structural drift → discard graph, return null (caller enters record mode).
* how much of the graph is still usable. * Environmental drift → drop results, keep edges, return updated graph.
* * Match → return graph unchanged.
* - **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<string, mixed>, environmental: array<string, mixed>} $current * @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
*/ */
@ -735,10 +485,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->formatStructuralDrift($drift), $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)) { if (in_array('composer_lock', $drift, true)) {
$branchSha = $graph->recordedAtSha($this->branch); $branchSha = $graph->recordedAtSha($this->branch);
if ($branchSha !== null) { if ($branchSha !== null) {
@ -752,18 +498,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
} }
// 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
// ~530s 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); $rebuilt = $this->tryRemoteBaselineForDrift($current);
if ($rebuilt instanceof Graph) { if ($rebuilt instanceof Graph) {
@ -801,14 +535,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): 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); $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'; $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
$fingerprint = Fingerprint::compute($projectRoot); $fingerprint = Fingerprint::compute($projectRoot);
@ -847,17 +574,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
} }
// Drop the marker so `Support\Coverage::report()` knows to merge the
// current (narrow) coverage with the cached full-run snapshot. Plain
// `--coverage` runs don't drop it, so their behaviour is untouched.
if ($this->piggybackCoverage) { if ($this->piggybackCoverage) {
$this->state->write(self::KEY_COVERAGE_MARKER, ''); $this->state->write(self::KEY_COVERAGE_MARKER, '');
} }
// First `--tia --coverage` run has nothing to merge against: if we // First `--tia --coverage` run: no cache to merge against yet, must record the full suite.
// replay, the coverage driver sees only the affected tests and the
// report collapses to near-zero coverage. Fall back to recording
// (full suite) to seed the cache for next time.
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) { if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
return $this->enterRecordMode($arguments); return $this->enterRecordMode($arguments);
} }
@ -878,10 +599,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main'; $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
if ($replayingGlobal) { 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); $this->installWorkerReplay($projectRoot);
return $arguments; return $arguments;
@ -891,9 +608,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $arguments; return $arguments;
} }
// Piggyback: PHPUnit starts its coverage driver, `CoverageCollector`
// harvests the per-test edges in `terminate()`. The Recorder stays
// idle — starting our own driver would corrupt PHPUnit's data.
if ($this->piggybackCoverage) { if ($this->piggybackCoverage) {
$this->recordingActive = true; $this->recordingActive = true;
@ -903,11 +617,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$recorder = $this->recorder; $recorder = $this->recorder;
if (! $recorder->driverAvailable()) { if (! $recorder->driverAvailable()) {
// Worker PHP can differ from the parent (Herd profile, custom
// `php.ini` scan dir, stripped CI runner). Drop a sentinel so
// the parent surfaces a single warning at end-of-run instead
// of letting the missing per-test edges and results pass
// unnoticed.
$this->state->write( $this->state->write(
self::KEY_WORKER_NO_DRIVER_PREFIX.$this->workerToken().'.json', self::KEY_WORKER_NO_DRIVER_PREFIX.$this->workerToken().'.json',
'{}', '{}',
@ -922,13 +631,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $arguments; 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 private function installWorkerReplay(string $projectRoot): void
{ {
$graph = $this->loadGraph($projectRoot); $graph = $this->loadGraph($projectRoot);
@ -959,6 +661,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->replayGraph = $graph; $this->replayGraph = $graph;
$this->affectedFiles = $affectedSet; $this->affectedFiles = $affectedSet;
if ((string) Parallel::getGlobal(self::FILTERED_GLOBAL) === '1') {
TestSuite::getInstance()->tests->addTestCaseFilter(
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
);
}
} }
/** /**
@ -980,13 +688,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$branchSha = $graph->recordedAtSha($this->branch); $branchSha = $graph->recordedAtSha($this->branch);
$changed = $changedFiles->since($branchSha) ?? []; $changed = $changedFiles->since($branchSha) ?? [];
// 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. Passing the recorded sha also catches reverts: a file
// that was edited last run but is now back to its committed
// form no longer looks "changed".
$changed = $changedFiles->filterUnchangedSinceLastRun( $changed = $changedFiles->filterUnchangedSinceLastRun(
$changed, $changed,
$graph->lastRunTree($this->branch), $graph->lastRunTree($this->branch),
@ -1003,11 +704,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->registerRecap(); $this->registerRecap();
if ($this->filteredMode) {
TestSuite::getInstance()->tests->addTestCaseFilter(
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
);
}
if (! Parallel::isEnabled()) { if (! Parallel::isEnabled()) {
return $arguments; return $arguments;
} }
// Parallel: persist affected set so workers can install the filter.
if (! $this->persistAffectedSet($affected)) { if (! $this->persistAffectedSet($affected)) {
$this->output->writeln( $this->output->writeln(
' <fg=red>TIA</> failed to persist affected set — running full suite.', ' <fg=red>TIA</> failed to persist affected set — running full suite.',
@ -1016,12 +722,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $arguments; return $arguments;
} }
// Clear stale partials from a previous interrupted run so the merge
// pass doesn't pick up results from an unrelated invocation.
$this->purgeWorkerPartials(); $this->purgeWorkerPartials();
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1'); Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
if ($this->filteredMode) {
Parallel::setGlobal(self::FILTERED_GLOBAL, '1');
}
return $arguments; return $arguments;
} }
@ -1047,27 +755,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
{ {
$recorder = $this->recorder; $recorder = $this->recorder;
// Piggyback: PHPUnit's coverage driver is already running under
// `--coverage`. We don't need our own driver — `CoverageCollector`
// harvests the per-test edges from PHPUnit's shared `CodeCoverage`
// at terminate time. Skip the driver check entirely in this mode.
if (! $this->piggybackCoverage && ! $recorder->driverAvailable()) { if (! $this->piggybackCoverage && ! $recorder->driverAvailable()) {
// Both series and parallel record require the coverage driver.
// Parallel also requires it because workers inherit the parent's
// PHP config — if the parent lacks the driver, workers will too
// and would silently produce no graph. Warn once, up-front, and
// continue running the suite without TIA so the user still gets
// their test results.
$this->emitCoverageDriverMissing(); $this->emitCoverageDriverMissing();
return $arguments; return $arguments;
} }
if (Parallel::isEnabled()) { 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(); $this->purgeWorkerPartials();
Parallel::setGlobal(self::RECORDING_GLOBAL, '1'); Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
@ -1140,20 +834,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->state->write($this->workerEdgesKey($this->workerToken()), $json); $this->state->write($this->workerEdgesKey($this->workerToken()), $json);
} }
/**
* @return list<string> State keys of per-worker edges partials.
*/
private function collectWorkerEdgesPartials(): array private function collectWorkerEdgesPartials(): array
{ {
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX); return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
} }
/**
* Reads per-worker "no driver available" sentinels and surfaces a
* single warning to the parent's terminal. Self-clears so the
* sentinel doesn't leak into the next run. No-op when every worker
* had a usable coverage driver.
*/
private function reportMissingWorkerDrivers(): void private function reportMissingWorkerDrivers(): void
{ {
$keys = $this->state->keysWithPrefix(self::KEY_WORKER_NO_DRIVER_PREFIX); $keys = $this->state->keysWithPrefix(self::KEY_WORKER_NO_DRIVER_PREFIX);
@ -1183,11 +868,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
} }
/**
* Worker-side flush of replay state (collected results + cache-hit
* counter) into a per-worker partial file. Parent merges them in
* `addOutput` so the graph snapshot + summary reflect the full run.
*/
private function flushWorkerReplay(): void private function flushWorkerReplay(): void
{ {
/** @var ResultCollector $collector */ /** @var ResultCollector $collector */
@ -1195,13 +875,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$results = $collector->all(); $results = $collector->all();
if ($results === [] && $this->replayedCount === 0 && $this->executedCount === 0) { if ($results === [] && $this->replayedCount === 0 && $this->affectedCount === 0 && $this->executedCount === 0) {
return; return;
} }
$json = json_encode([ $json = json_encode([
'results' => $results, 'results' => $results,
'replayed' => $this->replayedCount, 'replayed' => $this->replayedCount,
'affected' => $this->affectedCount,
'executed' => $this->executedCount, 'executed' => $this->executedCount,
], JSON_UNESCAPED_SLASHES); ], JSON_UNESCAPED_SLASHES);
@ -1212,19 +893,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->state->write($this->workerResultsKey($this->workerToken()), $json); $this->state->write($this->workerResultsKey($this->workerToken()), $json);
} }
/**
* @return list<string> State keys of per-worker replay partials.
*/
private function collectWorkerReplayPartials(): array private function collectWorkerReplayPartials(): array
{ {
return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX); return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX);
} }
/**
* Parent-side merge of per-worker replay partials. Feeds the results into
* the parent's `ResultCollector` so the existing snapshot pass persists
* them, and rolls up the cache-hit counts so the summary is accurate.
*/
private function mergeWorkerReplayPartials(): void private function mergeWorkerReplayPartials(): void
{ {
/** @var ResultCollector $collector */ /** @var ResultCollector $collector */
@ -1248,6 +921,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->replayedCount += $decoded['replayed']; $this->replayedCount += $decoded['replayed'];
} }
if (isset($decoded['affected']) && is_int($decoded['affected'])) {
$this->affectedCount += $decoded['affected'];
}
if (isset($decoded['executed']) && is_int($decoded['executed'])) { if (isset($decoded['executed']) && is_int($decoded['executed'])) {
$this->executedCount += $decoded['executed']; $this->executedCount += $decoded['executed'];
} }
@ -1350,47 +1027,25 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $out; 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.
*/
/**
* Hooks a recap callback into Collision's `DefaultPrinter` so TIA's
* counts ride along the "Tests: N passed (M assertions, ...)" line
* instead of printing on their own block. Collision joins each
* callback's return value with a gray `, ` separator, so we return
* a single fragment like `728 replayed via tia` (or nothing when
* there's no replay activity to report).
*/
private function registerRecap(): void private function registerRecap(): void
{ {
DefaultPrinter::addRecap(function (): string { DefaultPrinter::addRecap(function (): string {
// Parallel mode: worker replays live in other processes and // mergeWorkerReplayPartials fires before addOutput on --parallel, which is intentional:
// flushed their counters to disk on terminate. Collision's // partial keys are deleted on read so the later addOutput call becomes a no-op.
// `writeRecap` fires inside `ExecutionFinished`, which is
// strictly before `addOutput` — so we must merge right here
// or the fragment below would read 0 and the suffix would
// silently disappear on `--tia --parallel`. The merge is
// idempotent: partial keys are deleted on read, so the
// later `addOutput` call becomes a no-op.
if (Parallel::isEnabled() && ! Parallel::isWorker()) { if (Parallel::isEnabled() && ! Parallel::isWorker()) {
$this->mergeWorkerReplayPartials(); $this->mergeWorkerReplayPartials();
} }
$fragments = []; $fragments = [];
if ($this->executedCount > 0) { if ($this->affectedCount > 0) {
$fragments[] = $this->executedCount.' affected'; $fragments[] = $this->affectedCount.' affected';
}
$uncachedCount = max(0, $this->executedCount - $this->affectedCount);
if ($uncachedCount > 0) {
$fragments[] = $uncachedCount.' uncached';
} }
if ($this->replayedCount > 0) { if ($this->replayedCount > 0) {
@ -1418,21 +1073,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->setRecordedAtSha($this->branch, $currentSha); $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) ?? []; $workingTreeFiles = $changedFiles->since($currentSha) ?? [];
$graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles)); $graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles));
$this->saveGraph($graph); $this->saveGraph($graph);
} }
/**
* In-memory equivalent of `snapshotTestResults()` — transfers the
* collected results straight into the given graph instance without a
* load/save round-trip. Used on the record path where the graph
* hasn't hit disk yet and a separate `loadGraph()` would find nothing.
*/
private function seedResultsInto(Graph $graph): void private function seedResultsInto(Graph $graph): void
{ {
/** @var ResultCollector $collector */ /** @var ResultCollector $collector */
@ -1452,11 +1098,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$collector->reset(); $collector->reset();
} }
/**
* 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 private function snapshotTestResults(): void
{ {
/** @var ResultCollector $collector */ /** @var ResultCollector $collector */
@ -1504,14 +1145,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
/** /**
* 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<string, mixed>, environmental: array<string, mixed>} $current * @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
*/ */
private function tryRemoteBaselineForDrift(array $current): ?Graph private function tryRemoteBaselineForDrift(array $current): ?Graph
@ -1545,9 +1178,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
/** /**
* Maps `Fingerprint::structuralDrift()` field names to a human
* label suitable for the `(reason)` part of the rebuild banner.
*
* @param list<string> $drift * @param list<string> $drift
*/ */
private function formatStructuralDrift(array $drift): string private function formatStructuralDrift(array $drift): string
@ -1558,6 +1188,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
'phpunit_xml' => 'phpunit.xml', 'phpunit_xml' => 'phpunit.xml',
'phpunit_xml_dist' => 'phpunit.xml.dist', 'phpunit_xml_dist' => 'phpunit.xml.dist',
'vite_config' => 'vite.config', 'vite_config' => 'vite.config',
'package_json' => 'package.json',
'package_lock' => 'Node lockfile',
'js_config' => 'JS/TS config',
'pest_factory' => 'Pest internals', 'pest_factory' => 'Pest internals',
'pest_method_factory' => 'Pest internals', 'pest_method_factory' => 'Pest internals',
]; ];
@ -1574,16 +1207,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return implode(', ', array_keys($seen)); 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 private function composerLockDelta(string $projectRoot, string $sha): string
{ {
$current = @file_get_contents($projectRoot.'/composer.lock'); $current = @file_get_contents($projectRoot.'/composer.lock');
@ -1626,8 +1249,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
sort($changes); sort($changes);
// Cap at a sensible number — a wholesale `composer update`
// could list 50+ packages and bury the prompt.
$maxShown = 8; $maxShown = 8;
if (count($changes) > $maxShown) { if (count($changes) > $maxShown) {
$extra = count($changes) - $maxShown; $extra = count($changes) - $maxShown;
@ -1639,17 +1260,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
/** /**
* Unions the project's full migration-defined table set into every
* test that uses a Laravel DB-resetting trait. Captures the
* seeded-attribute case where `parent::setUp()` ran inserts before
* `TableTracker` armed and the test body issued no further queries
* — without this, those tests would have empty `$testTables` and
* be silently skipped on migration changes.
*
* Tests that DID record specific tables in their body keep those
* (the union is additive). The migration scan is cheap (one pass
* over `database/migrations/`) and only runs once per record.
*
* @param array<string, array<int, string>> $perTestTables * @param array<string, array<int, string>> $perTestTables
* @param array<string, true> $perTestUsesDatabase * @param array<string, true> $perTestUsesDatabase
* @return array<string, array<int, string>> * @return array<string, array<int, string>>

View File

@ -11,64 +11,26 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
/** /**
* Pulls a team-shared TIA baseline on the first `--tia` run so new * Downloads a team-shared TIA baseline from GitHub workflow artifacts so new contributors and
* contributors and fresh CI workspaces start in replay mode instead of * fresh CI workspaces start in replay mode. Artifacts are used instead of releases because they
* paying the ~30s record cost. * produce no tag (no push cascade), support tunable retention, and can only be published by CI.
* *
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow * Fingerprint validation happens in `Tia::handleParent` after the blobs land; a mismatched
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full * environment falls through to the normal record path.
* suite under `--tia` and uploads the `.pest/tia/` directory as a named
* artifact (`pest-tia-baseline`) containing `graph.json` +
* `coverage.bin`. On dev
* machines, this class finds the latest successful run of that workflow
* and downloads the artifact via `gh`.
*
* Why artifacts, not releases:
* - No tag is created → no `push` event cascade into CI workflows.
* - No release event → no deploy workflows tied to `release:published`.
* - Retention is run-scoped and tunable (1-90 days) instead of clobbering
* a single floating tag.
* - Publishing is strictly CI-only: artifacts can't be produced from a
* developer's laptop. This enforces the "CI is the authoritative
* publisher" policy that local-publish paths would otherwise erode.
*
* Fingerprint validation happens back in `Tia::handleParent` after the
* blobs are written: a mismatched environment (different PHP version,
* composer.lock, etc.) discards the pulled baseline and falls through to
* the regular record path.
* *
* @internal * @internal
*/ */
final readonly class BaselineSync final readonly class BaselineSync
{ {
/**
* Conventional workflow filename teams publish from. Not configurable
* for MVP — teams that outgrow the default can set
* `PEST_TIA_BASELINE_WORKFLOW` later.
*/
private const string WORKFLOW_FILE = 'tia-baseline.yml'; private const string WORKFLOW_FILE = 'tia-baseline.yml';
/**
* Artifact name the workflow uploads under. The artifact is a zip
* containing `graph.json` (always) + `coverage.bin` (optional).
*/
private const string ARTIFACT_NAME = 'pest-tia-baseline'; private const string ARTIFACT_NAME = 'pest-tia-baseline';
/**
* Asset filenames inside the artifact — mirror the state keys so the
* CI publisher and the sync consumer stay in lock-step.
*/
private const string GRAPH_ASSET = Tia::KEY_GRAPH; private const string GRAPH_ASSET = Tia::KEY_GRAPH;
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE; private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
/** // 24 h cooldown after a failed fetch so repeated `pest --tia` calls don't re-hit `gh run list`.
* Cooldown (in seconds) applied after a failed baseline fetch.
* Rationale: when the remote workflow hasn't published yet, every
* `pest --tia` invocation would otherwise re-hit `gh run list` and
* re-print the publish instructions — noisy + slow. Back off for a
* day, let the user override with `--refetch`.
*/
private const int FETCH_COOLDOWN_SECONDS = 86400; private const int FETCH_COOLDOWN_SECONDS = 86400;
public function __construct( public function __construct(
@ -76,16 +38,6 @@ final readonly class BaselineSync
private OutputInterface $output, private OutputInterface $output,
) {} ) {}
/**
* Detects the repo, fetches the latest baseline artifact, writes its
* contents into the TIA state store. Returns true when the graph blob
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
* never reads it.
*
* `$force = true` (driven by `--refetch`) ignores the post-failure
* cooldown so the user can retry on demand without waiting out the
* 24h window.
*/
public function fetchIfAvailable(string $projectRoot, bool $force = false): bool public function fetchIfAvailable(string $projectRoot, bool $force = false): bool
{ {
$repo = $this->detectGitHubRepo($projectRoot); $repo = $this->detectGitHubRepo($projectRoot);
@ -126,9 +78,6 @@ final readonly class BaselineSync
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']); $this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
} }
// Successful fetch wipes any stale cooldown so the next failure
// (say, weeks later) starts a fresh 24h timer rather than inheriting
// one from the deep past.
$this->clearCooldown(); $this->clearCooldown();
$this->output->writeln(sprintf( $this->output->writeln(sprintf(
@ -139,10 +88,6 @@ final readonly class BaselineSync
return true; return true;
} }
/**
* Seconds left on the cooldown, or `null` when the cooldown is cleared
* / expired / unreadable.
*/
private function cooldownRemaining(): ?int private function cooldownRemaining(): ?int
{ {
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN); $raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
@ -187,18 +132,6 @@ final readonly class BaselineSync
return $seconds.'s'; return $seconds.'s';
} }
/**
* Prints actionable instructions for publishing a first baseline when
* the consumer-side fetch finds nothing.
*
* Behaviour splits on environment:
* - **CI:** a single line. The current run is almost certainly *the*
* publisher (it's what this workflow does by definition), so
* printing the whole recipe again is redundant and noisy.
* - **Local:** the full recipe, adapted to Laravel's pre-test steps
* (`.env.example` copy + `artisan key:generate`) when the framework
* is present. Generic PHP projects get a slimmer skeleton.
*/
private function emitPublishInstructions(string $repo): void private function emitPublishInstructions(string $repo): void
{ {
if ($this->isCi()) { if ($this->isCi()) {
@ -237,12 +170,7 @@ final readonly class BaselineSync
$this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]); $this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]);
} }
/** // `CI=true` alone is ambiguous (users set it locally) — require a provider-specific env var.
* True when running inside a CI provider. Conservative list — only the
* three providers Pest formally supports / sees in the wild. `CI=true`
* alone is ambiguous (users set it locally too) so we require a
* provider-specific flag.
*/
private function isCi(): bool private function isCi(): bool
{ {
return getenv('GITHUB_ACTIONS') === 'true' return getenv('GITHUB_ACTIONS') === 'true'
@ -256,12 +184,6 @@ final readonly class BaselineSync
&& InstalledVersions::isInstalled('laravel/framework'); && InstalledVersions::isInstalled('laravel/framework');
} }
/**
* Laravel projects need a populated `.env` and a generated `APP_KEY`
* before the first boot, otherwise `Illuminate\Encryption\MissingAppKeyException`
* fires during `setUp`. Include the standard pre-test dance plus the
* extension set typical Laravel apps rely on.
*/
private function laravelWorkflowYaml(): string private function laravelWorkflowYaml(): string
{ {
return <<<'YAML' return <<<'YAML'
@ -329,12 +251,6 @@ jobs:
YAML; YAML;
} }
/**
* Parses `.git/config` for the `origin` remote and extracts
* `org/repo`. Supports the two URL flavours git emits out of the box.
* Non-GitHub remotes (GitLab, Bitbucket, self-hosted) → null, which
* silently opts the repo out of auto-sync.
*/
private function detectGitHubRepo(string $projectRoot): ?string private function detectGitHubRepo(string $projectRoot): ?string
{ {
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
@ -349,29 +265,20 @@ YAML;
return null; return null;
} }
// Find the `[remote "origin"]` section and the first `url` line
// inside it. Tolerates INI whitespace quirks (tabs, CRLF).
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) { if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
return null; return null;
} }
$url = $match[1]; $url = $match[1];
// SSH: git@github.com:org/repo(.git)
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) { if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
return $m[1]; return $m[1];
} }
// HTTPS: https://github.com/org/repo(.git) (optional trailing slash)
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) { if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return $m[1]; return $m[1];
} }
// SSH URL form: ssh://[user@]github.com[:port]/org/repo(.git).
// Some teams configure this explicitly to pin the SSH port; the
// colon-separated form above doesn't match. Mirrors the parser
// in `Storage::originIdentity` so the same remote produces the
// same project key for both storage and remote-fetch.
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) { if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return $m[1]; return $m[1];
} }
@ -379,15 +286,7 @@ YAML;
return null; return null;
} }
/** /** @return array{graph: string, coverage: ?string}|null */
* Two-step fetch: find the latest successful run of the baseline
* workflow, then download the named artifact from it. Returns
* `['graph' => bytes, 'coverage' => bytes|null]` on success, or null
* if `gh` is unavailable, the workflow hasn't run yet, the artifact
* is missing, or any shell step fails.
*
* @return array{graph: string, coverage: ?string}|null
*/
private function download(string $repo): ?array private function download(string $repo): ?array
{ {
if (! $this->commandExists('gh')) { if (! $this->commandExists('gh')) {
@ -442,11 +341,6 @@ YAML;
]; ];
} }
/**
* Queries GitHub for the most recent successful run of the baseline
* workflow. `--jq '.[0].databaseId // empty'` coerces "no runs found"
* into an empty string, which we map to null.
*/
private function latestSuccessfulRunId(string $repo): ?string private function latestSuccessfulRunId(string $repo): ?string
{ {
$process = new Process([ $process = new Process([

View File

@ -27,17 +27,6 @@ final readonly class ChangedFiles
public function __construct(private string $projectRoot) {} public function __construct(private string $projectRoot) {}
/** /**
* @return array<int, string>|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.
*/
/**
* Removes files whose current content hash matches the snapshot from the
* last `--tia` run. Used to ignore "dirty but unchanged" files — a file
* that git still reports as modified but whose content is bit-identical
* to the previous TIA invocation.
*
* @param array<int, string> $files project-relative paths. * @param array<int, string> $files project-relative paths.
* @param array<string, string> $lastRunTree path → content hash from last run. * @param array<string, string> $lastRunTree path → content hash from last run.
* @return array<int, string> * @return array<int, string>
@ -48,12 +37,7 @@ final readonly class ChangedFiles
return $files; return $files;
} }
// Union: `$files` (what git currently reports) + every path that was // Union with last-run snapshot: catches reverts that git reports clean but are new vs the snapshot.
// dirty last run. The second set matters for reverts — when a user
// undoes a local edit, the file matches HEAD again and git reports
// it clean, so it would never enter `$files`. But it has genuinely
// changed vs the snapshot we captured during the bad run, so it
// must be checked.
$candidates = array_fill_keys($files, true); $candidates = array_fill_keys($files, true);
foreach (array_keys($lastRunTree) as $snapshotted) { foreach (array_keys($lastRunTree) as $snapshotted) {
@ -68,28 +52,14 @@ final readonly class ChangedFiles
$exists = is_file($absolute); $exists = is_file($absolute);
if ($snapshot === null) { if ($snapshot === null) {
// File wasn't in last-run tree at all — trust git's signal.
$remaining[] = $file; $remaining[] = $file;
continue; continue;
} }
if (! $exists) { if (! $exists) {
// Missing on disk. We always invalidate here, even when // Always invalidate deletions — a stale cached result from before the deletion
// the snapshot also recorded "deleted" (sentinel ''). // would persist forever otherwise, even if the snapshot recorded the empty sentinel.
// The `snapshot=='' && !exists` shortcut would in
// principle say "no change since last run, cached
// result is still valid" — but it's only safe if the
// cached result was recorded *during* a run that saw
// the file as deleted. A previous run that captured
// the deletion in `lastRunTree` but failed to refresh
// the cached pass/fail (paratest worker race, an
// earlier plugin bug, etc.) would leave the cache
// stuck on a stale pass from before the deletion.
// Skipping invalidation in that state perpetuates the
// wrong result on every subsequent run. Treat any
// missing file as a change; cost is one re-run per
// `--tia` while the file stays deleted.
$remaining[] = $file; $remaining[] = $file;
continue; continue;
@ -104,21 +74,9 @@ final readonly class ChangedFiles
} }
if ($hash === $snapshot) { if ($hash === $snapshot) {
// Same state as the last TIA invocation — cached
// result is still valid, no need to re-run.
continue; continue;
} }
// Differs from the snapshot. This includes the
// revert-back-to-baseline case (last run had a real edit
// and was cached against that edit; this run reverted).
// Even though the file now matches what's at the recorded
// SHA, the cached test result reflects the *modified*
// version, not the baseline version — so it's stale and
// the test must re-run to refresh the cache. An earlier
// version of this filter short-circuited on
// matches-baseline, which served the stale failure
// forever after the user reverted.
$remaining[] = $file; $remaining[] = $file;
} }

View File

@ -24,6 +24,52 @@ use Pest\Support\Container;
*/ */
final class Configuration final class Configuration
{ {
/**
* Activates TIA for every run without requiring the `--tia` CLI flag.
*
* @return $this
*/
public function always(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markAlways();
return $this;
}
/**
* Restricts the `always()` activation to local environments only.
* On CI (`--ci` flag or `CI` env var), TIA is skipped even if `always()` is set.
* Explicit `--tia` on the CLI always takes effect regardless.
*
* @return $this
*/
public function locally(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markLocally();
return $this;
}
/**
* In replay mode, instead of short-circuiting cached results for unaffected
* tests, narrows PHPUnit to only the affected files — unaffected tests are
* never loaded. Can also be enabled with the `--filtered` CLI flag.
*
* @return $this
*/
public function filtered(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markFiltered();
return $this;
}
/** /**
* Adds watch-pattern → test-directory mappings that supplement (or * Adds watch-pattern → test-directory mappings that supplement (or
* override) the built-in defaults. * override) the built-in defaults.

View File

@ -5,30 +5,14 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
/** /**
* Captures environmental inputs that, when changed, may make the TIA graph * Two-bucket fingerprint for TIA staleness detection.
* or its recorded results stale. The fingerprint is split into two buckets:
* *
* - **structural** — describes what the graph's *edges* were recorded * - **structural**: inputs whose drift means graph *edges* may be wrong → full rebuild.
* against. If any of these drift (`composer.lock`, `composer.json`, * `tests/TestCase.php` and `tests/Pest.php` are intentionally absent; they're covered by
* `phpunit.xml{,.dist}`, `vite.config.*`, Pest's factory codegen) the * `Recorder::linkAncestorFiles` and the watch pattern, giving precise per-test invalidation.
* edges themselves are potentially wrong and the graph must rebuild * - **environmental**: runtime inputs (PHP version, extensions, env files) whose drift means
* from scratch. `tests/TestCase.php` and `tests/Pest.php` are * edges are still valid but cached results may not reproduce → drop results and re-run.
* intentionally NOT here — those are handled by per-test ancestor * Pest's own version is absent; `composer.lock` moves whenever Pest is upgraded.
* linking (`Recorder::linkAncestorFiles`) and the Php watch pattern
* respectively, which give precise invalidation rather than a wholesale
* rebuild.
* - **environmental** — describes the *runtime* the results were captured
* on (PHP minor, extension set). 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. Pest's own version is intentionally NOT
* here — `composer.lock`'s structural hash already moves whenever the
* installed Pest version changes.
*
* 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 * @internal
*/ */
@ -83,7 +67,11 @@ final readonly class Fingerprint
// are included in the environmental bucket. They are commonly // are included in the environmental bucket. They are commonly
// git-ignored, so watch patterns alone cannot reliably notice // git-ignored, so watch patterns alone cannot reliably notice
// edits; a drift drops cached results and re-executes the suite. // edits; a drift drops cached results and re-executes the suite.
private const int SCHEMA_VERSION = 13; // v14: Node/Vite resolver inputs (`package*.json`, `tsconfig.*`,
// `jsconfig.*`) are included in the structural bucket. They can
// reshape the persisted JS module graph without touching
// `vite.config.*` itself.
private const int SCHEMA_VERSION = 14;
/** /**
* @return array{ * @return array{
@ -96,40 +84,19 @@ final readonly class Fingerprint
return [ return [
'structural' => [ 'structural' => [
'schema' => self::SCHEMA_VERSION, 'schema' => self::SCHEMA_VERSION,
// `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), 'composer_lock' => self::composerLockHash($projectRoot),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), '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 via `ContentHash::of()` so cosmetic edits
// (comments, formatting) don't drift the fingerprint.
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'), 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.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), 'vite_config' => self::viteConfigHash($projectRoot),
// `composer.json` hashed against a behavioural subset: 'package_json' => self::packageJsonHash($projectRoot),
// autoload(-dev), require(-dev), extra (Laravel 'package_lock' => self::packageLockHash($projectRoot),
// package discovery), repositories, minimum-stability, 'js_config' => self::jsConfigHash($projectRoot),
// and the platform / allow-plugins entries from
// `config`. Cosmetic fields (description, keywords,
// scripts, authors, funding, support) are excluded.
'composer_json' => self::composerJsonHash($projectRoot), 'composer_json' => self::composerJsonHash($projectRoot),
], ],
'environmental' => [ 'environmental' => [
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch // Minor only (8.4, not 8.4.19) — CI's patch rarely matches dev installs.
// 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, 'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
'extensions' => self::extensionsFingerprint($projectRoot), 'extensions' => self::extensionsFingerprint($projectRoot),
'env_files' => self::envFilesHash($projectRoot), 'env_files' => self::envFilesHash($projectRoot),
@ -138,9 +105,6 @@ final readonly class Fingerprint
} }
/** /**
* True when the structural buckets match. Drift here means the edges
* are potentially wrong; caller should discard the graph and rebuild.
*
* @param array<string, mixed> $a * @param array<string, mixed> $a
* @param array<string, mixed> $b * @param array<string, mixed> $b
*/ */
@ -156,12 +120,6 @@ final readonly class Fingerprint
} }
/** /**
* 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<string, mixed> $stored * @param array<string, mixed> $stored
* @param array<string, mixed> $current * @param array<string, mixed> $current
* @return list<string> * @return list<string>
@ -195,11 +153,6 @@ final readonly class Fingerprint
} }
/** /**
* 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<string, mixed> $stored * @param array<string, mixed> $stored
* @param array<string, mixed> $current * @param array<string, mixed> $current
* @return list<string> * @return list<string>
@ -244,12 +197,8 @@ final readonly class Fingerprint
return self::bucket($fingerprint, 'environmental'); return self::bucket($fingerprint, 'environmental');
} }
// Legacy flat-shape fingerprints (schema ≤ 3) return empty, causing structuralMatches to fail → rebuild.
/** /**
* Returns `$fingerprint[$key]` as an `array<string, mixed>` 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<string, mixed> $fingerprint * @param array<string, mixed> $fingerprint
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -272,13 +221,6 @@ final readonly class Fingerprint
return $normalised; return $normalised;
} }
/**
* Combined hash of every `vite.config.{ts,js,mjs,cjs,mts}` present
* at the project root. Most projects have exactly one; we accept
* any of the five recognised extensions without assuming which
* the user picked. Returns null when no config file exists —
* treated as "no Vite project" by the matcher, no drift.
*/
private static function viteConfigHash(string $projectRoot): ?string private static function viteConfigHash(string $projectRoot): ?string
{ {
$parts = []; $parts = [];
@ -294,12 +236,79 @@ final readonly class Fingerprint
return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
} }
/** private static function jsConfigHash(string $projectRoot): ?string
* Hashes environment files that can globally alter app boot behaviour. {
* These files are often git-ignored, so they cannot rely on changed-file $parts = [];
* detection. The environmental bucket keeps graph edges while forcing all
* cached results to refresh after an env edit. foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
*/ $hash = self::hashIfExists($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function packageJsonHash(string $projectRoot): ?string
{
$path = $projectRoot.'/package.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;
}
$relevant = [
'type' => $data['type'] ?? null,
'packageManager' => $data['packageManager'] ?? null,
'dependencies' => $data['dependencies'] ?? null,
'devDependencies' => $data['devDependencies'] ?? null,
'optionalDependencies' => $data['optionalDependencies'] ?? null,
'peerDependencies' => $data['peerDependencies'] ?? null,
'overrides' => $data['overrides'] ?? null,
'resolutions' => $data['resolutions'] ?? null,
'imports' => $data['imports'] ?? null,
'exports' => $data['exports'] ?? null,
'browser' => $data['browser'] ?? null,
];
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
}
private static function packageLockHash(string $projectRoot): ?string
{
$parts = [];
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
$hash = self::hashIfExists($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function envFilesHash(string $projectRoot): ?string private static function envFilesHash(string $projectRoot): ?string
{ {
$paths = [ $paths = [
@ -348,14 +357,6 @@ final readonly class Fingerprint
return hash('xxh128', implode("\n", $parts)); return 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 private static function composerJsonHash(string $projectRoot): ?string
{ {
$path = $projectRoot.'/composer.json'; $path = $projectRoot.'/composer.json';
@ -403,15 +404,6 @@ final readonly class Fingerprint
return $json === false ? null : hash('xxh128', $json); 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 private static function composerLockHash(string $projectRoot): ?string
{ {
$path = $projectRoot.'/composer.lock'; $path = $projectRoot.'/composer.lock';
@ -492,12 +484,6 @@ final readonly class Fingerprint
return is_string($reference) ? $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 private static function sortRecursively(mixed &$value): void
{ {
if (! is_array($value)) { if (! is_array($value)) {
@ -537,16 +523,8 @@ final readonly class Fingerprint
return $hash === false ? null : $hash; return $hash === false ? null : $hash;
} }
/** // Only hashes `ext-*` entries declared in composer.json — incidental extensions loaded on the
* Deterministic hash of the extensions the project actually depends on — // machine but not declared can't affect suite correctness, so they're excluded to reduce noise.
* the `ext-*` entries in composer.json's `require` / `require-dev`. An
* incidental extension loaded on the developer's machine (or on CI) but
* not declared as a dependency can't affect correctness of the test
* suite, so we ignore it here to keep the drift signal quiet.
*
* Declared extensions that aren't currently loaded record as `missing`,
* which is itself a drift signal worth surfacing.
*/
private static function extensionsFingerprint(string $projectRoot): string private static function extensionsFingerprint(string $projectRoot): string
{ {
$extensions = self::declaredExtensions($projectRoot); $extensions = self::declaredExtensions($projectRoot);
@ -567,15 +545,7 @@ final readonly class Fingerprint
return hash('xxh128', implode("\n", $parts)); return hash('xxh128', implode("\n", $parts));
} }
/** /** @return list<string> */
* Extension names (without the `ext-` prefix) that appear as keys under
* `require` or `require-dev` in the project's composer.json. Returns
* an empty list when composer.json is missing / unreadable / malformed,
* so the environmental fingerprint stays stable in those cases rather
* than flapping.
*
* @return list<string>
*/
private static function declaredExtensions(string $projectRoot): array private static function declaredExtensions(string $projectRoot): array
{ {
$path = $projectRoot.'/composer.json'; $path = $projectRoot.'/composer.json';

View File

@ -11,100 +11,35 @@ use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
/** /**
* File-level Test Impact Analysis graph. * Dependency graph: test file → set<source file>. Skips unchanged tests on replay.
* * Source files are indexed by numeric id to keep the on-disk JSON compact.
* Persists the mapping `test_file → set<source_file>` 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 * @internal
*/ */
final class Graph final class Graph
{ {
/** /** @var array<int, string> */
* Relative path of each known source file, indexed by numeric id.
*
* @var array<int, string>
*/
private array $files = []; private array $files = [];
/** /** @var array<string, int> */
* Reverse lookup: source file → numeric id.
*
* @var array<string, int>
*/
private array $fileIds = []; private array $fileIds = [];
/** /** @var array<string, array<int, int>> */
* Edges: test file (relative) → list of source file ids.
*
* @var array<string, array<int, int>>
*/
private array $edges = []; private array $edges = [];
/** /** @var array<string, array<int, string>> */
* Table edges: test file (relative) → list of lowercase SQL table
* names the test queried during record. Populated from the
* Recorder's `perTestTables()` snapshot; consumed at replay time
* to do surgical invalidation when a migration changes — the
* test only re-runs if its set intersects the tables the changed
* migration touches. Empty for tests that never hit the DB, which
* is exactly why those tests stay unaffected by migration edits.
*
* Unlike `$edges`, we store names rather than ids: the table
* universe is small (hundreds at most on a giant app), storing
* strings keeps the on-disk graph diff-readable, and the lookup
* cost is negligible compared to the per-file ids used above.
*
* @var array<string, array<int, string>>
*/
private array $testTables = []; private array $testTables = [];
/** /** @var array<string, array<int, string>> */
* Inertia page component edges: test file (relative) → list of
* component names the test server-side rendered (whatever was
* passed to `Inertia::render($component, …)`). Populated from
* `Recorder::perTestInertiaComponents()`; consumed at replay time
* so an edit to `resources/js/Pages/Users/Show.vue` only invalidates
* tests that rendered `Users/Show`. Same string-keyed shape as
* `$testTables` for the same diff-readable reasons.
*
* @var array<string, array<int, string>>
*/
private array $testInertiaComponents = []; private array $testInertiaComponents = [];
/** /** @var array<string, array<int, string>> */
* Inverted JS dependency map: project-relative source path under
* `resources/js/**` → list of Inertia page components that
* transitively import it. Populated at record time by
* `JsModuleGraph::build()` (Vite module graph via Node helper,
* with a PHP fallback). Replay uses this to route a
* `Components/Button.vue` edit directly to the pages that depend
* on it, intersecting against `$testInertiaComponents` for
* surgical invalidation.
*
* @var array<string, array<int, string>>
*/
private array $jsFileToComponents = []; private array $jsFileToComponents = [];
/** /** @var array<string, mixed> */
* Environment fingerprint captured at record time.
*
* @var array<string, mixed>
*/
private array $fingerprint = []; private array $fingerprint = [];
/** /**
* Per-branch baselines. Each branch independently tracks:
* - `sha` — last HEAD at which `--tia` ran on this branch
* - `tree` — content hashes of modified files at that point
* - `results` — per-test status + message + time
*
* Graph edges (test → source) stay shared across branches because
* structure doesn't change per branch. Only run-state is per-branch so
* a failing test on one branch doesn't poison another branch's replay.
*
* @var array<string, array{ * @var array<string, array{
* sha: ?string, * sha: ?string,
* tree: array<string, string>, * tree: array<string, string>,
@ -113,20 +48,10 @@ final class Graph
*/ */
private array $baselines = []; private array $baselines = [];
/** // Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink.
* 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; private readonly string $projectRoot;
/** /** @var array<string, true>|null */
* Cached project-relative test files that contain at least one test in the
* `arch` group.
*
* @var array<string, true>|null
*/
private ?array $archTestFiles = null; private ?array $archTestFiles = null;
public function __construct(string $projectRoot) public function __construct(string $projectRoot)
@ -136,9 +61,6 @@ final class Graph
$this->projectRoot = $real !== false ? $real : $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 public function link(string $testFile, string $sourceFile): void
{ {
$testRel = $this->relative($testFile); $testRel = $this->relative($testFile);
@ -158,20 +80,11 @@ final class Graph
} }
/** /**
* Returns the set of test files whose dependencies intersect $changedFiles.
*
* Two resolution paths:
* 1. **Coverage edges** — test depends on a PHP source file that changed.
* 2. **Watch patterns** — a non-PHP file (JS, CSS, config, …) matches a
* glob that maps to a test directory; every test under that directory
* is affected.
*
* @param array<int, string> $changedFiles Absolute or relative paths. * @param array<int, string> $changedFiles Absolute or relative paths.
* @return array<int, string> Relative test file paths. * @return array<int, string>
*/ */
public function affected(array $changedFiles): array public function affected(array $changedFiles): array
{ {
// Normalise all changed paths once.
$normalised = []; $normalised = [];
foreach ($changedFiles as $file) { foreach ($changedFiles as $file) {
@ -184,15 +97,9 @@ final class Graph
$affectedSet = []; $affectedSet = [];
// Migration changes don't flow through the coverage-edge path — // Migrations can't flow through coverage edges: `RefreshDatabase` gives every test an edge to
// `RefreshDatabase` in every test's `setUp()` means every test // every migration, so any migration change would re-run the whole DB suite. Route them via
// has an edge to every migration, so step 1 would re-run the // table-intersection instead; unparseable migrations fall through to the watch pattern.
// whole DB-touching suite on any migration edit. Route them
// separately: static-parse the migration source, union the
// referenced tables, and match tests whose recorded query
// footprint intersects that set. Missed files (rare: migrations
// with pure raw SQL or dynamic names) fall back to the watch
// pattern below.
$migrationPaths = []; $migrationPaths = [];
$nonMigrationPaths = []; $nonMigrationPaths = [];
@ -237,16 +144,22 @@ final class Graph
} }
} }
// Inertia page-component routing. When a page under // Inertia page routing: map changed page files to component names and intersect with recorded
// `resources/js/Pages/` changes, map it to the component name // component edges. Pages with no captured edges fall through to the watch pattern.
// Inertia would use (the path relative to `Pages/`, extension $globalFrontendRuntimeFiles = [];
// stripped) and intersect with the captured component edges.
// Only invalidates tests that actually rendered the page. foreach ($nonMigrationPaths as $rel) {
// Pages with no captured edges (never rendered during record, if (! $this->isGlobalFrontendRuntimePath($rel)) {
// brand-new on this branch) fall through to the watch-pattern continue;
// fallback — safe over-run. Pages handled here are tracked in }
// `$preciselyHandledPages` so the watch broadcast and JS-dep
// lookup don't re-route them. foreach (array_keys($this->testInertiaComponents) as $testFile) {
$affectedSet[$testFile] = true;
}
$globalFrontendRuntimeFiles[$rel] = true;
}
$changedComponents = []; $changedComponents = [];
$preciselyHandledPages = []; $preciselyHandledPages = [];
@ -263,17 +176,13 @@ final class Graph
} }
} }
// Shared JS files (Components, Layouts, composables, etc.) // Shared JS files: resolve via the recorded Vite module graph to their dependent page components.
// aren't Inertia pages but pages depend on them transitively. // Files absent from the map fall through to the watch pattern.
// `$jsFileToComponents` was computed at record time by walking
// Vite's module graph, so a change to
// `resources/js/Components/Button.vue` resolves directly to
// the set of page components that import it. Union those into
// `$changedComponents`. Files that aren't in the JS dep map
// fall through to the watch pattern below — same safety-net
// path the Inertia block above uses for unresolved pages.
$sharedFilesResolved = []; $sharedFilesResolved = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) {
continue;
}
if (isset($preciselyHandledPages[$rel])) { if (isset($preciselyHandledPages[$rel])) {
continue; continue;
} }
@ -295,23 +204,14 @@ final class Graph
} }
} }
// Orphan detection for NEW JS files. `$jsFileToComponents` is // New JS files absent from the record-time map: ask Vite (strict, no PHP fallback) which pages
// a record-time snapshot; files added since (a fresh Vue // import them. A negative answer suppresses the broad watch broadcast; Node is the only resolver
// component, a new shared util, etc.) are absent from it. // trustworthy enough to honour a negative (PHP parser can miss custom aliases).
// Today the broad watch pattern catches them — correct but
// pessimistic: a JS file that literally no page imports
// would still invalidate the entire browser dir.
//
// Fix: for each new JS file in the changed set, ask Vite
// (strict mode — no PHP fallback) which pages transitively
// import it. If none → orphan, suppress the broadcast. If
// some → precise union with their tests' components. The
// Node helper is the only resolver trustworthy enough to
// honour a *negative* answer (the PHP parser can silently
// miss custom aliases). When Node is unreachable we leave
// the files alone and let the watch pattern do its job.
$newJsFiles = []; $newJsFiles = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) {
continue;
}
if (isset($preciselyHandledPages[$rel])) { if (isset($preciselyHandledPages[$rel])) {
continue; continue;
} }
@ -331,12 +231,8 @@ final class Graph
$freshMap = JsModuleGraph::buildStrict($this->projectRoot); $freshMap = JsModuleGraph::buildStrict($this->projectRoot);
if ($freshMap === null) { if ($freshMap === null) {
// Vite resolver was unavailable (Node missing, cold-start // Vite resolver unavailable — falling back to watch pattern; surface a line so the user
// timeout, vite.config refused to load). Falling back to // knows precision was downgraded rather than leaving the slower replay unexplained.
// the broad watch pattern is the correct call, but
// doing so silently can make a slow replay feel
// inexplicable — surface a single line so the user
// knows precision was downgraded for these files.
$output = Container::getInstance()->get(OutputInterface::class); $output = Container::getInstance()->get(OutputInterface::class);
if ($output instanceof OutputInterface) { if ($output instanceof OutputInterface) {
$output->writeln(sprintf( $output->writeln(sprintf(
@ -349,9 +245,7 @@ final class Graph
$pages = $freshMap[$rel] ?? []; $pages = $freshMap[$rel] ?? [];
if ($pages === []) { if ($pages === []) {
// Vite itself says nothing imports this file. // Vite confirms no page imports this file — suppress the watch broadcast.
// Safe to skip — mark handled so the watch
// pattern below doesn't re-broadcast it.
$sharedFilesResolved[$rel] = true; $sharedFilesResolved[$rel] = true;
continue; continue;
@ -388,9 +282,8 @@ final class Graph
} }
} }
// 1. Coverage-edge lookup (PHP → PHP). Migrations are already // Coverage-edge lookup (PHP → PHP). Migrations already handled above; skipping here prevents
// handled above; skipping them here prevents their always-on // their always-on edges from re-running the whole DB suite.
// coverage edges from invalidating the whole DB suite.
$changedIds = []; $changedIds = [];
$unknownSourceDirs = []; $unknownSourceDirs = [];
$sourcePhpChanged = false; $sourcePhpChanged = false;
@ -410,28 +303,18 @@ final class Graph
$absolute = $this->projectRoot.'/'.$rel; $absolute = $this->projectRoot.'/'.$rel;
if (! is_file($absolute)) { if (! is_file($absolute)) {
// Deleted source file unknown to the graph — can't affect // Deleted source file unknown to the graph — no edge ever pointed to it.
// any test because no edge ever pointed to it.
continue; continue;
} }
// Source PHP file unknown to the graph — might be a new file
// that only exists on this branch (graph inherited from main).
// Only use the sibling heuristic for files that commonly
// participate in framework discovery / bootstrap. Ordinary new
// classes, enums, DTOs, services, etc. should not re-run sibling
// tests just because they live in the same directory.
if ($this->usesSiblingHeuristicForUnknownPhp($rel)) { if ($this->usesSiblingHeuristicForUnknownPhp($rel)) {
$unknownSourceDirs[dirname($rel)] = true; $unknownSourceDirs[dirname($rel)] = true;
} }
} }
} }
// Architecture tests inspect source structure by namespace / path rather // Arch tests inspect structure by namespace/path, never producing coverage edges for the files
// than by executing the inspected files. A new enum/class can therefore // they examine — so a new class can fail an arch expectation without any edge to it.
// fail an Arch expectation without ever producing a coverage edge. Keep
// this fallback narrow: only tests in Pest's `arch` group run, not the
// suite.
if ($sourcePhpChanged) { if ($sourcePhpChanged) {
foreach (array_keys($this->edges) as $testFile) { foreach (array_keys($this->edges) as $testFile) {
if ($this->isArchTestFile($testFile)) { if ($this->isArchTestFile($testFile)) {
@ -454,11 +337,8 @@ final class Graph
} }
} }
// Unknown Blade files can still be routed precisely when another // Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
// recorded Blade view statically references them (`@include`, // ancestors and invalidate only tests that covered them.
// `@extends`, `<x-alert />`, etc.). Walk the source-level Blade graph
// upward to rendered ancestors and invalidate tests that rendered those
// ancestors instead of broadcasting every Blade edit to the whole suite.
$staticallyHandledBlade = []; $staticallyHandledBlade = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($this->fileIds[$rel])) { if (isset($this->fileIds[$rel])) {
@ -480,29 +360,13 @@ final class Graph
$staticallyHandledBlade[$rel] = true; $staticallyHandledBlade[$rel] = true;
} elseif ($this->isBladeComponentPath($rel)) { } elseif ($this->isBladeComponentPath($rel)) {
// Anonymous Blade components are leaf templates. If nothing in // Anonymous component with no static usages — treat as orphan rather than broadcasting.
// the project statically renders the component, treat it like an
// orphan rather than running the full suite.
$staticallyHandledBlade[$rel] = true; $staticallyHandledBlade[$rel] = true;
} }
} }
// 2. Watch-pattern lookup — fallback for files we don't have // Watch-pattern fallback: files with no precise edges. Already-resolved files are excluded
// precise edges for. When a file is already in `$fileIds` step // to avoid re-broadcasting via the watch pattern and defeating the surgical match.
// 1 resolved it surgically; broadcasting it again through the
// watch pattern would re-add every test the pattern maps to,
// defeating the point of recording the edge in the first place.
// Blade templates captured via Laravel's view composer are the
// motivating case — we want their specific tests, not every
// feature test. Migrations whose static parse yielded nothing
// (exotic syntax, raw SQL) are funneled back in here too so
// broad invalidation still kicks in for edge cases we can't
// parse.
// Exclude paths that were already routed precisely through
// either the Inertia page-component path or the shared-JS
// dependency path. Broadcasting them again via the watch
// pattern would re-add every test the pattern maps to,
// defeating the surgical match.
$unknownToGraph = $unparseableMigrations; $unknownToGraph = $unparseableMigrations;
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($preciselyHandledPages[$rel])) { if (isset($preciselyHandledPages[$rel])) {
@ -516,8 +380,7 @@ final class Graph
} }
if (! isset($this->fileIds[$rel])) { if (! isset($this->fileIds[$rel])) {
if (! is_file($this->projectRoot.'/'.$rel)) { if (! is_file($this->projectRoot.'/'.$rel)) {
// Deleted file unknown to the graph — no edge ever // Deleted file unknown to the graph — no edge ever pointed to it.
// pointed to it, so it can't affect any test.
continue; continue;
} }
@ -535,22 +398,9 @@ final class Graph
$affectedSet[$testFile] = true; $affectedSet[$testFile] = true;
} }
// 3. Sibling heuristic for unknown source files. // Sibling heuristic: unknown PHP source files may be new files whose graph was inherited from
// // another branch. Run tests that cover neighbouring files in the same directory so framework-
// When a PHP source file is unknown to the graph (no test depends on // discovered files (Listeners, Events, Policies, etc.) aren't silently missed.
// it), it is either genuinely untested OR it was added on a branch
// whose graph was inherited from another branch (e.g. main). In the
// latter case the graph simply never saw the file.
//
// To avoid silent misses for framework-discovered files: find tests
// that already cover ANY file in the same directory. If
// `app/Listeners/SendWelcomeEmail.php` is unknown but neighbouring
// listeners are covered by a mail-flow test, run that test — it likely
// exercises the same discovery surface.
//
// This over-runs slightly (sibling may be unrelated) but never
// under-runs. And once the test executes, its coverage captures the
// new file → graph self-heals for next run.
if ($unknownSourceDirs !== []) { if ($unknownSourceDirs !== []) {
foreach ($this->edges as $testFile => $ids) { foreach ($this->edges as $testFile => $ids) {
if (isset($affectedSet[$testFile])) { if (isset($affectedSet[$testFile])) {
@ -576,9 +426,6 @@ final class Graph
return array_keys($affectedSet); return array_keys($affectedSet);
} }
/**
* Returns `true` if the given test file has any recorded dependencies.
*/
public function knowsTest(string $testFile): bool public function knowsTest(string $testFile): bool
{ {
$rel = $this->relative($testFile); $rel = $this->relative($testFile);
@ -586,9 +433,7 @@ final class Graph
return $rel !== null && isset($this->edges[$rel]); return $rel !== null && isset($this->edges[$rel]);
} }
/** /** @return array<int, string> */
* @return array<int, string> All project-relative test files the graph knows.
*/
public function allTestFiles(): array public function allTestFiles(): array
{ {
return array_keys($this->edges); return array_keys($this->edges);
@ -610,12 +455,6 @@ final class Graph
return $this->fingerprint; return $this->fingerprint;
} }
/**
* Returns the SHA the given branch last ran against, or falls back to
* `$fallbackBranch` (typically `main`) when this branch has no baseline
* yet. That way a freshly-created feature branch inherits main's
* baseline on its first run.
*/
public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string
{ {
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
@ -640,12 +479,6 @@ final class Graph
]; ];
} }
/**
* Returns the cached assertion count for a test, or `null` if unknown.
* Callers use this to feed `addToAssertionCount()` at replay time so
* the "Tests: N passed (M assertions)" banner matches the recorded run
* instead of defaulting to 1 assertion per test.
*/
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
{ {
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
@ -693,14 +526,7 @@ final class Graph
$this->baselines[$branch]['tree'] = $tree; $this->baselines[$branch]['tree'] = $tree;
} }
/** // Edges and tree snapshot stay intact; only the run-state is reset.
* 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 public function clearResults(string $branch): void
{ {
$this->ensureBaseline($branch); $this->ensureBaseline($branch);
@ -739,9 +565,6 @@ final class Graph
} }
/** /**
* Replaces edges for the given test files. Used during a partial record
* run so that existing edges for other tests are preserved.
*
* @param array<string, array<int, string>> $testToFiles * @param array<string, array<int, string>> $testToFiles
*/ */
public function replaceEdges(array $testToFiles): void public function replaceEdges(array $testToFiles): void
@ -765,12 +588,6 @@ final class Graph
} }
/** /**
* Replaces table edges for the given test files. Table names are
* lowercased + deduplicated; the input comes straight from the
* Recorder's `perTestTables()` snapshot. Tests absent from the
* input keep their existing table set (same partial-update policy
* as `replaceEdges`).
*
* @param array<string, array<int, string>> $testToTables * @param array<string, array<int, string>> $testToTables
*/ */
public function replaceTestTables(array $testToTables): void public function replaceTestTables(array $testToTables): void
@ -800,11 +617,6 @@ final class Graph
} }
/** /**
* Replaces Inertia component edges for the given test files. Names
* preserve case (they're identifiers like `Users/Show`, not
* user-supplied strings) but duplicates are collapsed. Same
* partial-update policy as `replaceTestTables`.
*
* @param array<string, array<int, string>> $testToComponents * @param array<string, array<int, string>> $testToComponents
*/ */
public function replaceTestInertiaComponents(array $testToComponents): void public function replaceTestInertiaComponents(array $testToComponents): void
@ -831,16 +643,8 @@ final class Graph
} }
} }
// Empty input is treated as a resolver failure (not "no JS pages") — keep the previous map.
/** /**
* Replaces the whole JS dep map. Called at record time with the
* output of `JsModuleGraph::build()`. Empty input is treated as a
* resolver failure (Node missing, Vite refused to load, transient
* `npm install`) rather than a legitimate "no JS pages" signal —
* we keep the previous map. Stale entries for genuinely-deleted
* pages are harmless because deleted files never enter the
* changed set; over-broadcasting every JS edit through the watch
* pattern after a flaky Node run would be a real regression.
*
* @param array<string, array<int, string>> $fileToComponents * @param array<string, array<int, string>> $fileToComponents
*/ */
public function replaceJsFileToComponents(array $fileToComponents): void public function replaceJsFileToComponents(array $fileToComponents): void
@ -877,23 +681,11 @@ final class Graph
$this->jsFileToComponents = $out; $this->jsFileToComponents = $out;
} }
/**
* Projects under Laravel conventionally keep migrations at
* `database/migrations/`. We recognise the directory as a prefix
* so nested subdirectories (a pattern some teams use for grouping
* — `database/migrations/tenant/`, `database/migrations/archived/`)
* are still routed through the table-intersection path.
*/
private function isMigrationPath(string $rel): bool private function isMigrationPath(string $rel): bool
{ {
return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php'); return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php');
} }
/**
* Unknown PHP files have no historical edge yet. Keep sibling fan-out only
* for framework-discovered / boot-loaded conventions where adding a file can
* change behaviour without another source file changing too.
*/
private function usesSiblingHeuristicForUnknownPhp(string $rel): bool private function usesSiblingHeuristicForUnknownPhp(string $rel): bool
{ {
static $prefixes = [ static $prefixes = [
@ -905,6 +697,14 @@ final class Graph
'app/Console/Commands/', 'app/Console/Commands/',
'app/Mail/', 'app/Mail/',
'app/Notifications/', 'app/Notifications/',
'app/Nova/Actions/',
'app/Nova/Dashboards/',
'app/Nova/Lenses/',
'app/Nova/Metrics/',
'app/Nova/Policies/',
'app/Nova/Resources/',
'app/Projectors/',
'app/Reactors/',
'database/factories/', 'database/factories/',
'database/seeders/', 'database/seeders/',
]; ];
@ -1204,15 +1004,7 @@ final class Graph
return $name === '' ? [] : [$name, str_replace('_', '-', $name)]; return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
} }
/** /** @return list<string> */
* Reads `$rel` relative to the project root and extracts the
* tables it declares via `Schema::create/table/drop/rename`.
* Empty on missing/unreadable files or when the parser finds
* nothing — the caller escalates those cases to the watch
* pattern safety net.
*
* @return list<string>
*/
private function tablesForMigration(string $rel): array private function tablesForMigration(string $rel): array
{ {
$absolute = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$rel; $absolute = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$rel;
@ -1230,21 +1022,7 @@ final class Graph
return TableExtractor::fromMigrationSource($content); return TableExtractor::fromMigrationSource($content);
} }
/** // Both `Pages/` and `pages/` are accepted — git paths are case-sensitive on Linux.
* Maps a project-relative path to its Inertia component name if it
* lives under the project's pages directory with a recognised
* framework extension. Returns null otherwise so callers can
* cheaply ignore non-page files. Matches Inertia's resolver
* convention: strip the pages prefix, strip the extension, preserve
* the remaining slashes (`Users/Show.vue` → `Users/Show`).
*
* Both `resources/js/Pages/` (the classic Inertia-Vue convention)
* and `resources/js/pages/` (the Laravel React starter kit, and
* other lowercase-by-default setups) are accepted — paths from
* git are case-sensitive on Linux, so we must match the exact
* casing used by the project rather than picking one and forcing
* the other to fall through to the broad watch pattern.
*/
private function componentForInertiaPage(string $rel): ?string private function componentForInertiaPage(string $rel): ?string
{ {
foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) { foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
@ -1273,13 +1051,27 @@ final class Graph
return null; return null;
} }
/** private function isGlobalFrontendRuntimePath(string $rel): bool
* Whether any test's component set contains `$component`. Used to {
* decide between precise edge matching and watch-pattern fallback if (! str_starts_with($rel, 'resources/js/')) {
* for a changed Inertia page file. return false;
* }
* @param array<string, array<int, string>> $edges
*/ $tail = substr($rel, strlen('resources/js/'));
$dot = strrpos($tail, '.');
if ($dot === false) {
return false;
}
$name = substr($tail, 0, $dot);
$extension = substr($tail, $dot + 1);
return in_array($extension, ['js', 'jsx', 'ts', 'tsx', 'vue', 'svelte'], true)
&& in_array($name, ['App', 'app', 'bootstrap', 'echo', 'favicon'], true);
}
/** @param array<string, array<int, string>> $edges */
private function anyTestUses(array $edges, string $component): bool private function anyTestUses(array $edges, string $component): bool
{ {
foreach ($edges as $components) { foreach ($edges as $components) {
@ -1291,11 +1083,6 @@ final class Graph
return false; return false;
} }
/**
* 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 public function pruneMissingTests(): void
{ {
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
@ -1319,12 +1106,6 @@ final class Graph
} }
} }
/**
* Rebuilds a graph from its JSON representation. Returns `null` when
* the payload is missing, unreadable, or schema-incompatible. Separated
* from transport (state backend, file, etc.) so tests can feed bytes
* directly without touching disk.
*/
public static function decode(string $json, string $projectRoot): ?self public static function decode(string $json, string $projectRoot): ?self
{ {
$data = json_decode($json, true); $data = json_decode($json, true);
@ -1412,12 +1193,6 @@ final class Graph
return $graph; return $graph;
} }
/**
* Serialises the graph to its JSON on-disk form. Returns `null` if the
* payload can't be encoded (extremely rare — pathological UTF-8 only).
* Persistence is the caller's responsibility: write the returned bytes
* through whatever `State` implementation is in play.
*/
public function encode(): ?string public function encode(): ?string
{ {
$payload = [ $payload = [
@ -1436,15 +1211,8 @@ final class Graph
return $json === false ? null : $json; return $json === false ? null : $json;
} }
/** // Accepts both absolute paths (from coverage drivers) and project-relative paths (from git diff).
* Normalises a path to be relative to the project root; returns `null` for // Relative paths are NOT resolved via realpath() because CWD is not guaranteed to be the project root.
* 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 private function relative(string $path): ?string
{ {
if ($path === '' || $path === 'unknown') { if ($path === '' || $path === 'unknown') {
@ -1471,12 +1239,9 @@ final class Graph
return null; return null;
} }
// Always normalise to forward slashes. Windows' native separator // Always forward slashes — git always uses them; Windows backslashes would never match.
// 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))); $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
} else { } else {
// Normalise directory separators and strip any "./" prefix.
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path); $relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);
while (str_starts_with($relative, './')) { while (str_starts_with($relative, './')) {
@ -1484,10 +1249,6 @@ final class Graph
} }
} }
// 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/')) { if (str_starts_with($relative, 'vendor/')) {
return null; return null;
} }

View File

@ -8,119 +8,49 @@ use Pest\TestSuite;
use ReflectionClass; use ReflectionClass;
/** /**
* Captures per-test file coverage using the PCOV driver. * Captures per-test file coverage. Singleton because PCOV/Xdebug have a single global state
* * shared across the `Prepared` and `Finished` subscribers.
* Acts as a singleton because PCOV has a single global collection state and
* the recorder is wired into PHPUnit through two distinct subscribers
* (`Prepared` / `Finished`) that must share context.
* *
* @internal * @internal
*/ */
final class Recorder final class Recorder
{ {
/**
* Test file currently being recorded, or `null` when idle.
*/
private ?string $currentTestFile = null; private ?string $currentTestFile = null;
/** /** @var array<string, array<string, true>> */
* Aggregated map: absolute test file → set<absolute source file>.
*
* @var array<string, array<string, true>>
*/
private array $perTestFiles = []; private array $perTestFiles = [];
/** /** @var array<string, array<string, true>> */
* Aggregated map: absolute test file → set<lowercase table name>.
* Populated by `TableTracker` from `DB::listen` callbacks; consumed
* at record finalize to populate the graph's `$testTables` edges
* that drive migration-change impact analysis.
*
* @var array<string, array<string, true>>
*/
private array $perTestTables = []; private array $perTestTables = [];
/** /** @var array<string, array<string, true>> */
* Aggregated map: absolute test file → set<Inertia component name>.
* Populated by `InertiaEdges` from Inertia responses observed at
* request-handled time; consumed at record finalize to populate
* the graph's per-test component edges that drive Vue / React
* page-file impact analysis.
*
* @var array<string, array<string, true>>
*/
private array $perTestInertiaComponents = []; private array $perTestInertiaComponents = [];
/** /** @var array<string, true> */
* Set of absolute test files whose class hierarchy uses one of
* Laravel's database-resetting traits (`RefreshDatabase`,
* `DatabaseMigrations`, `DatabaseTransactions`). Captured at
* `beginTest` so the finalize path can augment their table edges
* even when seeders / pre-test DML fired before `TableTracker`
* armed.
*
* @var array<string, true>
*/
private array $perTestUsesDatabase = []; private array $perTestUsesDatabase = [];
/** /** @var array<string, string|null> */
* Cached class → test file resolution.
*
* @var array<string, string|null>
*/
private array $classFileCache = []; private array $classFileCache = [];
/** /** @var array<string, bool> */
* Cached class → "uses Laravel DB trait" introspection result.
*
* @var array<string, bool>
*/
private array $classUsesDatabaseCache = []; private array $classUsesDatabaseCache = [];
/** // Source file → declared class names. Built incrementally as classes are autoloaded.
* Reverse map of project-local source file → list of class / // Used to walk the interface/trait/parent hierarchy which coverage drivers miss
* interface / trait names declared in it. Built incrementally as // (interfaces and empty traits emit no executable bytecode).
* tests run and new classes get autoloaded; consumed by /** @var array<string, list<string>> */
* `linkSourceDependencies()` so a test's covered file's
* declared classes can be walked for their interfaces, traits,
* and parents (which the coverage driver doesn't capture
* because interface declarations and empty traits emit no
* executable bytecode).
*
* @var array<string, list<string>>
*/
private array $fileToClassNames = []; private array $fileToClassNames = [];
/** /** @var array<string, true> */
* Names already folded into `$fileToClassNames`. Lets the
* incremental refresher skip classes seen in a previous test.
*
* @var array<string, true>
*/
private array $indexedClassNames = []; private array $indexedClassNames = [];
/** /** @var array<string, list<string>> */
* Cached "files this class transitively depends on (interfaces,
* traits, parent chain, parents' interfaces and traits)" for
* project-local class names. Avoids re-walking the same
* hierarchy on every test that touches the same class.
*
* @var array<string, list<string>>
*/
private array $classDependencyCache = []; private array $classDependencyCache = [];
/** /** @var array<string, list<string>> */
* Cached test-file import resolution.
*
* @var array<string, list<string>>
*/
private array $testImportFileCache = []; private array $testImportFileCache = [];
/** /** @var array<string, true> */
* Included-file snapshot captured at the start of the current test.
*
* @var array<string, true>
*/
private array $includedFilesAtTestStart = []; private array $includedFilesAtTestStart = [];
private bool $active = false; private bool $active = false;
@ -148,15 +78,8 @@ final class Recorder
$this->driver = 'pcov'; $this->driver = 'pcov';
$this->driverAvailable = true; $this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) { } elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
// Xdebug 3+ exposes the active mode set via `xdebug_info`, // Probing with start/stop emits E_WARNING when coverage is off, which monitoring agents
// so we can ask directly instead of probing with a // (Sentry, Bugsnag) can surface as a real error. xdebug_info('mode') is silent.
// start/stop pair. The probe approach used to emit
// E_WARNING when coverage mode was off; with monitoring
// agents (Sentry, Bugsnag) hooked into the error
// handler stack that warning could be reported as a
// real error. `xdebug_info('mode')` is silent and
// returns the active modes as a list, so a presence
// check is enough.
$modes = \xdebug_info('mode'); $modes = \xdebug_info('mode');
if (is_array($modes) && in_array('coverage', $modes, true)) { if (is_array($modes) && in_array('coverage', $modes, true)) {
@ -201,17 +124,8 @@ final class Recorder
$this->perTestUsesDatabase[$file] = true; $this->perTestUsesDatabase[$file] = true;
} }
// Walk the parent-class chain and link each ancestor's defining // Walk parent-class chain to link ancestor files. Empty base classes (e.g. a trait-only
// file as a source dependency of this test. Captures the common // TestCase) emit no executable bytecode, so the coverage driver never records them.
// `tests/TestCase.php` case (where the user's base may be
// trait-only and have no executable lines for the coverage
// driver to pick up), and any deeper hierarchy. Vendor parents
// are skipped — those are pinned by `composer.lock` and don't
// need per-test edges. Same idea applies to traits used by the
// ancestors: a trait's body executes when the test method
// calls into it, so coverage already captures it; we only need
// the explicit walk for ancestors whose own bodies might be
// empty.
$this->linkAncestorFiles($className); $this->linkAncestorFiles($className);
$this->linkImportedFiles($file); $this->linkImportedFiles($file);
@ -239,9 +153,7 @@ final class Recorder
} else { } else {
/** @var array<string, mixed> $data */ /** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage(); $data = \xdebug_get_code_coverage();
// `true` resets Xdebug's internal buffer so the next `start()` // `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage.
// does not accumulate earlier tests' coverage into the current
// one — otherwise the graph becomes progressively polluted.
\xdebug_stop_code_coverage(true); \xdebug_stop_code_coverage(true);
} }
@ -258,29 +170,14 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
} }
// Walk each covered class's interfaces / traits / parent chain // Walk covered classes' interfaces/traits/parents. Interfaces have no executable bytecode,
// and link those files explicitly. Interface declarations have // so a signature change would leave implementing-class tests stale without this walk.
// no executable bytecode, so coverage drivers never emit lines
// for them — without this walk, a signature change to an
// interface like `Viewable` would leave the cached results of
// every test that exercises an implementing class stale,
// because the interface file never enters the graph through
// the coverage path.
$this->linkSourceDependencies(array_keys($data)); $this->linkSourceDependencies(array_keys($data));
$this->currentTestFile = null; $this->currentTestFile = null;
$this->includedFilesAtTestStart = []; $this->includedFilesAtTestStart = [];
} }
/**
* Records an extra source-file dependency for the currently-running
* test. Used by collaborators that capture edges the coverage driver
* cannot see — Blade templates rendered through Laravel's view
* factory are the motivating case (their `.blade.php` source never
* executes directly; a cached compiled PHP file does). No-op when
* the recorder is inactive or no test is in flight, so callers can
* fire it unconditionally from app-level hooks.
*/
public function linkSource(string $sourceFile): void public function linkSource(string $sourceFile): void
{ {
if (! $this->active) { if (! $this->active) {
@ -298,12 +195,7 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
} }
/** /** @param iterable<int, string> $sourceFiles */
* Records source dependencies for a specific test file. Used for edges
* captured before `Prepared` has opened the normal per-test recorder window.
*
* @param iterable<int, string> $sourceFiles
*/
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
{ {
if (! $this->active) { if (! $this->active) {
@ -323,23 +215,7 @@ final class Recorder
} }
} }
/** /** @param array<int, string> $coveredFiles */
* For each project-local source file the coverage driver
* captured for this test, finds the classes / interfaces / traits
* declared in it and links every file in their declarative
* hierarchy: implemented interfaces (transitive), used traits,
* and parent classes (with their own interfaces and traits).
*
* Coverage drivers only record executable lines, so an interface
* signature change (e.g. adding a return type to a `Viewable`
* method) never registers — the interface file has no bytecode
* to instrument. Without this walk, every class implementing the
* interface would silently keep its stale cached result through
* the change, even though `--parallel` (no TIA) catches the
* incompatibility immediately.
*
* @param array<int, string> $coveredFiles absolute paths from coverage
*/
private function linkSourceDependencies(array $coveredFiles): void private function linkSourceDependencies(array $coveredFiles): void
{ {
if ($this->currentTestFile === null) { if ($this->currentTestFile === null) {
@ -361,15 +237,6 @@ final class Recorder
} }
} }
/**
* Incrementally folds every project-local class / interface /
* trait declared since the last refresh into `$fileToClassNames`.
* PHP only ever appends to its declared-symbol lists (classes
* never get unloaded), so iterating from `$indexedClassNames`'s
* cardinality forward is sufficient — and over a long suite this
* is dominated by the first test, since most classes are loaded
* by then.
*/
private function refreshClassMap(): void private function refreshClassMap(): void
{ {
$names = array_merge( $names = array_merge(
@ -384,12 +251,6 @@ final class Recorder
} }
$this->indexedClassNames[$name] = true; $this->indexedClassNames[$name] = true;
// Names came directly from `get_declared_*`, so the
// class/interface/trait is guaranteed loaded — but
// `class_exists($name, false)` (no autoload) keeps the
// string narrowed to `class-string` for static analysis
// and the `ReflectionClass` constructor stays in its
// documented happy path.
if (! class_exists($name, false) if (! class_exists($name, false)
&& ! interface_exists($name, false) && ! interface_exists($name, false)
&& ! trait_exists($name, false)) { && ! trait_exists($name, false)) {
@ -416,15 +277,7 @@ final class Recorder
} }
} }
/** /** @return list<string> */
* Returns the project-local files the named class declaratively
* depends on: implemented interfaces (transitive), used traits,
* and the entire parent chain (each with their own interfaces
* and traits). Cached per class because the answer is invariant
* across a single process.
*
* @return list<string>
*/
private function classDependencies(string $className): array private function classDependencies(string $className): array
{ {
if (isset($this->classDependencyCache[$className])) { if (isset($this->classDependencyCache[$className])) {
@ -458,19 +311,11 @@ final class Recorder
$files[$f] = true; $files[$f] = true;
}; };
// `getInterfaceNames()` is transitive — it returns interfaces // getInterfaceNames() is transitive — includes parents' interfaces — so one pass suffices.
// from parent classes and parent interfaces too — so a single
// pass covers the whole interface graph.
foreach ($reflection->getInterfaceNames() as $iname) { foreach ($reflection->getInterfaceNames() as $iname) {
$linkSymbol($iname); $linkSymbol($iname);
} }
// Direct + ancestor traits. `getTraitNames()` doesn't recurse
// into traits-using-traits, but that's a rare pattern in
// application code; if a project genuinely needs it, the
// coverage driver will pick up the executed bytecode of the
// outer trait and the dependency walk runs against the
// resulting class anyway.
foreach ($reflection->getTraitNames() as $tname) { foreach ($reflection->getTraitNames() as $tname) {
$linkSymbol($tname); $linkSymbol($tname);
} }
@ -490,16 +335,6 @@ final class Recorder
return $this->classDependencyCache[$className] = array_keys($files); return $this->classDependencyCache[$className] = array_keys($files);
} }
/**
* Records every project-local ancestor class's defining file as a
* source dependency of the currently-running test. PCOV / Xdebug
* record *executable lines* — a base class whose body is just
* `class TestCase extends BaseTestCase { use CreatesApplication; }`
* has no executable bytecode of its own, so the driver doesn't
* emit a line for it and it never enters the graph through the
* usual coverage path. This walk fills that gap by asking
* reflection for each parent's file and linking it explicitly.
*/
private function linkAncestorFiles(string $className): void private function linkAncestorFiles(string $className): void
{ {
if (! class_exists($className, false)) { if (! class_exists($className, false)) {
@ -524,11 +359,6 @@ final class Recorder
} }
} }
/**
* Links project-local classes imported by the test file. This catches
* declaration-only support classes / enums / interfaces that may never emit
* executable coverage lines, and avoids relying on global autoload timing.
*/
private function linkImportedFiles(string $testFile): void private function linkImportedFiles(string $testFile): void
{ {
if ($this->currentTestFile === null) { if ($this->currentTestFile === null) {
@ -653,14 +483,6 @@ final class Recorder
return null; return null;
} }
/**
* True when `$className` (or any of its ancestors) uses one of
* Laravel's database-resetting traits. Walking up `getTraits()` is
* necessary because Pest test classes are eval'd from the
* generated `*.php` test file and the trait usually lives on a
* shared `tests/TestCase.php` ancestor. Result is cached per class
* — class hierarchies don't change within a process.
*/
private function classUsesDatabase(string $className): bool private function classUsesDatabase(string $className): bool
{ {
if (array_key_exists($className, $this->classUsesDatabaseCache)) { if (array_key_exists($className, $this->classUsesDatabaseCache)) {
@ -692,14 +514,6 @@ final class Recorder
return $this->classUsesDatabaseCache[$className] = false; return $this->classUsesDatabaseCache[$className] = false;
} }
/**
* Records that the currently-running test queried `$table`. Called
* by `TableTracker` for every DML statement Laravel's `DB::listen`
* reports; the table name has already been extracted by
* `TableExtractor::fromSql()` so we just store it. No-op outside
* a test window, so the callback is safe to leave armed across
* setUp / tearDown boundaries.
*/
public function linkTable(string $table): void public function linkTable(string $table): void
{ {
if (! $this->active) { if (! $this->active) {
@ -717,15 +531,6 @@ final class Recorder
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true; $this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
} }
/**
* Records that the currently-running test server-side-rendered the
* named Inertia component. The name is whatever
* `Inertia::render($component, …)` was called with — typically a
* slash-separated path like `Users/Show` that maps to
* `resources/js/Pages/Users/Show.vue`. No-op outside a test window
* so the underlying listener can stay armed without leaking
* state between tests.
*/
public function linkInertiaComponent(string $component): void public function linkInertiaComponent(string $component): void
{ {
if (! $this->active) { if (! $this->active) {
@ -743,9 +548,7 @@ final class Recorder
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true; $this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
} }
/** /** @return array<string, array<int, string>> */
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
*/
public function perTestFiles(): array public function perTestFiles(): array
{ {
$out = []; $out = [];
@ -757,9 +560,7 @@ final class Recorder
return $out; return $out;
} }
/** /** @return array<string, array<int, string>> */
* @return array<string, array<int, string>> absolute test file → sorted list of table names.
*/
public function perTestTables(): array public function perTestTables(): array
{ {
$out = []; $out = [];
@ -773,9 +574,7 @@ final class Recorder
return $out; return $out;
} }
/** /** @return array<string, array<int, string>> */
* @return array<string, array<int, string>> absolute test file → sorted list of Inertia component names.
*/
public function perTestInertiaComponents(): array public function perTestInertiaComponents(): array
{ {
$out = []; $out = [];
@ -789,9 +588,7 @@ final class Recorder
return $out; return $out;
} }
/** /** @return array<string, true> */
* @return array<string, true> absolute test file → true for tests using a Laravel DB-resetting trait.
*/
public function perTestUsesDatabase(): array public function perTestUsesDatabase(): array
{ {
return $this->perTestUsesDatabase; return $this->perTestUsesDatabase;
@ -817,17 +614,8 @@ final class Recorder
return null; return null;
} }
/** // Prefers Pest's `$__filename` static (the original .php file) over ReflectionClass::getFileName()
* Resolves the file that *defines* the test class. // (which returns the trait file for methods brought in via `uses SharedTestBehavior`).
*
* 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 private function readPestFilename(string $className): ?string
{ {
if (! class_exists($className, false)) { if (! class_exists($className, false)) {
@ -853,11 +641,6 @@ final class Recorder
return is_string($file) ? $file : null; return is_string($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 public function reset(): void
{ {
$this->currentTestFile = null; $this->currentTestFile = null;

View File

@ -45,6 +45,21 @@ final readonly class Browser implements WatchDefault
// Vite / Webpack build output that browser tests may consume. // Vite / Webpack build output that browser tests may consume.
'public/build/**/*.js', 'public/build/**/*.js',
'public/build/**/*.css', 'public/build/**/*.css',
// Static public assets can affect browser-rendered pages without
// any PHP file changing (favicons, robots, images, downloaded
// manifests, etc.). Only browser-test targets are invalidated.
'public/**/*.js',
'public/**/*.css',
'public/**/*.svg',
'public/**/*.png',
'public/**/*.jpg',
'public/**/*.jpeg',
'public/**/*.webp',
'public/**/*.ico',
'public/**/*.txt',
'public/**/*.json',
'public/**/*.xml',
'public/hot',
]; ];
$patterns = []; $patterns = [];

View File

@ -54,8 +54,28 @@ final readonly class Laravel implements WatchDefault
// if the factory file was already autoloaded before Prepared. // if the factory file was already autoloaded before Prepared.
'database/factories/**/*.php' => [$testPath], 'database/factories/**/*.php' => [$testPath],
// Project fixture data. Laravel apps often keep fake repository
// lockfiles / API payloads here and read them via `storage_path()`
// + `file_get_contents()`, which neither PHP coverage nor static
// import edges can observe.
'storage/fixtures/**/*' => [$testPath],
// Non-PHP templates/data living beside app code. These are often
// read dynamically by services (Dockerfile templates, stubs,
// payload examples) and never appear in coverage because PHP only
// sees the reader method, not the external file.
'app/**/*.tpl' => [$testPath],
'app/**/*.stub' => [$testPath],
'app/**/*.json' => [$testPath],
'app/**/*.yaml' => [$testPath],
'app/**/*.yml' => [$testPath],
'app/**/*.txt' => [$testPath],
// Blade templates — compiled to cache, source file not executed. // Blade templates — compiled to cache, source file not executed.
'resources/views/**/*.blade.php' => [$testPath], 'resources/views/**/*.blade.php' => [$testPath],
// Mail / view-adjacent themes can be read dynamically by
// mailables (for example Laravel's markdown mail theme CSS).
'resources/views/**/*.css' => [$testPath],
// Email templates are nested under views/email or views/emails // Email templates are nested under views/email or views/emails
// by convention and power mailable tests that render markup. // by convention and power mailable tests that render markup.
'resources/views/email/**/*.blade.php' => [$testPath], 'resources/views/email/**/*.blade.php' => [$testPath],

View File

@ -58,12 +58,12 @@ final readonly class Php implements WatchDefault
// suite. // suite.
$testPath.'/Datasets/**/*.php' => [$testPath], $testPath.'/Datasets/**/*.php' => [$testPath],
// Test fixtures — JSON, CSV, XML, TXT data files consumed by // Test fixtures — data/source snippets consumed by assertions or
// assertions. A fixture change can flip a test result. // external analysers. Nested `Fixtures/` directories are common
$testPath.'/Fixtures/**/*.json' => [$testPath], // beside a single test class, and PHP fixtures may be parsed by
$testPath.'/Fixtures/**/*.csv' => [$testPath], // tools without being `require`d, so coverage cannot see them.
$testPath.'/Fixtures/**/*.xml' => [$testPath], $testPath.'/Fixtures/**/*' => [$testPath],
$testPath.'/Fixtures/**/*.txt' => [$testPath], $testPath.'/**/Fixtures/**/*' => [$testPath],
// Pest snapshots — external edits to snapshot files invalidate // Pest snapshots — external edits to snapshot files invalidate
// snapshot assertions. // snapshot assertions.

View File

@ -43,6 +43,12 @@ final class WatchPatterns
*/ */
private array $patterns = []; private array $patterns = [];
private bool $always = false;
private bool $locally = false;
private bool $filtered = false;
/** /**
* Probes every registered `WatchDefault` and merges the patterns of * Probes every registered `WatchDefault` and merges the patterns of
* those that apply. Called once during Tia plugin boot, after BootFiles * those that apply. Called once during Tia plugin boot, after BootFiles
@ -149,9 +155,42 @@ final class WatchPatterns
return $affected; return $affected;
} }
public function markAlways(): void
{
$this->always = true;
}
public function isAlways(): bool
{
return $this->always;
}
public function markLocally(): void
{
$this->locally = true;
}
public function isLocally(): bool
{
return $this->locally;
}
public function markFiltered(): void
{
$this->filtered = true;
}
public function isFiltered(): bool
{
return $this->filtered;
}
public function reset(): void public function reset(): void
{ {
$this->patterns = []; $this->patterns = [];
$this->always = false;
$this->locally = false;
$this->filtered = false;
} }
/** /**

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Pest\TestCaseFilters;
use Pest\Contracts\TestCaseFilter;
use Pest\Plugins\Tia\Graph;
/**
* Accepts a test file only if it is in the TIA-computed affected set.
*
* Falls back to accepting when the graph has no record of the file (new tests
* must always run) or when the file is outside the project root.
*
* @internal
*/
final readonly class TiaTestCaseFilter implements TestCaseFilter
{
/**
* @param array<string, true> $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)));
}
}