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