From 6a434be0f61e7f5c4b6bd0f8e8f0d18813cd478e Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 30 Apr 2026 20:45:36 +0100 Subject: [PATCH] wip --- src/Concerns/Testable.php | 3 + src/Plugins/Tia.php | 536 +++------------------- src/Plugins/Tia/BaselineSync.php | 122 +---- src/Plugins/Tia/ChangedFiles.php | 48 +- src/Plugins/Tia/Configuration.php | 46 ++ src/Plugins/Tia/Fingerprint.php | 216 ++++----- src/Plugins/Tia/Graph.php | 431 ++++------------- src/Plugins/Tia/Recorder.php | 283 ++---------- src/Plugins/Tia/WatchDefaults/Browser.php | 15 + src/Plugins/Tia/WatchDefaults/Laravel.php | 20 + src/Plugins/Tia/WatchDefaults/Php.php | 12 +- src/Plugins/Tia/WatchPatterns.php | 39 ++ src/TestCaseFilters/TiaTestCaseFilter.php | 60 +++ 13 files changed, 495 insertions(+), 1336 deletions(-) create mode 100644 src/TestCaseFilters/TiaTestCaseFilter.php diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index aa93c834..22491ad8 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -288,6 +288,7 @@ trait Testable if ($cached !== null) { if ($cached->isSuccess()) { $this->__cachedPass = true; + $this->__ran = true; return; } @@ -299,6 +300,7 @@ trait Testable // programmatic risky-marker API. if ($cached->isRisky()) { $this->__cachedPass = true; + $this->__ran = true; return; } @@ -313,6 +315,7 @@ trait Testable if ($cached->isIncomplete()) { $this->markTestIncomplete($cached->message()); + $this->__ran = true; } throw new AssertionFailedError($cached->message() ?: 'Cached failure'); diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 5738b15a..ce1619f6 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -20,6 +20,7 @@ use Pest\Plugins\Tia\ResultCollector; use Pest\Plugins\Tia\TableExtractor; use Pest\Plugins\Tia\WatchPatterns; use Pest\Support\Container; +use Pest\TestCaseFilters\TiaTestCaseFilter; use Pest\TestSuite; use PHPUnit\Framework\TestStatus\TestStatus; use Symfony\Component\Console\Output\OutputInterface; @@ -27,45 +28,10 @@ use Symfony\Component\Process\Process; use Throwable; /** - * Test Impact Analysis (file-level, parallel-aware). + * Test Impact Analysis plugin — record/replay, parallel-aware. * - * Modes - * ----- - * - **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-.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. + * Must be registered before `Parallel` — Parallel exits on `--parallel`, + * so later plugins never execute. * * @internal */ @@ -75,29 +41,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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'; - /** - * 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'; - /** - * 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. - */ + private const string FILTERED_OPTION = '--filtered'; + public const string KEY_GRAPH = 'graph.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-'; - /** - * 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. - */ + /** Sentinel dropped by a recording worker without a usable coverage 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'; - /** - * 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'; - /** - * Cooldown marker keyed by `BaselineSync` after a failed fetch. Holds - * `{"until": }` — 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'; - /** - * Global flag toggled by the parent process so workers know to record. - */ private const string RECORDING_GLOBAL = 'TIA_RECORDING'; - /** - * Global flag that tells workers to install the TIA filter (replay mode). - * Workers read the affected set from `.pest/tia/affected.json`. - */ private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; - /** - * Global flag that tells workers to piggyback on PHPUnit's coverage - * driver (set by the parent whenever `--tia --coverage` is used). Workers - * can't infer this from their own argv because paratest forwards only - * `--coverage-php=` — not the `--coverage` flag Pest's Coverage - * plugin inspects. - */ + /** Tells workers to apply TiaTestCaseFilter instead of cache short-circuiting. */ + private const string FILTERED_GLOBAL = 'TIA_FILTERED'; + + /** Workers can't detect `--coverage` from their own argv — paratest strips it. */ private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE'; private bool $graphWritten = false; private bool $replayRan = false; - /** - * Counts cache hits during a replay run. Incremented each time - * `getCachedResult()` returns a non-null status so the end-of-run - * summary reflects what actually happened, not a graph-level estimate. - */ private int $replayedCount = 0; - /** - * 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 $affectedCount = 0; + private int $executedCount = 0; - /** - * 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 - */ + /** @var array */ 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; - /** - * Current git branch (or `HEAD` SHA when detached). Resolved once per - * run so all graph accesses use the same branch key. - */ private string $branch = 'main'; - /** - * Test files that are affected (should re-execute). Keyed by - * project-relative path. Set during `enterReplayMode`. - * - * @var array - */ + /** @var array */ private array $affectedFiles = []; 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'; } - /** - * 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; - /** - * 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; - /** - * True when `--refetch` is in the current argv — `BaselineSync` - * uses it to bypass the post-failure fetch cooldown. - */ private bool $forceRefetch = false; - /** - * 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. - */ + /** Prevents fetching the same stale baseline twice after structural drift. */ private bool $baselineFetchAttemptedForDrift = false; - /** - * 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. - */ + /** Gates `Graph::pruneMissingTests()` — only safe on full `--fresh` rebuilds. */ private bool $freshRebuild = false; + private bool $filteredMode = false; + public function __construct( private readonly OutputInterface $output, private readonly Recorder $recorder, @@ -269,12 +127,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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 { $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); } - /** - * Returns the cached result for the given test, or `null` if the test - * must run (affected, unknown, or no replay mode active). - */ public function getCachedResult(string $filename, string $testId): ?TestStatus { if (! $this->replayGraph instanceof Graph) { return null; } - // Resolve file to project-relative path. $projectRoot = TestSuite::getInstance()->rootPath; $real = @realpath($filename); $rel = $real !== false ? str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen(rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR))) : null; - // Affected files must re-execute. if ($rel !== null && isset($this->affectedFiles[$rel])) { + $this->affectedCount++; $this->executedCount++; return null; } - // Unknown files (not in graph) must execute — they're new. if ($rel === null || ! $this->replayGraph->knowsTest($rel)) { $this->executedCount++; return null; } - // Known + unaffected: return cached result if we have one for this - // branch (falls back to main if branch is fresh). $result = $this->replayGraph->getResult($this->branch, $testId); if ($result instanceof TestStatus) { $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); $this->cachedAssertionsByTestId[$testId] = $assertions ?? 0; } 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++; } 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 { 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'; $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); $this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments); - // `--fresh` only takes effect alongside `--tia` (or from a - // worker that's already in TIA mode). Without `--tia`, Pest - // users could be passing `--fresh` to an unrelated plugin — - // silently ignore it here and let whatever else consumes it - // handle it. The flag isn't popped in that branch. + // Always strip TIA-owned flags so they never reach PHPUnit, even when + // TIA is not active for this run. + $arguments = $this->popArgument(self::OPTION, $arguments); + $arguments = $this->popArgument(self::FRESH_OPTION, $arguments); + $arguments = $this->popArgument(self::REFETCH_OPTION, $arguments); + $arguments = $this->popArgument(self::FILTERED_OPTION, $arguments); + $forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal); $this->freshRebuild = $forceRebuild; @@ -385,18 +225,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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=`) so the parent broadcasts - // via a global. $this->piggybackCoverage = $isWorker ? (string) Parallel::getGlobal(self::PIGGYBACK_COVERAGE_GLOBAL) === '1' : $this->coverageReportActive(); @@ -416,12 +244,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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)) { $this->flushWorkerReplay(); } @@ -450,15 +272,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $perTestInertia = $recorder->perTestInertiaComponents(); $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 !== []) { $perTestTables = $this->augmentDatabaseTestTables( $perTestTables, @@ -475,17 +288,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return; } - // Non-parallel record path: straight into the main cache. $changedFiles = new ChangedFiles($projectRoot); $currentSha = $changedFiles->currentSha(); $graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot); $graph->setFingerprint(Fingerprint::compute($projectRoot)); $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( $this->branch, $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), @@ -495,25 +303,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $graph->replaceTestInertiaComponents($perTestInertia); $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) { $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); if (! $this->saveGraph($graph)) { @@ -532,11 +325,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $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 { if (Parallel::isWorker()) { @@ -545,17 +333,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $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()) { $this->mergeWorkerReplayPartials(); } @@ -565,8 +342,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } 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(); return $exitCode; @@ -654,11 +429,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $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 === []) { $this->output->writeln([ '', @@ -675,10 +445,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $graph->replaceTestInertiaComponents($finalisedInertia); $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) { $graph->pruneMissingTests(); } @@ -695,31 +461,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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(); return $exitCode; } /** - * Compares a loaded graph's fingerprint to the current one and decides - * how much of the graph is still usable. - * - * - **Structural drift** (composer.lock, Pest.php, factory codegen, - * schema bump): edges themselves are potentially wrong → discard - * the whole graph + coverage cache and return null. Caller falls - * through to record mode. - * - **Environmental drift** (PHP minor, extension set, Pest version): - * edges describe the code correctly; only the cached per-test - * results were captured against a different runtime and might not - * reproduce. Drop `baselines[branch].results` + coverage cache, - * bump the fingerprint to the current env, persist. Caller uses - * the graph for edges; results refill naturally during this run's - * replay (every test misses cache, runs normally, seeds results). - * - **Match**: return the graph untouched. + * Structural drift → discard graph, return null (caller enters record mode). + * Environmental drift → drop results, keep edges, return updated graph. + * Match → return graph unchanged. * * @param array{structural: array, environmental: array} $current */ @@ -735,10 +485,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $this->formatStructuralDrift($drift), )); - // For composer.lock specifically, surface the actual - // package-version deltas. Saves the user a `git diff - // composer.lock | grep -E "name|version"` round-trip when - // a routine `composer update` invalidates the graph. if (in_array('composer_lock', $drift, true)) { $branchSha = $graph->recordedAtSha($this->branch); if ($branchSha !== null) { @@ -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 - // ~5–30s of network instead of minutes of recording. - // - // Revalidation is the safety: even if the fetch succeeds, - // we only adopt the result when its stored fingerprint - // structurally matches the *current* one. A stale CI - // baseline (workflow hasn't run since the drift) gets - // dropped and we fall through to the local rebuild path. $rebuilt = $this->tryRemoteBaselineForDrift($current); if ($rebuilt instanceof Graph) { @@ -801,14 +535,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable */ private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array { - // Initialise watch patterns (defaults + any user additions from - // tests/Pest.php which has already been loaded by BootFiles at - // this point). $this->watchPatterns->useDefaults($projectRoot); - - // Resolve current branch once per run so every baseline lookup uses - // the same key. Detached HEAD (or no git) falls back to `main` as - // the implicit branch identity. $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main'; $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) { $this->state->write(self::KEY_COVERAGE_MARKER, ''); } - // First `--tia --coverage` run has nothing to merge against: if we - // 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. + // First `--tia --coverage` run: no cache to merge against yet, must record the full suite. if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) { return $this->enterRecordMode($arguments); } @@ -878,10 +599,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main'; if ($replayingGlobal) { - // Replay in a worker: load the graph and the affected set that - // the parent persisted, then install the per-file filter so - // whichever tests paratest happens to hand this worker are - // accepted / rejected consistently with the series path. $this->installWorkerReplay($projectRoot); return $arguments; @@ -891,9 +608,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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) { $this->recordingActive = true; @@ -903,11 +617,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $recorder = $this->recorder; 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( self::KEY_WORKER_NO_DRIVER_PREFIX.$this->workerToken().'.json', '{}', @@ -922,13 +631,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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 { $graph = $this->loadGraph($projectRoot); @@ -959,6 +661,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $this->replayGraph = $graph; $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); $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, $graph->lastRunTree($this->branch), @@ -1003,11 +704,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $this->registerRecap(); + if ($this->filteredMode) { + TestSuite::getInstance()->tests->addTestCaseFilter( + new TiaTestCaseFilter($projectRoot, $graph, $affectedSet), + ); + } + if (! Parallel::isEnabled()) { return $arguments; } - // Parallel: persist affected set so workers can install the filter. if (! $this->persistAffectedSet($affected)) { $this->output->writeln( ' TIA failed to persist affected set — running full suite.', @@ -1016,12 +722,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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(); Parallel::setGlobal(self::REPLAYING_GLOBAL, '1'); + if ($this->filteredMode) { + Parallel::setGlobal(self::FILTERED_GLOBAL, '1'); + } + return $arguments; } @@ -1047,27 +755,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable { $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()) { - // 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(); return $arguments; } 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(); 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); } - /** - * @return list State keys of per-worker edges partials. - */ private function collectWorkerEdgesPartials(): array { 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 { $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 { /** @var ResultCollector $collector */ @@ -1195,13 +875,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $results = $collector->all(); - if ($results === [] && $this->replayedCount === 0 && $this->executedCount === 0) { + if ($results === [] && $this->replayedCount === 0 && $this->affectedCount === 0 && $this->executedCount === 0) { return; } $json = json_encode([ 'results' => $results, 'replayed' => $this->replayedCount, + 'affected' => $this->affectedCount, 'executed' => $this->executedCount, ], JSON_UNESCAPED_SLASHES); @@ -1212,19 +893,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $this->state->write($this->workerResultsKey($this->workerToken()), $json); } - /** - * @return list State keys of per-worker replay partials. - */ private function collectWorkerReplayPartials(): array { 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 { /** @var ResultCollector $collector */ @@ -1248,6 +921,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $this->replayedCount += $decoded['replayed']; } + if (isset($decoded['affected']) && is_int($decoded['affected'])) { + $this->affectedCount += $decoded['affected']; + } + if (isset($decoded['executed']) && is_int($decoded['executed'])) { $this->executedCount += $decoded['executed']; } @@ -1350,47 +1027,25 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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 { DefaultPrinter::addRecap(function (): string { - // Parallel mode: worker replays live in other processes and - // flushed their counters to disk on terminate. Collision's - // `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. + // mergeWorkerReplayPartials fires before addOutput on --parallel, which is intentional: + // partial keys are deleted on read so the later addOutput call becomes a no-op. if (Parallel::isEnabled() && ! Parallel::isWorker()) { $this->mergeWorkerReplayPartials(); } $fragments = []; - if ($this->executedCount > 0) { - $fragments[] = $this->executedCount.' affected'; + if ($this->affectedCount > 0) { + $fragments[] = $this->affectedCount.' affected'; + } + + $uncachedCount = max(0, $this->executedCount - $this->affectedCount); + + if ($uncachedCount > 0) { + $fragments[] = $uncachedCount.' uncached'; } if ($this->replayedCount > 0) { @@ -1418,21 +1073,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $graph->setRecordedAtSha($this->branch, $currentSha); } - // Snapshot the working tree: hash every currently-modified file. - // On next run, files still appearing as modified but whose hash - // matches this snapshot are treated as unchanged. $workingTreeFiles = $changedFiles->since($currentSha) ?? []; $graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles)); $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 { /** @var ResultCollector $collector */ @@ -1452,11 +1098,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $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 { /** @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, environmental: array} $current */ 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 $drift */ private function formatStructuralDrift(array $drift): string @@ -1558,6 +1188,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 'phpunit_xml' => 'phpunit.xml', 'phpunit_xml_dist' => 'phpunit.xml.dist', 'vite_config' => 'vite.config', + 'package_json' => 'package.json', + 'package_lock' => 'Node lockfile', + 'js_config' => 'JS/TS config', 'pest_factory' => 'Pest internals', 'pest_method_factory' => 'Pest internals', ]; @@ -1574,16 +1207,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return implode(', ', array_keys($seen)); } - /** - * Diffs `composer.lock` between the recorded SHA and the current - * working tree, returns a one-line summary like: - * - * "laravel/framework 12.30 → 12.31, + pestphp/pest 4.7" - * - * Empty string when git is unavailable, the sha doesn't have the - * file, the file can't be parsed, or there are no version - * deltas (a content-hash-only edit, vendor URL change, etc.). - */ private function composerLockDelta(string $projectRoot, string $sha): string { $current = @file_get_contents($projectRoot.'/composer.lock'); @@ -1626,8 +1249,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable sort($changes); - // Cap at a sensible number — a wholesale `composer update` - // could list 50+ packages and bury the prompt. $maxShown = 8; if (count($changes) > $maxShown) { $extra = count($changes) - $maxShown; @@ -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> $perTestTables * @param array $perTestUsesDatabase * @return array> diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index ac952c0d..6ecfdace 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -11,64 +11,26 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; /** - * Pulls a team-shared TIA baseline on the first `--tia` run so new - * contributors and fresh CI workspaces start in replay mode instead of - * paying the ~30s record cost. + * Downloads a team-shared TIA baseline from GitHub workflow artifacts so new contributors and + * fresh CI workspaces start in replay mode. Artifacts are used instead of releases because they + * 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 - * (conventionally `.github/workflows/tia-baseline.yml`) runs the full - * 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. + * Fingerprint validation happens in `Tia::handleParent` after the blobs land; a mismatched + * environment falls through to the normal record path. * * @internal */ 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'; - /** - * 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'; - /** - * 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 COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE; - /** - * 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`. - */ + // 24 h cooldown after a failed fetch so repeated `pest --tia` calls don't re-hit `gh run list`. private const int FETCH_COOLDOWN_SECONDS = 86400; public function __construct( @@ -76,16 +38,6 @@ final readonly class BaselineSync 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 { $repo = $this->detectGitHubRepo($projectRoot); @@ -126,9 +78,6 @@ final readonly class BaselineSync $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->output->writeln(sprintf( @@ -139,10 +88,6 @@ final readonly class BaselineSync return true; } - /** - * Seconds left on the cooldown, or `null` when the cooldown is cleared - * / expired / unreadable. - */ private function cooldownRemaining(): ?int { $raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN); @@ -187,18 +132,6 @@ final readonly class BaselineSync 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 { if ($this->isCi()) { @@ -237,12 +170,7 @@ final readonly class BaselineSync $this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]); } - /** - * 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. - */ + // `CI=true` alone is ambiguous (users set it locally) — require a provider-specific env var. private function isCi(): bool { return getenv('GITHUB_ACTIONS') === 'true' @@ -256,12 +184,6 @@ final readonly class BaselineSync && 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 { return <<<'YAML' @@ -329,12 +251,6 @@ jobs: 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 { $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; @@ -349,29 +265,20 @@ YAML; 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) { return null; } $url = $match[1]; - // SSH: git@github.com:org/repo(.git) if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $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) { 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) { return $m[1]; } @@ -379,15 +286,7 @@ YAML; return 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 - */ + /** @return array{graph: string, coverage: ?string}|null */ private function download(string $repo): ?array { 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 { $process = new Process([ diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index f32b8098..fd7fdb8a 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -27,17 +27,6 @@ final readonly class ChangedFiles public function __construct(private string $projectRoot) {} /** - * @return array|null `null` when git is unavailable, or when - * the recorded SHA is no longer reachable - * from HEAD (rebase / force-push) — in - * that case the graph should be rebuilt. - */ - /** - * 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 $files project-relative paths. * @param array $lastRunTree path → content hash from last run. * @return array @@ -48,12 +37,7 @@ final readonly class ChangedFiles return $files; } - // Union: `$files` (what git currently reports) + every path that was - // 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. + // Union with last-run snapshot: catches reverts that git reports clean but are new vs the snapshot. $candidates = array_fill_keys($files, true); foreach (array_keys($lastRunTree) as $snapshotted) { @@ -68,28 +52,14 @@ final readonly class ChangedFiles $exists = is_file($absolute); if ($snapshot === null) { - // File wasn't in last-run tree at all — trust git's signal. $remaining[] = $file; continue; } if (! $exists) { - // Missing on disk. We always invalidate here, even when - // the snapshot also recorded "deleted" (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. + // Always invalidate deletions — a stale cached result from before the deletion + // would persist forever otherwise, even if the snapshot recorded the empty sentinel. $remaining[] = $file; continue; @@ -104,21 +74,9 @@ final readonly class ChangedFiles } if ($hash === $snapshot) { - // Same state as the last TIA invocation — cached - // result is still valid, no need to re-run. 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; } diff --git a/src/Plugins/Tia/Configuration.php b/src/Plugins/Tia/Configuration.php index 0a0fe401..d9b72e92 100644 --- a/src/Plugins/Tia/Configuration.php +++ b/src/Plugins/Tia/Configuration.php @@ -24,6 +24,52 @@ use Pest\Support\Container; */ 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 * override) the built-in defaults. diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 0328918b..0c3758bd 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -5,30 +5,14 @@ declare(strict_types=1); namespace Pest\Plugins\Tia; /** - * Captures environmental inputs that, when changed, may make the TIA graph - * or its recorded results stale. The fingerprint is split into two buckets: + * Two-bucket fingerprint for TIA staleness detection. * - * - **structural** — describes what the graph's *edges* were recorded - * against. If any of these drift (`composer.lock`, `composer.json`, - * `phpunit.xml{,.dist}`, `vite.config.*`, Pest's factory codegen) the - * edges themselves are potentially wrong and the graph must rebuild - * from scratch. `tests/TestCase.php` and `tests/Pest.php` are - * intentionally NOT here — those are handled by per-test ancestor - * 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. + * - **structural**: inputs whose drift means graph *edges* may be wrong → full rebuild. + * `tests/TestCase.php` and `tests/Pest.php` are intentionally absent; they're covered by + * `Recorder::linkAncestorFiles` and the watch pattern, giving precise per-test invalidation. + * - **environmental**: runtime inputs (PHP version, extensions, env files) whose drift means + * edges are still valid but cached results may not reproduce → drop results and re-run. + * Pest's own version is absent; `composer.lock` moves whenever Pest is upgraded. * * @internal */ @@ -83,7 +67,11 @@ final readonly class Fingerprint // are included in the environmental bucket. They are commonly // git-ignored, so watch patterns alone cannot reliably notice // 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{ @@ -96,40 +84,19 @@ final readonly class Fingerprint return [ 'structural' => [ '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), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), - // Pest's generated classes bake the code-generation logic - // in — if TestCaseFactory changes (new attribute, different - // method signature, etc.) every previously-recorded edge is - // stale. Hashing via `ContentHash::of()` so cosmetic edits - // (comments, formatting) don't drift the fingerprint. 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'), 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'), - // `vite.config.*` reshapes the module graph - // `JsModuleGraph` records at the next `--tia` run; if - // the config drifts without a rebuild, the stored - // `$jsFileToComponents` map is silently stale. - // `viteConfigHash` itself uses `ContentHash::of()` so - // a comment-only edit to vite.config doesn't rebuild. 'vite_config' => self::viteConfigHash($projectRoot), - // `composer.json` hashed against a behavioural subset: - // autoload(-dev), require(-dev), extra (Laravel - // package discovery), repositories, minimum-stability, - // and the platform / allow-plugins entries from - // `config`. Cosmetic fields (description, keywords, - // scripts, authors, funding, support) are excluded. + 'package_json' => self::packageJsonHash($projectRoot), + 'package_lock' => self::packageLockHash($projectRoot), + 'js_config' => self::jsConfigHash($projectRoot), 'composer_json' => self::composerJsonHash($projectRoot), ], 'environmental' => [ - // PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch - // almost never matches a dev's Herd/Homebrew install, and - // the patch rarely changes anything test-visible. + // Minor only (8.4, not 8.4.19) — CI's patch rarely matches dev installs. 'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION, 'extensions' => self::extensionsFingerprint($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 $a * @param array $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 $stored * @param array $current * @return list @@ -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 $stored * @param array $current * @return list @@ -244,12 +197,8 @@ final readonly class Fingerprint return self::bucket($fingerprint, 'environmental'); } + // Legacy flat-shape fingerprints (schema ≤ 3) return empty, causing structuralMatches to fail → rebuild. /** - * Returns `$fingerprint[$key]` as an `array` if it exists - * and is an array, otherwise empty. Legacy flat-shape fingerprints - * (schema ≤ 3) return empty here, which makes `structuralMatches` fail - * and the caller rebuild — the clean migration path. - * * @param array $fingerprint * @return array */ @@ -272,13 +221,6 @@ final readonly class Fingerprint 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 { $parts = []; @@ -294,12 +236,79 @@ final readonly class Fingerprint return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); } - /** - * Hashes environment files that can globally alter app boot behaviour. - * These files are often git-ignored, so they cannot rely on changed-file - * detection. The environmental bucket keeps graph edges while forcing all - * cached results to refresh after an env edit. - */ + private static function jsConfigHash(string $projectRoot): ?string + { + $parts = []; + + 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 { $paths = [ @@ -348,14 +357,6 @@ final readonly class Fingerprint 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 { $path = $projectRoot.'/composer.json'; @@ -403,15 +404,6 @@ final readonly class Fingerprint return $json === false ? null : hash('xxh128', $json); } - /** - * Behavioural subset of `composer.lock`. For every package in - * `packages` and `packages-dev`, keeps version + dist/source - * reference (commit SHA — catches dev-branch updates that don't - * bump the version string) + autoload(-dev) + extra (Laravel - * package discovery). Drops install timestamps, dist URLs, - * support links, descriptions, etc. — none of which change what - * code runs. - */ private static function composerLockHash(string $projectRoot): ?string { $path = $projectRoot.'/composer.lock'; @@ -492,12 +484,6 @@ final readonly class Fingerprint return is_string($reference) ? $reference : null; } - /** - * Recursively sorts associative arrays by key so semantically - * equivalent JSON produces the same hash regardless of key - * ordering. Lists (numeric arrays) keep their order — they're - * meaningful in `repositories`, `autoload.files`, etc. - */ private static function sortRecursively(mixed &$value): void { if (! is_array($value)) { @@ -537,16 +523,8 @@ final readonly class Fingerprint return $hash === false ? null : $hash; } - /** - * Deterministic hash of the extensions the project actually depends on — - * 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. - */ + // Only hashes `ext-*` entries declared in composer.json — incidental extensions loaded on the + // machine but not declared can't affect suite correctness, so they're excluded to reduce noise. private static function extensionsFingerprint(string $projectRoot): string { $extensions = self::declaredExtensions($projectRoot); @@ -567,15 +545,7 @@ final readonly class Fingerprint return hash('xxh128', implode("\n", $parts)); } - /** - * 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 - */ + /** @return list */ private static function declaredExtensions(string $projectRoot): array { $path = $projectRoot.'/composer.json'; diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 9b119c92..a7e15681 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -11,100 +11,35 @@ use PHPUnit\Framework\TestStatus\TestStatus; use Symfony\Component\Console\Output\OutputInterface; /** - * File-level Test Impact Analysis graph. - * - * Persists the mapping `test_file → set` 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. + * Dependency graph: test file → set. Skips unchanged tests on replay. + * Source files are indexed by numeric id to keep the on-disk JSON compact. * * @internal */ final class Graph { - /** - * Relative path of each known source file, indexed by numeric id. - * - * @var array - */ + /** @var array */ private array $files = []; - /** - * Reverse lookup: source file → numeric id. - * - * @var array - */ + /** @var array */ private array $fileIds = []; - /** - * Edges: test file (relative) → list of source file ids. - * - * @var array> - */ + /** @var array> */ private array $edges = []; - /** - * 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> - */ + /** @var array> */ private array $testTables = []; - /** - * 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> - */ + /** @var array> */ private array $testInertiaComponents = []; - /** - * 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> - */ + /** @var array> */ private array $jsFileToComponents = []; - /** - * Environment fingerprint captured at record time. - * - * @var array - */ + /** @var array */ 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, @@ -113,20 +48,10 @@ final class Graph */ private array $baselines = []; - /** - * 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. - */ + // Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink. private readonly string $projectRoot; - /** - * Cached project-relative test files that contain at least one test in the - * `arch` group. - * - * @var array|null - */ + /** @var array|null */ private ?array $archTestFiles = null; public function __construct(string $projectRoot) @@ -136,9 +61,6 @@ final class Graph $this->projectRoot = $real !== false ? $real : $projectRoot; } - /** - * Records that a test file depends on the given source file. - */ public function link(string $testFile, string $sourceFile): void { $testRel = $this->relative($testFile); @@ -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 $changedFiles Absolute or relative paths. - * @return array Relative test file paths. + * @return array */ public function affected(array $changedFiles): array { - // Normalise all changed paths once. $normalised = []; foreach ($changedFiles as $file) { @@ -184,15 +97,9 @@ final class Graph $affectedSet = []; - // Migration changes don't flow through the coverage-edge path — - // `RefreshDatabase` in every test's `setUp()` means every test - // has an edge to every migration, so step 1 would re-run the - // 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. + // Migrations can't flow through coverage edges: `RefreshDatabase` gives every test an edge to + // every migration, so any migration change would re-run the whole DB suite. Route them via + // table-intersection instead; unparseable migrations fall through to the watch pattern. $migrationPaths = []; $nonMigrationPaths = []; @@ -237,16 +144,22 @@ final class Graph } } - // Inertia page-component routing. When a page under - // `resources/js/Pages/` changes, map it to the component name - // Inertia would use (the path relative to `Pages/`, extension - // stripped) and intersect with the captured component edges. - // Only invalidates tests that actually rendered the page. - // Pages with no captured edges (never rendered during record, - // brand-new on this branch) fall through to the watch-pattern - // fallback — safe over-run. Pages handled here are tracked in - // `$preciselyHandledPages` so the watch broadcast and JS-dep - // lookup don't re-route them. + // Inertia page routing: map changed page files to component names and intersect with recorded + // component edges. Pages with no captured edges fall through to the watch pattern. + $globalFrontendRuntimeFiles = []; + + foreach ($nonMigrationPaths as $rel) { + if (! $this->isGlobalFrontendRuntimePath($rel)) { + continue; + } + + foreach (array_keys($this->testInertiaComponents) as $testFile) { + $affectedSet[$testFile] = true; + } + + $globalFrontendRuntimeFiles[$rel] = true; + } + $changedComponents = []; $preciselyHandledPages = []; @@ -263,17 +176,13 @@ final class Graph } } - // Shared JS files (Components, Layouts, composables, etc.) - // aren't Inertia pages but pages depend on them transitively. - // `$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. + // Shared JS files: resolve via the recorded Vite module graph to their dependent page components. + // Files absent from the map fall through to the watch pattern. $sharedFilesResolved = []; foreach ($nonMigrationPaths as $rel) { + if (isset($globalFrontendRuntimeFiles[$rel])) { + continue; + } if (isset($preciselyHandledPages[$rel])) { continue; } @@ -295,23 +204,14 @@ final class Graph } } - // Orphan detection for NEW JS files. `$jsFileToComponents` is - // a record-time snapshot; files added since (a fresh Vue - // component, a new shared util, etc.) are absent from it. - // 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. + // New JS files absent from the record-time map: ask Vite (strict, no PHP fallback) which pages + // import them. A negative answer suppresses the broad watch broadcast; Node is the only resolver + // trustworthy enough to honour a negative (PHP parser can miss custom aliases). $newJsFiles = []; foreach ($nonMigrationPaths as $rel) { + if (isset($globalFrontendRuntimeFiles[$rel])) { + continue; + } if (isset($preciselyHandledPages[$rel])) { continue; } @@ -331,12 +231,8 @@ final class Graph $freshMap = JsModuleGraph::buildStrict($this->projectRoot); if ($freshMap === null) { - // Vite resolver was unavailable (Node missing, cold-start - // timeout, vite.config refused to load). Falling back to - // 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. + // Vite resolver unavailable — falling back to watch pattern; surface a line so the user + // knows precision was downgraded rather than leaving the slower replay unexplained. $output = Container::getInstance()->get(OutputInterface::class); if ($output instanceof OutputInterface) { $output->writeln(sprintf( @@ -349,9 +245,7 @@ final class Graph $pages = $freshMap[$rel] ?? []; if ($pages === []) { - // Vite itself says nothing imports this file. - // Safe to skip — mark handled so the watch - // pattern below doesn't re-broadcast it. + // Vite confirms no page imports this file — suppress the watch broadcast. $sharedFilesResolved[$rel] = true; continue; @@ -388,9 +282,8 @@ final class Graph } } - // 1. Coverage-edge lookup (PHP → PHP). Migrations are already - // handled above; skipping them here prevents their always-on - // coverage edges from invalidating the whole DB suite. + // Coverage-edge lookup (PHP → PHP). Migrations already handled above; skipping here prevents + // their always-on edges from re-running the whole DB suite. $changedIds = []; $unknownSourceDirs = []; $sourcePhpChanged = false; @@ -410,28 +303,18 @@ final class Graph $absolute = $this->projectRoot.'/'.$rel; if (! is_file($absolute)) { - // Deleted source file unknown to the graph — can't affect - // any test because no edge ever pointed to it. + // Deleted source file unknown to the graph — no edge ever pointed to it. 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)) { $unknownSourceDirs[dirname($rel)] = true; } } } - // Architecture tests inspect source structure by namespace / path rather - // than by executing the inspected files. A new enum/class can therefore - // 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. + // Arch tests inspect structure by namespace/path, never producing coverage edges for the files + // they examine — so a new class can fail an arch expectation without any edge to it. if ($sourcePhpChanged) { foreach (array_keys($this->edges) as $testFile) { if ($this->isArchTestFile($testFile)) { @@ -454,11 +337,8 @@ final class Graph } } - // Unknown Blade files can still be routed precisely when another - // recorded Blade view statically references them (`@include`, - // `@extends`, ``, 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. + // Unknown Blade files: walk static references (@include, @extends, ) up to rendered + // ancestors and invalidate only tests that covered them. $staticallyHandledBlade = []; foreach ($nonMigrationPaths as $rel) { if (isset($this->fileIds[$rel])) { @@ -480,29 +360,13 @@ final class Graph $staticallyHandledBlade[$rel] = true; } elseif ($this->isBladeComponentPath($rel)) { - // Anonymous Blade components are leaf templates. If nothing in - // the project statically renders the component, treat it like an - // orphan rather than running the full suite. + // Anonymous component with no static usages — treat as orphan rather than broadcasting. $staticallyHandledBlade[$rel] = true; } } - // 2. Watch-pattern lookup — fallback for files we don't have - // precise edges for. When a file is already in `$fileIds` step - // 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. + // Watch-pattern fallback: files with no precise edges. Already-resolved files are excluded + // to avoid re-broadcasting via the watch pattern and defeating the surgical match. $unknownToGraph = $unparseableMigrations; foreach ($nonMigrationPaths as $rel) { if (isset($preciselyHandledPages[$rel])) { @@ -516,8 +380,7 @@ final class Graph } if (! isset($this->fileIds[$rel])) { if (! is_file($this->projectRoot.'/'.$rel)) { - // Deleted file unknown to the graph — no edge ever - // pointed to it, so it can't affect any test. + // Deleted file unknown to the graph — no edge ever pointed to it. continue; } @@ -535,22 +398,9 @@ final class Graph $affectedSet[$testFile] = true; } - // 3. Sibling heuristic for unknown source files. - // - // When a PHP source file is unknown to the graph (no test depends on - // 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. + // 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- + // discovered files (Listeners, Events, Policies, etc.) aren't silently missed. if ($unknownSourceDirs !== []) { foreach ($this->edges as $testFile => $ids) { if (isset($affectedSet[$testFile])) { @@ -576,9 +426,6 @@ final class Graph return array_keys($affectedSet); } - /** - * Returns `true` if the given test file has any recorded dependencies. - */ public function knowsTest(string $testFile): bool { $rel = $this->relative($testFile); @@ -586,9 +433,7 @@ final class Graph return $rel !== null && isset($this->edges[$rel]); } - /** - * @return array All project-relative test files the graph knows. - */ + /** @return array */ public function allTestFiles(): array { return array_keys($this->edges); @@ -610,12 +455,6 @@ final class Graph 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 { $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 { $baseline = $this->baselineFor($branch, $fallbackBranch); @@ -693,14 +526,7 @@ final class Graph $this->baselines[$branch]['tree'] = $tree; } - /** - * Wipes cached per-test results for the given branch. Edges and tree - * snapshot stay intact — the graph still describes the code correctly, - * only the "what happened last time" data is reset. Used on - * environmental fingerprint drift: the edges were recorded elsewhere - * (e.g. CI) so they're still valid, but the results aren't trustworthy - * on this machine until the tests re-run here. - */ + // Edges and tree snapshot stay intact; only the run-state is reset. public function clearResults(string $branch): void { $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> $testToFiles */ 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> $testToTables */ 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> $testToComponents */ 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> $fileToComponents */ public function replaceJsFileToComponents(array $fileToComponents): void @@ -877,23 +681,11 @@ final class Graph $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 { 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 { static $prefixes = [ @@ -905,6 +697,14 @@ final class Graph 'app/Console/Commands/', 'app/Mail/', '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/seeders/', ]; @@ -1204,15 +1004,7 @@ final class Graph return $name === '' ? [] : [$name, str_replace('_', '-', $name)]; } - /** - * 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 - */ + /** @return list */ private function tablesForMigration(string $rel): array { $absolute = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$rel; @@ -1230,21 +1022,7 @@ final class Graph return TableExtractor::fromMigrationSource($content); } - /** - * 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. - */ + // Both `Pages/` and `pages/` are accepted — git paths are case-sensitive on Linux. private function componentForInertiaPage(string $rel): ?string { foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) { @@ -1273,13 +1051,27 @@ final class Graph return null; } - /** - * Whether any test's component set contains `$component`. Used to - * decide between precise edge matching and watch-pattern fallback - * for a changed Inertia page file. - * - * @param array> $edges - */ + private function isGlobalFrontendRuntimePath(string $rel): bool + { + if (! str_starts_with($rel, 'resources/js/')) { + return false; + } + + $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> $edges */ private function anyTestUses(array $edges, string $component): bool { foreach ($edges as $components) { @@ -1291,11 +1083,6 @@ final class Graph 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 { $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 { $data = json_decode($json, true); @@ -1412,12 +1193,6 @@ final class 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 { $payload = [ @@ -1436,15 +1211,8 @@ final class Graph return $json === false ? null : $json; } - /** - * Normalises a path to be relative to the project root; returns `null` for - * paths we should ignore (outside the project, unknown, virtual, vendor). - * - * Accepts both absolute paths (from Xdebug/PCOV coverage) and - * project-relative paths (from `git diff`) — we normalise without relying - * on `realpath()` of relative paths because the current working directory - * is not guaranteed to be the project root. - */ + // Accepts both absolute paths (from coverage drivers) and project-relative paths (from git diff). + // Relative paths are NOT resolved via realpath() because CWD is not guaranteed to be the project root. private function relative(string $path): ?string { if ($path === '' || $path === 'unknown') { @@ -1471,12 +1239,9 @@ final class Graph return null; } - // Always normalise to forward slashes. Windows' native separator - // would otherwise produce keys that never match paths reported - // by `git` (which always uses forward slashes). + // Always forward slashes — git always uses them; Windows backslashes would never match. $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); } else { - // Normalise directory separators and strip any "./" prefix. $relative = str_replace(DIRECTORY_SEPARATOR, '/', $path); while (str_starts_with($relative, './')) { @@ -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/')) { return null; } diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index 15f804a7..c8479dcb 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -8,119 +8,49 @@ use Pest\TestSuite; use ReflectionClass; /** - * Captures per-test file coverage using the PCOV driver. - * - * 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. + * Captures per-test file coverage. Singleton because PCOV/Xdebug have a single global state + * shared across the `Prepared` and `Finished` subscribers. * * @internal */ final class Recorder { - /** - * Test file currently being recorded, or `null` when idle. - */ private ?string $currentTestFile = null; - /** - * Aggregated map: absolute test file → set. - * - * @var array> - */ + /** @var array> */ private array $perTestFiles = []; - /** - * Aggregated map: absolute test file → set. - * 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> - */ + /** @var array> */ private array $perTestTables = []; - /** - * Aggregated map: absolute test file → set. - * 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> - */ + /** @var array> */ private array $perTestInertiaComponents = []; - /** - * 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 - */ + /** @var array */ private array $perTestUsesDatabase = []; - /** - * Cached class → test file resolution. - * - * @var array - */ + /** @var array */ private array $classFileCache = []; - /** - * Cached class → "uses Laravel DB trait" introspection result. - * - * @var array - */ + /** @var array */ private array $classUsesDatabaseCache = []; - /** - * Reverse map of project-local source file → list of class / - * interface / trait names declared in it. Built incrementally as - * tests run and new classes get autoloaded; consumed by - * `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> - */ + // Source file → declared class names. Built incrementally as classes are autoloaded. + // Used to walk the interface/trait/parent hierarchy which coverage drivers miss + // (interfaces and empty traits emit no executable bytecode). + /** @var array> */ private array $fileToClassNames = []; - /** - * Names already folded into `$fileToClassNames`. Lets the - * incremental refresher skip classes seen in a previous test. - * - * @var array - */ + /** @var array */ private array $indexedClassNames = []; - /** - * 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> - */ + /** @var array> */ private array $classDependencyCache = []; - /** - * Cached test-file import resolution. - * - * @var array> - */ + /** @var array> */ private array $testImportFileCache = []; - /** - * Included-file snapshot captured at the start of the current test. - * - * @var array - */ + /** @var array */ private array $includedFilesAtTestStart = []; private bool $active = false; @@ -148,15 +78,8 @@ final class Recorder $this->driver = 'pcov'; $this->driverAvailable = true; } elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) { - // Xdebug 3+ exposes the active mode set via `xdebug_info`, - // so we can ask directly instead of probing with a - // 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. + // Probing with start/stop emits E_WARNING when coverage is off, which monitoring agents + // (Sentry, Bugsnag) can surface as a real error. xdebug_info('mode') is silent. $modes = \xdebug_info('mode'); if (is_array($modes) && in_array('coverage', $modes, true)) { @@ -201,17 +124,8 @@ final class Recorder $this->perTestUsesDatabase[$file] = true; } - // Walk the parent-class chain and link each ancestor's defining - // file as a source dependency of this test. Captures the common - // `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. + // Walk parent-class chain to link ancestor files. Empty base classes (e.g. a trait-only + // TestCase) emit no executable bytecode, so the coverage driver never records them. $this->linkAncestorFiles($className); $this->linkImportedFiles($file); @@ -239,9 +153,7 @@ final class Recorder } else { /** @var array $data */ $data = \xdebug_get_code_coverage(); - // `true` resets Xdebug's internal buffer so the next `start()` - // does not accumulate earlier tests' coverage into the current - // one — otherwise the graph becomes progressively polluted. + // `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage. \xdebug_stop_code_coverage(true); } @@ -258,29 +170,14 @@ final class Recorder $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; } - // Walk each covered class's interfaces / traits / parent chain - // and link those files explicitly. Interface declarations have - // 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. + // Walk covered classes' interfaces/traits/parents. Interfaces have no executable bytecode, + // so a signature change would leave implementing-class tests stale without this walk. $this->linkSourceDependencies(array_keys($data)); $this->currentTestFile = null; $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 { if (! $this->active) { @@ -298,12 +195,7 @@ final class Recorder $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; } - /** - * Records source dependencies for a specific test file. Used for edges - * captured before `Prepared` has opened the normal per-test recorder window. - * - * @param iterable $sourceFiles - */ + /** @param iterable $sourceFiles */ public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void { if (! $this->active) { @@ -323,23 +215,7 @@ final class Recorder } } - /** - * 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 $coveredFiles absolute paths from coverage - */ + /** @param array $coveredFiles */ private function linkSourceDependencies(array $coveredFiles): void { 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 { $names = array_merge( @@ -384,12 +251,6 @@ final class Recorder } $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) && ! interface_exists($name, false) && ! trait_exists($name, false)) { @@ -416,15 +277,7 @@ final class Recorder } } - /** - * 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 - */ + /** @return list */ private function classDependencies(string $className): array { if (isset($this->classDependencyCache[$className])) { @@ -458,19 +311,11 @@ final class Recorder $files[$f] = true; }; - // `getInterfaceNames()` is transitive — it returns interfaces - // from parent classes and parent interfaces too — so a single - // pass covers the whole interface graph. + // getInterfaceNames() is transitive — includes parents' interfaces — so one pass suffices. foreach ($reflection->getInterfaceNames() as $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) { $linkSymbol($tname); } @@ -490,16 +335,6 @@ final class Recorder 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 { 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 { if ($this->currentTestFile === null) { @@ -653,14 +483,6 @@ final class Recorder 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 { if (array_key_exists($className, $this->classUsesDatabaseCache)) { @@ -692,14 +514,6 @@ final class Recorder 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 { if (! $this->active) { @@ -717,15 +531,6 @@ final class Recorder $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 { if (! $this->active) { @@ -743,9 +548,7 @@ final class Recorder $this->perTestInertiaComponents[$this->currentTestFile][$component] = true; } - /** - * @return array> absolute test file → list of absolute source files. - */ + /** @return array> */ public function perTestFiles(): array { $out = []; @@ -757,9 +560,7 @@ final class Recorder return $out; } - /** - * @return array> absolute test file → sorted list of table names. - */ + /** @return array> */ public function perTestTables(): array { $out = []; @@ -773,9 +574,7 @@ final class Recorder return $out; } - /** - * @return array> absolute test file → sorted list of Inertia component names. - */ + /** @return array> */ public function perTestInertiaComponents(): array { $out = []; @@ -789,9 +588,7 @@ final class Recorder return $out; } - /** - * @return array absolute test file → true for tests using a Laravel DB-resetting trait. - */ + /** @return array */ public function perTestUsesDatabase(): array { return $this->perTestUsesDatabase; @@ -817,17 +614,8 @@ final class Recorder return null; } - /** - * Resolves the file that *defines* the test class. - * - * Order of preference: - * 1. Pest's generated `$__filename` static — the original `*.php` file - * containing the `test()` calls (the eval'd class itself has no file). - * 2. `ReflectionClass::getFileName()` — the concrete class's file. This - * is intentionally more specific than `ReflectionMethod::getFileName()` - * (which would return the *trait* file for methods brought in via - * `uses SharedTestBehavior`). - */ + // Prefers Pest's `$__filename` static (the original .php file) over ReflectionClass::getFileName() + // (which returns the trait file for methods brought in via `uses SharedTestBehavior`). private function readPestFilename(string $className): ?string { if (! class_exists($className, false)) { @@ -853,11 +641,6 @@ final class Recorder 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 { $this->currentTestFile = null; diff --git a/src/Plugins/Tia/WatchDefaults/Browser.php b/src/Plugins/Tia/WatchDefaults/Browser.php index a6efd5d0..dd28df1b 100644 --- a/src/Plugins/Tia/WatchDefaults/Browser.php +++ b/src/Plugins/Tia/WatchDefaults/Browser.php @@ -45,6 +45,21 @@ final readonly class Browser implements WatchDefault // Vite / Webpack build output that browser tests may consume. 'public/build/**/*.js', '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 = []; diff --git a/src/Plugins/Tia/WatchDefaults/Laravel.php b/src/Plugins/Tia/WatchDefaults/Laravel.php index ace946a6..f9a2dc5c 100644 --- a/src/Plugins/Tia/WatchDefaults/Laravel.php +++ b/src/Plugins/Tia/WatchDefaults/Laravel.php @@ -54,8 +54,28 @@ final readonly class Laravel implements WatchDefault // if the factory file was already autoloaded before Prepared. '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. '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 // by convention and power mailable tests that render markup. 'resources/views/email/**/*.blade.php' => [$testPath], diff --git a/src/Plugins/Tia/WatchDefaults/Php.php b/src/Plugins/Tia/WatchDefaults/Php.php index 723f49a8..cd0e24ec 100644 --- a/src/Plugins/Tia/WatchDefaults/Php.php +++ b/src/Plugins/Tia/WatchDefaults/Php.php @@ -58,12 +58,12 @@ final readonly class Php implements WatchDefault // suite. $testPath.'/Datasets/**/*.php' => [$testPath], - // Test fixtures — JSON, CSV, XML, TXT data files consumed by - // assertions. A fixture change can flip a test result. - $testPath.'/Fixtures/**/*.json' => [$testPath], - $testPath.'/Fixtures/**/*.csv' => [$testPath], - $testPath.'/Fixtures/**/*.xml' => [$testPath], - $testPath.'/Fixtures/**/*.txt' => [$testPath], + // Test fixtures — data/source snippets consumed by assertions or + // external analysers. Nested `Fixtures/` directories are common + // beside a single test class, and PHP fixtures may be parsed by + // tools without being `require`d, so coverage cannot see them. + $testPath.'/Fixtures/**/*' => [$testPath], + $testPath.'/**/Fixtures/**/*' => [$testPath], // Pest snapshots — external edits to snapshot files invalidate // snapshot assertions. diff --git a/src/Plugins/Tia/WatchPatterns.php b/src/Plugins/Tia/WatchPatterns.php index 0d03e243..700d75c0 100644 --- a/src/Plugins/Tia/WatchPatterns.php +++ b/src/Plugins/Tia/WatchPatterns.php @@ -43,6 +43,12 @@ final class WatchPatterns */ private array $patterns = []; + private bool $always = false; + + private bool $locally = false; + + private bool $filtered = false; + /** * Probes every registered `WatchDefault` and merges the patterns of * those that apply. Called once during Tia plugin boot, after BootFiles @@ -149,9 +155,42 @@ final class WatchPatterns 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 { $this->patterns = []; + $this->always = false; + $this->locally = false; + $this->filtered = false; } /** diff --git a/src/TestCaseFilters/TiaTestCaseFilter.php b/src/TestCaseFilters/TiaTestCaseFilter.php new file mode 100644 index 00000000..ecec9fa6 --- /dev/null +++ b/src/TestCaseFilters/TiaTestCaseFilter.php @@ -0,0 +1,60 @@ + $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))); + } +}