From 48357c6f30775ad2176a4c685f8c34bf3080d67d Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 27 Apr 2026 10:30:08 +0100 Subject: [PATCH] wip --- src/Plugins/Tia/ChangedFiles.php | 5 + src/Plugins/Tia/Fingerprint.php | 27 ++++ src/Plugins/Tia/Graph.php | 261 ++++++++++++++++++++++++++++++ src/Plugins/Tia/JsModuleGraph.php | 18 +++ 4 files changed, 311 insertions(+) diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 37aeac92..1ec94383 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -283,6 +283,11 @@ final readonly class ChangedFiles '.phpunit.result.cache', 'vendor/', 'node_modules/', + // Laravel regenerates these from manifest state + // (package.json, service providers) at boot — they're + // fully derived, not authored. Treating them as + // "changes" just flaps the diff noisily. + 'bootstrap/cache/', ]; foreach ($prefixes as $prefix) { diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index e02e0b44..048fd782 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -85,6 +85,11 @@ final readonly class Fingerprint // Pest itself is edited. 'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'), 'pest_method_factory' => self::hashIfExists(__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. + 'vite_config' => self::viteConfigHash($projectRoot), ], 'environmental' => [ // PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch @@ -193,6 +198,28 @@ 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 = []; + + foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) { + $hash = self::hashIfExists($projectRoot.'/'.$name); + + if ($hash !== null) { + $parts[] = $name.':'.$hash; + } + } + + return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); + } + private static function hashIfExists(string $path): ?string { if (! is_file($path)) { diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index a1c6f1e4..ae4085c4 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -297,6 +297,131 @@ 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. + $newJsFiles = []; + foreach ($nonMigrationPaths as $rel) { + if (isset($preciselyHandledPages[$rel])) { + continue; + } + if (isset($sharedFilesResolved[$rel])) { + continue; + } + if (isset($this->jsFileToComponents[$rel])) { + continue; + } + if (! str_starts_with($rel, 'resources/js/')) { + continue; + } + $newJsFiles[] = $rel; + } + + if ($newJsFiles !== []) { + $freshMap = JsModuleGraph::buildStrict($this->projectRoot); + + if ($freshMap !== null) { + foreach ($newJsFiles as $rel) { + $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. + $sharedFilesResolved[$rel] = true; + + continue; + } + + $touchedAny = false; + foreach ($pages as $pageComponent) { + if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) { + $changedComponents[$pageComponent] = true; + $touchedAny = true; + } + } + + if ($touchedAny) { + $sharedFilesResolved[$rel] = true; + } + } + } + } + + // 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])) { @@ -806,6 +931,142 @@ 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/JsModuleGraph.php b/src/Plugins/Tia/JsModuleGraph.php index f32409fd..f79acf61 100644 --- a/src/Plugins/Tia/JsModuleGraph.php +++ b/src/Plugins/Tia/JsModuleGraph.php @@ -51,6 +51,24 @@ final class JsModuleGraph return JsImportParser::parse($projectRoot); } + /** + * Strict variant — only runs the Node helper, never falls back to + * the PHP parser. Returns null when Node isn't available or Vite + * won't load. + * + * Used at replay time when we need to *trust a negative result* + * (i.e., "no page imports this file, so it's orphan, safe to + * skip"). The PHP fallback is conservative on positives but can + * miss imports that rely on custom aliases or plugins — negative + * results from it cannot be trusted for orphan pruning. + * + * @return array>|null + */ + public static function buildStrict(string $projectRoot): ?array + { + return self::tryNodeHelper($projectRoot); + } + /** * True when the project looks like a Vite + Node project we can * ask for a module graph. Gate for callers that want to skip the