#!/usr/bin/env python3 """Find elements whose classes have no base rule — the dangling-selector bug. THE FAILURE THIS CATCHES Deleting a CSS rule from a scoped stylesheet is not the local edit it looks like. Three ways it goes wrong, all of them silent: 1. A rule is deleted and its `:hover` / modifier survives. The selector still exists, so nothing reads as unused, but the element renders with no base styling at all. `.btn-workspace:hover` outlived `.btn-workspace` and a router-link rendered as raw browser blue for days. 2. The parent's layout rule is deleted while the children keep theirs. `.milestone-header` was a flex row; its children still declare `flex: 1` and `flex-shrink: 0`. Without the parent they stack vertically, and a milestone that was one line becomes five. Nothing errors — the page just wastes space, which reads as a design decision. 3. A rule is removed from a comma-separated group, leaving `.a,` dangling in front of the next rule and swallowing it. That one at least has a brace-balance tell; these two do not. None of it is visible to `vue-tsc`, which is the frontend's entire check. A dead style typechecks perfectly. WHAT IT REPORTS An element whose every static class is styled NOWHERE as a base rule, while at least one of them appears in the file's CSS. That conjunction is the signal: a class nobody styles is ordinary (a hook for a test, a semantic label), and a class with only modifier rules is a deletion that went half-way. Descendant selectors count as a base — `.panel .row {}` styles `.row` — because from the element's side there is no difference. Only the LAST compound of a selector is what it styles. REPORT, NOT FAIL. Bare wrappers with no styling of their own are legitimate, so this cannot be a gate without a suppression mechanism nobody would maintain. A count that grows is the signal to look. INSTANCE-AGNOSTIC (rule #115). Nothing here knows a class name, a component, or a convention; point it at any Vue tree. """ from __future__ import annotations import argparse import pathlib import re import sys STYLE_BLOCK = re.compile(r"]*>(.*?)", re.S) CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S) # `class="a b"` only — never `:class="[...]"`, whose value is an expression. # The negative lookbehind is the whole point: a bound class list mentions names # that a static parse would misread as the element's only classes. STATIC_CLASS = re.compile(r'(? set[str]: """Class names any global stylesheet defines — a base rule from elsewhere.""" names: set[str] = set() for sheet in sheets: if sheet.exists(): names |= set(CLASS_TOKEN.findall(sheet.read_text())) return names def based_classes(css: str) -> set[str]: """Classes this stylesheet gives a base rule to. The last compound of a selector is what the rule styles: in `.panel .row:hover` that is `.row:hover`, a modifier — but in `.panel .row` it is `.row`, a base. So a selector qualifies only when its final compound is a lone class with nothing appended. """ out: set[str] = set() for selector in SELECTOR.findall(css): for part in selector.split(","): part = part.strip() if not part or part.startswith("@"): continue last = re.split(r"[\s>+~]+", part)[-1] match = BARE_CLASS.fullmatch(last) if match: out.add(match.group(1)) return out def scan(path: pathlib.Path, shared: set[str]) -> list[tuple[str, list[str]]]: source = path.read_text() template = source.split(" int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", default="frontend/src", help="tree of .vue files") parser.add_argument( "--shared", action="append", default=None, help="global stylesheet whose classes count as a base rule (repeatable)", ) args = parser.parse_args() root = pathlib.Path(args.root) if not root.exists(): print(f"{root}: no such directory", file=sys.stderr) return 2 sheets = [pathlib.Path(s) for s in (args.shared or [])] if not sheets: sheets = sorted(root.glob("assets/*.css")) shared = shared_classes(sheets) total = 0 for path in sorted(root.rglob("*.vue")): for attr, dangling in scan(path, shared): total += 1 print(f'{path}: class="{attr}" — styled but never based: {", ".join(dangling)}') print() if total: print( f"REPORT: {total} element(s) whose classes carry modifier rules but no base " f"rule. Each is either a deleted rule that left its :hover behind, or a " f"deliberately bare wrapper." ) else: print("OK — every styled class has a base rule.") return 0 if __name__ == "__main__": raise SystemExit(main())