diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 38d3c45f..46148178 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -88,7 +88,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable * flag forces an immediate retry (e.g. right after publishing a * baseline from CI for the first time). */ - private const string REFETCH_OPTION = '--tia-refetch'; + private const string REFETCH_OPTION = '--refetch'; /** * State keys under which TIA persists its blobs. Kept here as constants @@ -123,7 +123,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable * 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 `--tia-refetch`. + * cooldown expires or the user passes `--refetch`. */ public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json'; @@ -224,11 +224,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private bool $recordingActive = false; /** - * True when `--tia-refetch` is in the current argv — `BaselineSync` + * True when `--refetch` is in the current argv — `BaselineSync` * uses it to bypass the post-failure fetch cooldown. */ private bool $forceRefetch = 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. + */ + private bool $freshRebuild = false; + public function __construct( private readonly OutputInterface $output, private readonly Recorder $recorder, @@ -348,6 +359,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable // silently ignore it here and let whatever else consumes it // handle it. The flag isn't popped in that branch. $forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal); + $this->freshRebuild = $forceRebuild; if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) { return $arguments; @@ -444,7 +456,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $graph->replaceTestTables($perTestTables); $graph->replaceTestInertiaComponents($perTestInertia); $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); - $graph->pruneMissingTests(); + + // 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* @@ -612,7 +634,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $graph->replaceTestTables($finalisedTables); $graph->replaceTestInertiaComponents($finalisedInertia); $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); - $graph->pruneMissingTests(); + + // 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(); + } if (! $this->saveGraph($graph)) { $this->output->writeln(' TIA failed to write graph.'); diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 0c653fc0..d97a671d 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -67,7 +67,7 @@ final readonly class BaselineSync * 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 `--tia-refetch`. + * day, let the user override with `--refetch`. */ private const int FETCH_COOLDOWN_SECONDS = 86400; @@ -82,7 +82,7 @@ final readonly class BaselineSync * landed; coverage is best-effort since plain `--tia` (no `--coverage`) * never reads it. * - * `$force = true` (driven by `--tia-refetch`) ignores the post-failure + * `$force = true` (driven by `--refetch`) ignores the post-failure * cooldown so the user can retry on demand without waiting out the * 24h window. */ @@ -97,7 +97,7 @@ final readonly class BaselineSync if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { $this->output->writeln(sprintf( ' TIA last fetch found no baseline — next auto-retry in %s. ' - .'Override with --tia-refetch.', + .'Override with --refetch.', $this->formatDuration($remaining), )); diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index ae4085c4..f533b244 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -226,17 +226,18 @@ final class Graph } } - // Inertia page-component routing. When a Vue/React/Svelte page - // under `resources/js/Pages/` changes, map it to the component - // name Inertia would use (the path relative to `Pages/`, with - // the 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 via - // `$unknownPageComponents` — safe over-run. + // 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. $changedComponents = []; - $unknownPageComponents = []; + $preciselyHandledPages = []; foreach ($nonMigrationPaths as $rel) { $component = $this->componentForInertiaPage($rel); @@ -247,20 +248,6 @@ final class Graph if ($this->anyTestUses($this->testInertiaComponents, $component)) { $changedComponents[$component] = true; - } else { - $unknownPageComponents[] = $rel; - } - } - - // Pages whose component already resolved precisely via the - // direct Inertia edges path must not leak back through any - // broader mechanism (either the JS-dep lookup below, or the - // watch pattern further down). - $preciselyHandledPages = []; - foreach ($nonMigrationPaths as $rel) { - $component = $this->componentForInertiaPage($rel); - - if ($component !== null && isset($changedComponents[$component])) { $preciselyHandledPages[$rel] = true; } } @@ -360,68 +347,6 @@ final class Graph } } - // Blade orphan detection. Mirror of the JS orphan check: a - // newly-added `.blade.php` that literally nothing references - // (no `@include('x.y')`, no `view('x.y')`, no `Route::view`) - // can't affect any test, so the broad `resources/views/**` - // watch broadcast is wasted work. We only do this for blades - // *outside* the auto-resolved directories where Laravel / - // Livewire / Flux map class/tag names to file paths without - // a literal reference — there the absence of a string match - // isn't evidence of orphanhood. - $newBlades = []; - foreach ($nonMigrationPaths as $rel) { - if (isset($preciselyHandledPages[$rel])) { - continue; - } - if (isset($sharedFilesResolved[$rel])) { - continue; - } - if (! str_ends_with(strtolower($rel), '.blade.php')) { - continue; - } - if (isset($this->fileIds[$rel])) { - continue; - } - if (! $this->isBladeOrphanEligible($rel)) { - continue; - } - if (! is_file($this->projectRoot.DIRECTORY_SEPARATOR.$rel)) { - continue; - } - $newBlades[] = $rel; - } - - if ($newBlades !== []) { - $needles = []; - foreach ($newBlades as $rel) { - $name = $this->viewNameFromBladePath($rel); - if ($name !== null) { - $needles[$name] = $rel; - } - } - - $referenced = $this->findReferencedViewNames($needles, $newBlades); - - foreach ($newBlades as $rel) { - $name = $this->viewNameFromBladePath($rel); - if ($name === null) { - continue; - } - if (! isset($referenced[$name])) { - // No `@include`, `view(…)`, or `Route::view(…)` - // mentions this view name anywhere in the project. - // Dynamic includes (`@include($var)`) can't be - // proven against — we accept the tradeoff of - // under-invalidation in the narrow case where a - // view is loaded exclusively via runtime - // composition. The watch pattern still fires for - // blades in components/, livewire/, pages/ etc. - $sharedFilesResolved[$rel] = true; - } - } - } - if ($changedComponents !== []) { foreach ($this->testInertiaComponents as $testFile => $components) { if (isset($affectedSet[$testFile])) { @@ -805,9 +730,13 @@ final class Graph /** * Replaces the whole JS dep map. Called at record time with the - * output of `JsModuleGraph::build()`. Unlike the test-level - * replacements above this is a wholesale overwrite — the - * resolver produces the full graph on every run. + * 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 */ @@ -836,6 +765,10 @@ final class Graph $out[$path] = $keys; } + if ($out === []) { + return; + } + ksort($out); $this->jsFileToComponents = $out; @@ -904,7 +837,7 @@ final class Graph $extension = substr($tail, $dot + 1); - if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte'], true)) { + if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'], true)) { return null; } @@ -931,142 +864,6 @@ final class Graph return false; } - /** - * Blades inside auto-resolving directories (Blade components, - * Livewire components, Volt pages, Flux UI, vendor packages) are - * referenced by *class name* or *tag name* at runtime rather than - * by literal path string. Absence of a string match therefore - * can't prove orphanhood — we skip them and let the watch pattern - * do its job. - */ - private function isBladeOrphanEligible(string $rel): bool - { - $prefix = 'resources/views/'; - if (! str_starts_with($rel, $prefix)) { - return false; - } - - $tail = substr($rel, strlen($prefix)); - - foreach (['components/', 'livewire/', 'pages/', 'flux/', 'vendor/'] as $autoResolved) { - if (str_starts_with($tail, $autoResolved)) { - return false; - } - } - - return true; - } - - /** - * Maps `resources/views/admin/reports.blade.php` → - * `admin.reports` (Laravel's dot-notation view name). Returns - * null for anything that isn't a regular `.blade.php` under - * `resources/views/`. - */ - private function viewNameFromBladePath(string $rel): ?string - { - $prefix = 'resources/views/'; - $suffix = '.blade.php'; - - if (! str_starts_with($rel, $prefix)) { - return null; - } - if (! str_ends_with(strtolower($rel), $suffix)) { - return null; - } - - $tail = substr($rel, strlen($prefix)); - $base = substr($tail, 0, strlen($tail) - strlen($suffix)); - - if ($base === '') { - return null; - } - - return str_replace('/', '.', $base); - } - - /** - * Scans every `.php` / `.blade.php` file under `app/`, `routes/`, - * `tests/`, and `resources/views/` for a literal occurrence of - * each needle (the dot-notation view name). Returns the set of - * needles that were found at least once. - * - * @param array $needles view name → blade path (target itself, skipped during scan) - * @param array $skipPaths project-relative paths to skip (the new blades themselves) - * @return array - */ - private function findReferencedViewNames(array $needles, array $skipPaths): array - { - if ($needles === []) { - return []; - } - - $skipSet = array_fill_keys($skipPaths, true); - $found = []; - - $roots = [ - $this->projectRoot.DIRECTORY_SEPARATOR.'app', - $this->projectRoot.DIRECTORY_SEPARATOR.'routes', - $this->projectRoot.DIRECTORY_SEPARATOR.'tests', - $this->projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'views', - ]; - - $rootLen = strlen(rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR); - - foreach ($roots as $root) { - if (! is_dir($root)) { - continue; - } - - if (count($found) === count($needles)) { - break; - } - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), - ); - - foreach ($iterator as $fileInfo) { - if (count($found) === count($needles)) { - break; - } - if (! $fileInfo->isFile()) { - continue; - } - - $path = $fileInfo->getPathname(); - $lower = strtolower((string) $path); - - if (! str_ends_with($lower, '.php')) { - continue; - } - - $relPath = str_replace(DIRECTORY_SEPARATOR, '/', substr((string) $path, $rootLen)); - - if (isset($skipSet[$relPath])) { - continue; - } - - $content = @file_get_contents($path); - - if ($content === false) { - continue; - } - - foreach (array_keys($needles) as $needle) { - if (isset($found[$needle])) { - continue; - } - if (str_contains($content, $needle)) { - $found[$needle] = true; - } - } - } - } - - return $found; - } - /** * Drops edges whose test file no longer exists on disk. Prevents the graph * from keeping stale entries for deleted / renamed tests that would later diff --git a/src/Plugins/Tia/WatchDefaults/Php.php b/src/Plugins/Tia/WatchDefaults/Php.php index d8775fc5..723f49a8 100644 --- a/src/Plugins/Tia/WatchDefaults/Php.php +++ b/src/Plugins/Tia/WatchDefaults/Php.php @@ -51,6 +51,13 @@ final readonly class Php implements WatchDefault // record-mode graph rebuild. $testPath.'/Pest.php' => [$testPath], + // Pest dataset definitions are loaded once at boot, outside + // the per-test coverage window — no edge captures them. A + // change to a shared dataset can flip the result of any test + // that uses it, so broadcast every dataset edit to the full + // 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],