#!/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, and a compound's whole class SET is what it requires: `.pane.empty` and `td.num` are base rules for the element carrying those classes, not modifiers. Reading them as modifiers cost this check four false reports on its first run, and a check with false reports is one that gets skimmed. 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. Where a wrapper is bare on purpose, say so in a comment beside its descendant rules — the remaining reports here all carry one, so a new entry means something changed. KNOWN BLIND SPOT: a class with NO rule anywhere is invisible to this, because it cannot be told from a semantic-only hook. `.systems-list` had lost its entire rule and was rendering with browser bullets; it was found by reading the file next to a class that WAS half-styled, not by this check. 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 base_class_sets(css: str) -> list[frozenset[str]]: """Class combinations 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 state — but in `.panel .row` it is `.row`, a base. A compound may carry more than one class, and a type selector alongside them. `.pane.empty` and `td.num` are both base rules for the element that matches, so each is recorded as the SET of classes it requires; an element is styled when it carries all of them. Recording the classes individually instead would clear `.pane` everywhere on the strength of a rule that only ever applies with `.empty` — precision matters more here than reach, since a missed base is a false report and a wrong one is a defect gone quiet. A compound with no class at all (`ul`, `li`) is skipped: it styles by tag, which this cannot verify without parsing the template's elements, and an empty set would clear every element in the file. """ out: list[frozenset[str]] = [] 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] # A pseudo-class, pseudo-element or attribute selector makes it a # state or a variant, not the element's base appearance. if ":" in last or "[" in last: continue names = CLASS_TOKEN.findall(last) # Everything outside the class tokens must be a bare type selector. if names and re.fullmatch(r"[A-Za-z][\w-]*|\*|", CLASS_TOKEN.sub("", last)): out.append(frozenset(names)) return out def scan(path: pathlib.Path, shared: set[str]) -> list[tuple[str, list[str]]]: source = path.read_text() template = source.split("= required for required in base_sets): continue if any(n in shared for n in names): continue dangling = [n for n in names if n in mentioned] if dangling: findings.append((attr, dangling)) return findings def main() -> 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())