This commit is contained in:
nuno maduro
2026-04-27 13:03:07 +01:00
parent 7250185423
commit b9088d23fb
4 changed files with 67 additions and 234 deletions

View File

@ -88,7 +88,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
* flag forces an immediate retry (e.g. right after publishing a * flag forces an immediate retry (e.g. right after publishing a
* baseline from CI for the first time). * 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 * 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 * Cooldown marker keyed by `BaselineSync` after a failed fetch. Holds
* `{"until": <unix>}` — subsequent runs within the window skip the * `{"until": <unix>}` — subsequent runs within the window skip the
* fetch attempt (and its `gh run list` network hop) until 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'; public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json';
@ -224,11 +224,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private bool $recordingActive = false; 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. * uses it to bypass the post-failure fetch cooldown.
*/ */
private bool $forceRefetch = false; 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( public function __construct(
private readonly OutputInterface $output, private readonly OutputInterface $output,
private readonly Recorder $recorder, 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 // silently ignore it here and let whatever else consumes it
// handle it. The flag isn't popped in that branch. // handle it. The flag isn't popped in that branch.
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal); $forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
$this->freshRebuild = $forceRebuild;
if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) { if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
return $arguments; return $arguments;
@ -444,7 +456,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->replaceTestTables($perTestTables); $graph->replaceTestTables($perTestTables);
$graph->replaceTestInertiaComponents($perTestInertia); $graph->replaceTestInertiaComponents($perTestInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
// Pruning checks the local filesystem for each known test file —
// on a partial record (no `--fresh`) the current checkout may
// legitimately be missing tests that exist on other branches
// sharing this graph, so pruning would silently delete their
// edges. Stale entries for genuinely-deleted tests are harmless
// (test discovery never finds the file) and get cleaned up on
// the next `--fresh` rebuild.
if ($this->freshRebuild) {
$graph->pruneMissingTests(); $graph->pruneMissingTests();
}
// Fold in the results collected during this same record run. The // Fold in the results collected during this same record run. The
// `AddsOutput` pass that runs `snapshotTestResults` fires *before* // `AddsOutput` pass that runs `snapshotTestResults` fires *before*
@ -612,7 +634,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->replaceTestTables($finalisedTables); $graph->replaceTestTables($finalisedTables);
$graph->replaceTestInertiaComponents($finalisedInertia); $graph->replaceTestInertiaComponents($finalisedInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
// See `terminate()` — same rationale: pruning by current
// working-tree presence would silently drop edges for tests
// owned by other branches sharing this graph. Only safe on
// `--fresh` rebuilds.
if ($this->freshRebuild) {
$graph->pruneMissingTests(); $graph->pruneMissingTests();
}
if (! $this->saveGraph($graph)) { if (! $this->saveGraph($graph)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph.'); $this->output->writeln(' <fg=red>TIA</> failed to write graph.');

View File

@ -67,7 +67,7 @@ final readonly class BaselineSync
* Rationale: when the remote workflow hasn't published yet, every * Rationale: when the remote workflow hasn't published yet, every
* `pest --tia` invocation would otherwise re-hit `gh run list` and * `pest --tia` invocation would otherwise re-hit `gh run list` and
* re-print the publish instructions — noisy + slow. Back off for a * 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; 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`) * landed; coverage is best-effort since plain `--tia` (no `--coverage`)
* never reads it. * 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 * cooldown so the user can retry on demand without waiting out the
* 24h window. * 24h window.
*/ */
@ -97,7 +97,7 @@ final readonly class BaselineSync
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
$this->output->writeln(sprintf( $this->output->writeln(sprintf(
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. ' ' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. '
.'Override with <fg=cyan>--tia-refetch</>.', .'Override with <fg=cyan>--refetch</>.',
$this->formatDuration($remaining), $this->formatDuration($remaining),
)); ));

View File

@ -226,17 +226,18 @@ final class Graph
} }
} }
// Inertia page-component routing. When a Vue/React/Svelte page // Inertia page-component routing. When a page under
// under `resources/js/Pages/` changes, map it to the component // `resources/js/Pages/` changes, map it to the component name
// name Inertia would use (the path relative to `Pages/`, with // Inertia would use (the path relative to `Pages/`, extension
// the extension stripped) and intersect with the captured // stripped) and intersect with the captured component edges.
// component edges. Only invalidates tests that actually // Only invalidates tests that actually rendered the page.
// rendered the page. Pages with no captured edges (never // Pages with no captured edges (never rendered during record,
// rendered during record, brand-new on this branch) fall // brand-new on this branch) fall through to the watch-pattern
// through to the watch-pattern fallback via // fallback — safe over-run. Pages handled here are tracked in
// `$unknownPageComponents` — safe over-run. // `$preciselyHandledPages` so the watch broadcast and JS-dep
// lookup don't re-route them.
$changedComponents = []; $changedComponents = [];
$unknownPageComponents = []; $preciselyHandledPages = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
$component = $this->componentForInertiaPage($rel); $component = $this->componentForInertiaPage($rel);
@ -247,20 +248,6 @@ final class Graph
if ($this->anyTestUses($this->testInertiaComponents, $component)) { if ($this->anyTestUses($this->testInertiaComponents, $component)) {
$changedComponents[$component] = true; $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; $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 !== []) { if ($changedComponents !== []) {
foreach ($this->testInertiaComponents as $testFile => $components) { foreach ($this->testInertiaComponents as $testFile => $components) {
if (isset($affectedSet[$testFile])) { if (isset($affectedSet[$testFile])) {
@ -805,9 +730,13 @@ final class Graph
/** /**
* Replaces the whole JS dep map. Called at record time with the * Replaces the whole JS dep map. Called at record time with the
* output of `JsModuleGraph::build()`. Unlike the test-level * output of `JsModuleGraph::build()`. Empty input is treated as a
* replacements above this is a wholesale overwrite — the * resolver failure (Node missing, Vite refused to load, transient
* resolver produces the full graph on every run. * `npm install`) rather than a legitimate "no JS pages" signal —
* we keep the previous map. Stale entries for genuinely-deleted
* pages are harmless because deleted files never enter the
* changed set; over-broadcasting every JS edit through the watch
* pattern after a flaky Node run would be a real regression.
* *
* @param array<string, array<int, string>> $fileToComponents * @param array<string, array<int, string>> $fileToComponents
*/ */
@ -836,6 +765,10 @@ final class Graph
$out[$path] = $keys; $out[$path] = $keys;
} }
if ($out === []) {
return;
}
ksort($out); ksort($out);
$this->jsFileToComponents = $out; $this->jsFileToComponents = $out;
@ -904,7 +837,7 @@ final class Graph
$extension = substr($tail, $dot + 1); $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; return null;
} }
@ -931,142 +864,6 @@ final class Graph
return false; 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<string, string> $needles view name → blade path (target itself, skipped during scan)
* @param array<int, string> $skipPaths project-relative paths to skip (the new blades themselves)
* @return array<string, true>
*/
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 * Drops edges whose test file no longer exists on disk. Prevents the graph
* from keeping stale entries for deleted / renamed tests that would later * from keeping stale entries for deleted / renamed tests that would later

View File

@ -51,6 +51,13 @@ final readonly class Php implements WatchDefault
// record-mode graph rebuild. // record-mode graph rebuild.
$testPath.'/Pest.php' => [$testPath], $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 // Test fixtures — JSON, CSV, XML, TXT data files consumed by
// assertions. A fixture change can flip a test result. // assertions. A fixture change can flip a test result.
$testPath.'/Fixtures/**/*.json' => [$testPath], $testPath.'/Fixtures/**/*.json' => [$testPath],