CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 34s
The acceptance greps found what the per-area passes missed: - src/scribe: "Phase 7/8" removal notes in projects, settings and user_profile; a paragraph promising an auto-summary side effect that no longer exists; user_profile's claim that "upcoming MCP profile tools" read the profile, which nothing does; an instance count and instance rule titles in plugin_context; "MILESTONE 323 DECIDED" in rulebooks. - scripts/: check_plugin's header and inline comments were a history of the incidents behind each guard, with issue numbers in two failure messages; check_dangling_styles named this app's classes as its examples; check_design_tokens, measure_duplication and mint_plugin_version cited issues and checklists. - frontend/src: forty-odd comments with four-digit issue numbers that the earlier frontend scan filtered out as hex colours, plus "used to" and "Phase N" notes. Kept: illustrative ids (`#4181` as a bare-id example, `PR #198`), migration Create Date headers and one migration's dated data floor, and the plugin skills' worked examples of how to cite a record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
7.4 KiB
Python
184 lines
7.4 KiB
Python
#!/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:hover` outliving `.btn` leaves a link in raw
|
|
browser blue.
|
|
|
|
2. The parent's layout rule is deleted while the children keep theirs.
|
|
A header that is a flex row loses its rule; its children still declare
|
|
`flex: 1` and `flex-shrink: 0`. Without the parent they stack vertically,
|
|
and a row 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 produces false
|
|
reports, 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. A list whose entire rule is gone
|
|
renders with browser bullets, and only reading the file finds it.
|
|
|
|
INSTANCE-AGNOSTIC. 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"<style[^>]*>(.*?)</style>", 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'(?<![:\w-])class="([^"{}\[\]]*)"')
|
|
SELECTOR = re.compile(r"([^{}]+)\{")
|
|
CLASS_TOKEN = re.compile(r"\.([A-Za-z][\w-]*)")
|
|
|
|
|
|
def shared_classes(sheets: list[pathlib.Path]) -> 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("<style")[0]
|
|
css = "\n".join(CSS_COMMENT.sub("", block) for block in STYLE_BLOCK.findall(source))
|
|
if not css.strip():
|
|
return []
|
|
|
|
base_sets = base_class_sets(css)
|
|
mentioned = set(CLASS_TOKEN.findall(css))
|
|
|
|
findings: list[tuple[str, list[str]]] = []
|
|
for attr in sorted(set(STATIC_CLASS.findall(template))):
|
|
names = [n for n in attr.split() if re.fullmatch(r"[A-Za-z][\w-]*", n)]
|
|
if not names:
|
|
continue
|
|
carried = set(names)
|
|
if any(carried >= 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())
|