This commit is contained in:
nuno maduro
2026-04-28 21:41:20 +01:00
parent ca2dca592d
commit 00f8d56083

View File

@ -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`, `<x-alert />`, 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<string> 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<string> 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<string>
*/
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('#<x-'.$quoted.'(?=[\s>/.:])#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<string>
*/
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`.