Keep missingStylesheets() to imports, and never crash on a foreign one

Plan step 41 review: an application's CSS entry may import what a Vite
build resolves but Stylesheets::bundle() refuses (tailwindcss mid-
migration, a font URL, a layer() import); resolvedFiles() threw on each,
failing the application's whole design test. It now follows relative
imports only and skips the rest. Each missing stylesheet, shadowed tag
and ->links() is reported once, at its first use, instead of at every
occurrence; a CSS entry that does not exist says so; the @import line goes
through vendor/ when Composer symlinks the package.

missingStylesheets() also switched on the literal-value check for the
entry's whole import graph, vendor stylesheets included, with no way to
check imports alone. Check (iii) now reads the .css files scan() is given,
like every other check; the entry's imports still feed the class
exemptions, now escaped selectors (.hover\:underline) included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 08:46:33 +02:00
co-authored by Claude Opus 5
parent 9595722f35
commit c0c71ef16c
8 changed files with 163 additions and 46 deletions
+48 -11
View File
@@ -93,26 +93,63 @@ final class Stylesheets
}
/**
* Every file `$files` reaches, transitively, through the same `@import` resolution `bundle()`
* walks — real paths, `$files` themselves included — without concatenating anything.
* `DesignGuard::missingStylesheets()` (plan step 41) asks this: whether an application's CSS
* entry's import graph already reaches a given package stylesheet, which needs the file
* identities `bundle()` resolves, not the CSS it produces. Shares `bundle()`'s cache (and so
* its freshness rule) under the same key, `$base` fixed to `null`, since only `bundle()` ever
* populates it.
* Every file `$files` reaches, transitively, through their relative `@import`s — real paths,
* `$files` themselves included — without concatenating anything.
* `DesignGuard::missingStylesheets()` (plan step 41) asks this of an application's CSS entry,
* which may import what `bundle()` refuses and a Vite build still resolves: a package name
* (`tailwindcss`), a URL, an import with a `layer()` or media condition. So unlike `bundle()`
* this never throws: an import naming a URL, an absolute path or a file that does not exist
* relative to its stylesheet is skipped, and a conditional import still counts as reaching
* its file.
*
* @param list<string> $files
* @return list<string>
*/
public static function resolvedFiles(array $files): array
{
if ($files === []) {
return [];
$seen = [];
foreach ($files as $file) {
$real = realpath($file);
if ($real !== false && is_file($real)) {
self::reach($real, $seen);
}
}
self::bundle($files);
return array_keys($seen);
}
return array_keys(self::$cache[serialize([$files, null])]['files']);
/**
* @param array<string, true> $seen every file already reached, by real path
*/
private static function reach(string $file, array &$seen): void
{
if (isset($seen[$file])) {
return;
}
$seen[$file] = true;
$css = (string) file_get_contents($file);
$masked = self::mask($css);
preg_match_all('/@import\b/i', $masked, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as [, $start]) {
$end = strpos($masked, ';', $start);
$statement = substr($css, $start, ($end === false ? strlen($css) : $end) - $start);
if (preg_match('/^@import\s*(?:url\(\s*([\'"]?)([^\'"()\s]+)\1\s*\)|([\'"])(.+?)\3)/is', $statement, $match, PREG_UNMATCHED_AS_NULL) !== 1) {
continue;
}
$target = $match[2] ?? $match[4] ?? '';
$real = preg_match('~^(?:[a-z][a-z0-9+.-]*:|/)~i', $target) === 1 ? false : realpath(dirname($file).'/'.$target);
if ($real !== false && is_file($real)) {
self::reach($real, $seen);
}
}
}
/**