From 05f115ab36fadab3d99fee06fd03c249753dc8f1 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 14:31:01 +0200 Subject: [PATCH] Report the stylesheets an entry imports that no view needs any more missingStylesheets() names an import the views need, but nothing named one they had stopped needing: when SealShare's last card left its views, card.css stayed in every page until a reviewer noticed by hand. unusedStylesheets($cssEntry) reads the entry's direct package imports and reports each one no scanned view needs, counting what a needed stylesheet imports itself and foundation.css always; an entry that imports all.css is left alone. Opt-in, beside missingStylesheets() (plan step 46). Co-Authored-By: Claude Opus 5 (1M context) --- UPGRADE.md | 3 +- .../livewire-material-development/SKILL.md | 3 +- src/Testing/DesignGuard.php | 116 +++++++++++++++++- tests/Feature/DesignGuardTest.php | 14 +++ .../design-guard/stylesheets/unused.css | 5 + 5 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/design-guard/stylesheets/unused.css diff --git a/UPGRADE.md b/UPGRADE.md index 8471fd27..ef9c2af7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -170,7 +170,8 @@ the contrast toggle (``) and the Standard motion - **Retargeted:** `forbidColours([...])` keeps its signature and reports a left-out role where 2.0.0 writes one: `var(--md-sys-color-…)` in CSS or an inline `style`, an `md-ink-*` class, a component's `color`/`tone` prop. -- **New:** `missingStylesheets($cssEntry)` names each `@import` the views need; a `.css` file passed +- **New:** `missingStylesheets($cssEntry)` names each `@import` the views need, and + `unusedStylesheets($cssEntry)` each one they no longer do; a `.css` file passed to `scan()` is checked for literal values and off-scale media queries. Browser tests that assert widths switch at 640/1024/1280 now diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index c2e9ccfb..ae121e59 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -1118,6 +1118,7 @@ use NoNameWeb\LivewireMaterial\Testing\DesignGuard; it('uses only what compiles', function () { expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()]) ->missingStylesheets(resource_path('css/app.css')) + ->unusedStylesheets(resource_path('css/app.css')) ->forbidColours(['tertiary']) // roles this application's rules leave out ->violations())->toBe([]); }); @@ -1151,7 +1152,7 @@ It reads every path it is given and fails with `path:line` on unknown symbol nam In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, font size, weight, line height, letter spacing, easing or duration, and on a media query width off 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. -`missingStylesheets($cssEntry)` follows the entry's relative `@import`s through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too; `tailwindcss` or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or ``) and `->links()`; each missing stylesheet names its `@import` line once, and a tag the application shadows with its own component is reported instead — the application's component wins in Blade. It reads imports only: leave `resource_path('css')` out of `scan()` until the stylesheets are on tokens. `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds a pattern of your own. +`missingStylesheets($cssEntry)` follows the entry's relative `@import`s through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too; `tailwindcss` or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or ``) and `->links()`; each missing stylesheet names its `@import` line once, and a tag the application shadows with its own component is reported instead — the application's component wins in Blade. It reads imports only: leave `resource_path('css')` out of `scan()` until the stylesheets are on tokens. `unusedStylesheets($cssEntry)` is the other way round: a package stylesheet the entry imports directly that no scanned view needs, even through a needed stylesheet's own imports, named at its `@import` line to remove (an entry importing `all.css` is left alone). `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds a pattern of your own. ## Conventions diff --git a/src/Testing/DesignGuard.php b/src/Testing/DesignGuard.php index cd4aad61..fac3d32a 100644 --- a/src/Testing/DesignGuard.php +++ b/src/Testing/DesignGuard.php @@ -31,7 +31,9 @@ use Symfony\Component\Finder\Finder; * `->links()` needing `pagination.css`; each names the missing `@import` line once, at its * first use. A tag the application shadows with its own component of the same name is * reported instead — the application's component wins in Blade, so the package's - * stylesheet is moot. + * stylesheet is moot. `unusedStylesheets($cssEntry)` reports the other way: a package + * stylesheet the entry imports directly that no scanned view needs, not even through + * another needed stylesheet's imports (an entry importing `all.css` is left alone). * (iii) every `.css` file among the scanned paths, outside the package and excluding the * generated `material-scheme.css`: a literal colour, radius, shadow, font size, weight, * line height, letter spacing, easing or duration, and a media query at a width other @@ -262,6 +264,8 @@ class DesignGuard protected ?string $cssEntry = null; + protected ?string $unusedEntry = null; + /** @var array|null */ protected ?array $applicationClassesCache = null; @@ -325,6 +329,20 @@ class DesignGuard return $this; } + /** + * Check (ii) the other way round: each package stylesheet `$cssEntry` imports directly that no + * scanned Blade view needs — no package tag it renders, no `->links()`, and not reached through + * the imports of a stylesheet that is needed — named at its `@import` line, to remove. A + * component whose last use left the views otherwise keeps its CSS in every page. `foundation.css` + * is always needed; an entry that imports `all.css` has chosen everything and is not read. + */ + public function unusedStylesheets(string $cssEntry): static + { + $this->unusedEntry = $cssEntry; + + return $this; + } + /** * @return list */ @@ -336,6 +354,7 @@ class DesignGuard : []; $reported = []; + $needed = []; if ($this->cssEntry !== null && ! is_file($this->cssEntry)) { $violations[] = "{$this->cssEntry}:1 the CSS entry `missingStylesheets()` names does not exist"; @@ -390,6 +409,10 @@ class DesignGuard $violations[] = "{$where}:{$line} {$what}"; } } + + if ($this->unusedEntry !== null) { + $needed = [...$needed, ...$this->neededStylesheets($contents)]; + } } foreach (explode("\n", $contents) as $index => $text) { @@ -413,9 +436,92 @@ class DesignGuard } } + if ($this->unusedEntry !== null) { + $violations = [...$violations, ...$this->unusedStylesheetViolations($needed)]; + } + return $this->sortViolations([...$violations, ...$this->applicationCssViolations()]); } + /** + * The package stylesheets a Blade view's own tags and `->links()` calls need, before their + * imports are followed. + * + * @return list + */ + protected function neededStylesheets(string $contents): array + { + $needed = []; + + foreach ($this->packageTagUsages($contents) as [, $name, $spelling]) { + if ($spelling === 'plain' && $this->shadowedByApplication($name)) { + continue; + } + + if (($stylesheet = static::packageStylesheetFor($name)) !== null) { + $needed[] = $stylesheet; + } + } + + if ($this->paginationUsages($contents) !== []) { + $needed[] = static::packageCssRoot().'/components/pagination.css'; + } + + return $needed; + } + + /** + * @param list $needed + * @return list + */ + protected function unusedStylesheetViolations(array $needed): array + { + $entry = (string) $this->unusedEntry; + $real = realpath($entry); + + if ($real === false || ! is_file($real)) { + return ["{$entry}:1 the CSS entry `unusedStylesheets()` names does not exist"]; + } + + $root = static::packageCssRoot(); + // Comments out, line count kept; the import strings themselves stay readable. + $css = (string) preg_replace_callback('~/\*.*?\*/~s', fn (array $comment): string => str_repeat("\n", substr_count($comment[0], "\n")), (string) file_get_contents($real)); + $imports = []; + + preg_match_all('/@import\s+(?:url\(\s*)?([\'"])([^\'"]+)\1/i', $css, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + foreach ($matches as $match) { + $file = realpath(dirname($real).'/'.$match[2][0]); + + if ($file === false || ! static::isPackageFile($file)) { + continue; + } + + if ($file === $root.'/all.css') { + return []; + } + + $imports[] = [substr_count(substr($css, 0, $match[0][1]), "\n") + 1, $file]; + } + + $reached = array_fill_keys(Stylesheets::resolvedFiles([...array_unique($needed), $root.'/foundation.css']), true); + $violations = []; + + foreach ($imports as [$line, $file]) { + if (! isset($reached[$file])) { + $violations[] = sprintf( + '%s:%d `%s` is imported, but no scanned view renders a component that needs it — remove `@import \'%s\';`', + $this->relative($real), + $line, + static::packageRelativeName($file), + $this->importLineFrom($real, $file), + ); + } + } + + return $violations; + } + /** * `$violations` ("path:line message", `relative()`'s shape) sorted by path, then line * (numerically, so line 9 sits before line 10), then message — whichever check produced each @@ -1559,7 +1665,13 @@ class DesignGuard */ protected function importLine(string $file): string { - $entryDir = dirname((string) (realpath($this->cssEntry) ?: $this->cssEntry)); + return $this->importLineFrom((string) $this->cssEntry, $file); + } + + /** `importLine()` for an entry other than `missingStylesheets()`'s. */ + protected function importLineFrom(string $entry, string $file): string + { + $entryDir = dirname((string) (realpath($entry) ?: $entry)); $vendor = base_path('vendor/nonameweb/livewire-material/resources/css'); // A Composer path repository may symlink the package: the import still goes through vendor/. diff --git a/tests/Feature/DesignGuardTest.php b/tests/Feature/DesignGuardTest.php index ea02d0f9..46f11480 100644 --- a/tests/Feature/DesignGuardTest.php +++ b/tests/Feature/DesignGuardTest.php @@ -420,6 +420,20 @@ it('counts what an imported package stylesheet already brings in, through its ow expect($violations)->toBe([]); }); +it('names each stylesheet the entry imports that no scanned view needs, and leaves all.css alone', function () { + // dependencies.blade.php renders , and : menu.css is needed + // (and reached through split-button.css too); card.css and stack.css serve nothing on the page. + $entry = realpath(GUARD_FIXTURES.'/stylesheets/unused.css'); + + expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->unusedStylesheets($entry)->violations()))->toBe([ + "stylesheets/unused.css:4 `components/card.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/components/card.css';`", + "stylesheets/unused.css:5 `layout/stack.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/layout/stack.css';`", + ]) + // missingStylesheets() alone never reports an unused import. + ->and(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->missingStylesheets($entry)->violations())->toBe([]) + ->and(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->unusedStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/all.css'))->violations())->toBe([]); +}); + it('needs nothing more once the entry imports all.css', function () { $violations = DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views')) ->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/all.css')) diff --git a/tests/Fixtures/design-guard/stylesheets/unused.css b/tests/Fixtures/design-guard/stylesheets/unused.css new file mode 100644 index 00000000..62c3182b --- /dev/null +++ b/tests/Fixtures/design-guard/stylesheets/unused.css @@ -0,0 +1,5 @@ +@import '../../../../resources/css/foundation.css'; +@import '../../../../resources/css/components/split-button.css'; +@import '../../../../resources/css/components/menu.css'; +@import '../../../../resources/css/components/card.css'; +@import '../../../../resources/css/layout/stack.css';