#!/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("