From 00f8d560838444f988b255e2f413ea8175208132 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Tue, 28 Apr 2026 21:41:20 +0100 Subject: [PATCH] wip --- src/Plugins/Tia/Graph.php | 217 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index c49a9501..0fe312df 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -426,6 +426,39 @@ 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. + $staticallyHandledBlade = []; + foreach ($nonMigrationPaths as $rel) { + if (isset($this->fileIds[$rel])) { + continue; + } + if (! $this->isBladePath($rel)) { + continue; + } + if (! is_file($this->projectRoot.'/'.$rel)) { + continue; + } + + $bladeAffected = $this->affectedByStaticBladeUsage($rel); + + if ($bladeAffected !== []) { + foreach ($bladeAffected as $testFile) { + $affectedSet[$testFile] = true; + } + + $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. + $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 @@ -450,6 +483,9 @@ final class Graph if (isset($sharedFilesResolved[$rel])) { continue; } + if (isset($staticallyHandledBlade[$rel])) { + continue; + } if (! isset($this->fileIds[$rel])) { if (! is_file($this->projectRoot.'/'.$rel)) { // Deleted file unknown to the graph — no edge ever @@ -854,6 +890,187 @@ final class Graph return false; } + private function isBladePath(string $rel): bool + { + return str_starts_with($rel, 'resources/views/') && str_ends_with($rel, '.blade.php'); + } + + private function isBladeComponentPath(string $rel): bool + { + return str_starts_with($rel, 'resources/views/components/') && str_ends_with($rel, '.blade.php'); + } + + /** + * @return list Project-relative test files. + */ + private function affectedByStaticBladeUsage(string $changedBlade): array + { + $ancestors = $this->bladeAncestorsFor($changedBlade); + + if ($ancestors === []) { + return []; + } + + $ancestorIds = []; + foreach ($ancestors as $ancestor) { + if (isset($this->fileIds[$ancestor])) { + $ancestorIds[$this->fileIds[$ancestor]] = true; + } + } + + if ($ancestorIds === []) { + return []; + } + + $affected = []; + foreach ($this->edges as $testFile => $ids) { + foreach ($ids as $id) { + if (isset($ancestorIds[$id])) { + $affected[$testFile] = true; + + break; + } + } + } + + return array_keys($affected); + } + + /** + * @return list Project-relative Blade files that statically depend on $changedBlade, directly or transitively. + */ + private function bladeAncestorsFor(string $changedBlade): array + { + $allBladeFiles = $this->allBladeFiles(); + + if ($allBladeFiles === []) { + return []; + } + + $targets = [$changedBlade => true]; + $ancestors = []; + $changed = true; + + while ($changed) { + $changed = false; + + foreach ($allBladeFiles as $candidate) { + if (isset($targets[$candidate])) { + continue; + } + if (isset($ancestors[$candidate])) { + continue; + } + + $source = @file_get_contents($this->projectRoot.'/'.$candidate); + if ($source === false) { + continue; + } + + foreach (array_keys($targets) as $target) { + if ($this->bladeSourceReferences($source, $target)) { + $ancestors[$candidate] = true; + $targets[$candidate] = true; + $changed = true; + + break; + } + } + } + } + + return array_keys($ancestors); + } + + /** + * @return list + */ + private function allBladeFiles(): array + { + $views = $this->projectRoot.'/resources/views'; + + if (! is_dir($views)) { + return []; + } + + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($views, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + if (! $file instanceof \SplFileInfo || ! $file->isFile()) { + continue; + } + + $path = $file->getPathname(); + if (! str_ends_with($path, '.blade.php')) { + continue; + } + + $files[] = str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->projectRoot) + 1)); + } + + sort($files); + + return $files; + } + + private function bladeSourceReferences(string $source, string $targetBlade): bool + { + $view = $this->viewNameForBlade($targetBlade); + + if ($view !== null) { + $quoted = preg_quote($view, '#'); + + if (preg_match('#@(include|includeIf|includeWhen|includeUnless|extends|component|each)\s*\([^)]*[\'\"]'.$quoted.'[\'\"]#', $source) === 1) { + return true; + } + + if (preg_match('#\b(view|View::make)\s*\(\s*[\'\"]'.$quoted.'[\'\"]#', $source) === 1) { + return true; + } + } + + foreach ($this->componentNamesForBlade($targetBlade) as $component) { + $quoted = preg_quote($component, '#'); + + if (preg_match('#/.:])#i', $source) === 1) { + return true; + } + } + + return false; + } + + private function viewNameForBlade(string $rel): ?string + { + if (! $this->isBladePath($rel)) { + return null; + } + + $tail = substr($rel, strlen('resources/views/')); + $tail = substr($tail, 0, -strlen('.blade.php')); + + return str_replace('/', '.', $tail); + } + + /** + * @return list + */ + private function componentNamesForBlade(string $rel): array + { + if (! $this->isBladeComponentPath($rel)) { + return []; + } + + $tail = substr($rel, strlen('resources/views/components/')); + $tail = substr($tail, 0, -strlen('.blade.php')); + $name = str_replace('/', '.', $tail); + + 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`.