Dead CSS deleted, and the instrument that found it sharpened (#2962, #2970) #129

Merged
bvandeusen merged 5 commits from dev into main 2026-08-23 21:34:18 -04:00
6 changed files with 164 additions and 15 deletions
Showing only changes of commit df18e897af - Show all commits
+5 -1
View File
@@ -125,7 +125,11 @@ the last sweep left it. Three surfaces say so without anyone running an audit
templates, one recipe → derive; one template each, different purposes → templates, one recipe → derive; one template each, different purposes →
dismiss. `list_shapes(flag="unused-css")` is the map's negative space — dismiss. `list_shapes(flag="unused-css")` is the map's negative space —
css rules no template names, a deletion candidate to look at, never css rules no template names, a deletion candidate to look at, never
auto-deleted (a class built at runtime is invisible to the map). auto-deleted. The map reads the two class forms templates don't spell out
— a `<Transition name="x">`'s generated classes, and the prefix of a
concatenated name (`` `status-${s}` `` credits every `status-…` rule) —
so what it flags is worth reading. What it still cannot see is a name
assembled in a script (`classList.add`), so confirm before deleting.
After the one-time pay-down the derive queue reads empty; anything in it After the one-time pay-down the derive queue reads empty; anything in it
afterwards is drift of the moment, and the hint already said so at the write. afterwards is drift of the moment, and the hint already said so at the write.
+6 -4
View File
@@ -118,8 +118,9 @@ async def list_shapes(
whose body changed since judged (the judgment stands; confirm whose body changed since judged (the judgment stands; confirm
it again with classify_shapes, or re-judge); "unused-css" it again with classify_shapes, or re-judge); "unused-css"
(milestone 302): live css rules no file's markup names — a (milestone 302): live css rules no file's markup names — a
deletion candidate to look at, never auto-deleted (the map reads deletion candidate to look at, never auto-deleted. Transition
templates only; a class built at runtime is invisible to it). classes and concatenated names are read (#2970), so the list is
worth acting on; a name assembled in a script still is not.
Returns {"shapes": [...], "total": N} — total counts every match, not Returns {"shapes": [...], "total": N} — total counts every match, not
just this page. Every css row carries `used_by` {count, paths} — the just this page. Every css row carries `used_by` {count, paths} — the
@@ -305,8 +306,9 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
confirmation), `derive_groups` (the biggest repeats-with-no-canon confirmation), `derive_groups` (the biggest repeats-with-no-canon
families, each css one with `consumers` — the files whose markup families, each css one with `consumers` — the files whose markup
render it, milestone 302), `unused_css` (css rules no template names; render it, milestone 302), `unused_css` (css rules no template names
None where the map has no evidence of templates), `derive_new` (copies counting a `<Transition name=>`'s generated classes and concatenated
names as named, #2970; None where the map has no evidence of templates), `derive_new` (copies
that joined a family since the previous that joined a family since the previous
refresh — the drift to act on now: derive the canon, don't queue an refresh — the drift to act on now: derive the canon, don't queue an
audit), `proposer` (what this refresh examined) — plus audit), `proposer` (what this refresh examined) — plus
+54 -3
View File
@@ -294,6 +294,34 @@ _DYNAMIC_CLASS_RE = re.compile(
) )
# Svelte's directive form: class:active={cond}. # Svelte's directive form: class:active={cond}.
_SVELTE_CLASS_RE = re.compile(r"(?<![:\w.-])class:([A-Za-z_][A-Za-z0-9_-]*)\s*=") _SVELTE_CLASS_RE = re.compile(r"(?<![:\w.-])class:([A-Za-z_][A-Za-z0-9_-]*)\s*=")
# Transition classes are applied by the FRAMEWORK, never written in markup:
# <Transition name="toast"> makes Vue add .toast-enter-active et al at
# runtime, and React's <CSSTransition classNames="fade"> does the same. A
# reader of `class=` attributes alone therefore calls every one of those
# rules unused, which is a false positive no amount of care in the
# stylesheet can avoid (#2970). A dynamic `:name="…"` stays unknowable.
_TRANSITION_NAME_RE = re.compile(
r"""<\s*[Tt]ransition(?:-[Gg]roup|Group)?\b[^>]*?(?<![:\w.-])name\s*=\s*"""
r"""(?:"([^"]*)"|'([^']*)')"""
r"""|(?<![:\w.-])classNames\s*=\s*(?:"([^"]*)"|'([^']*)')"""
)
# The union of what Vue 3, Vue 2 and React CSSTransition generate. Naming a
# class that no rule defines costs nothing — it resolves to no row — so the
# union is safer than guessing the framework from the file.
_TRANSITION_SUFFIXES = (
"-enter", "-enter-from", "-enter-active", "-enter-to", "-enter-done",
"-leave", "-leave-from", "-leave-active", "-leave-to",
"-exit", "-exit-active", "-exit-done",
"-appear", "-appear-from", "-appear-active", "-appear-to", "-appear-done",
"-move",
)
# A name built by concatenation — `status-${s}`, 'pri-' + p, class="c-{{ v }}"
# — leaves its static head behind once the hole is blanked. That head is a
# PREFIX reference, spelled `status-*`: "*" cannot occur in a class token, so
# the marker rides the plain token dict without a schema change. Needs a real
# name before the separator; `a-` or a bare `-` says nothing worth matching.
_PREFIX_MIN_STEM = 2
PREFIX_MARK = "*"
# Inside a dynamic expression: string literals (ternary arms, array items, # Inside a dynamic expression: string literals (ternary arms, array items,
# quoted object keys) and the bare keys of object literals. # quoted object keys) and the bare keys of object literals.
_STR_LIT_RE = re.compile(r"""'([^'\\]*)'|"([^"\\]*)"|`([^`]*)`""") _STR_LIT_RE = re.compile(r"""'([^'\\]*)'|"([^"\\]*)"|`([^`]*)`""")
@@ -307,8 +335,20 @@ _MUSTACHE_RE = re.compile(r"\{[{%][^}]*[}%]\}")
def _class_tokens(value: str) -> list[str]: def _class_tokens(value: str) -> list[str]:
"""The class tokens of a static attribute value: whitespace-split, only """The class tokens of a static attribute value: whitespace-split, only
well-formed names (an interpolation like `{{ cls }}` contributes none).""" well-formed names. An interpolation (`{{ cls }}`, `${cls}`) is blanked
return [t for t in _MUSTACHE_RE.sub(" ", value).split() if _CLASS_TOKEN_RE.match(t)] before the split, so a name built around one leaves its static head —
`status-` from `status-{{ s }}` — which is emitted as the prefix
reference `status-*` rather than as a class nothing is called."""
out: list[str] = []
for t in _MUSTACHE_RE.sub(" ", value).split():
if not _CLASS_TOKEN_RE.match(t):
continue
if t.endswith(("-", "_")):
if len(t.rstrip("-_")) >= _PREFIX_MIN_STEM:
out.append(t + PREFIX_MARK)
continue
out.append(t)
return out
def _dynamic_class_tokens(expr: str) -> list[str]: def _dynamic_class_tokens(expr: str) -> list[str]:
@@ -336,7 +376,13 @@ def class_references(path: str, text: str) -> dict[str, int]:
files that carry no markup (by suffix). Reads the static `class=` / files that carry no markup (by suffix). Reads the static `class=` /
`className=` attributes, the Vue and React dynamic forms and Svelte's `className=` attributes, the Vue and React dynamic forms and Svelte's
`class:x` directive; never a CSS selector (`.x {` is a definition, read `class:x` directive; never a CSS selector (`.x {` is a definition, read
by extract_definitions) and never a script's `querySelector('.x')`.""" by extract_definitions) and never a script's `querySelector('.x')`.
Two forms name classes without spelling them out, and both are read
(#2970): a transition `name=` stands for every class the framework
generates from it, and a concatenated name contributes the prefix
reference `head-*` — which resolve_consumers matches against every row
whose symbol starts with `head-`."""
if not (path or "").lower().endswith(_TEMPLATE_SUFFIXES): if not (path or "").lower().endswith(_TEMPLATE_SUFFIXES):
return {} return {}
counts: dict[str, int] = {} counts: dict[str, int] = {}
@@ -351,6 +397,11 @@ def class_references(path: str, text: str) -> dict[str, int]:
expr = next((g for g in m.groups() if g is not None), "") expr = next((g for g in m.groups() if g is not None), "")
bump(_dynamic_class_tokens(expr)) bump(_dynamic_class_tokens(expr))
bump([m.group(1) for m in _SVELTE_CLASS_RE.finditer(text)]) bump([m.group(1) for m in _SVELTE_CLASS_RE.finditer(text)])
for m in _TRANSITION_NAME_RE.finditer(text):
name = next((g for g in m.groups() if g is not None), "").strip()
if not _CLASS_TOKEN_RE.match(name):
continue
bump([name + suffix for suffix in _TRANSITION_SUFFIXES])
return counts return counts
+26 -7
View File
@@ -279,20 +279,39 @@ def resolve_consumers(
of that name when F defines it (a scoped rule is consumed by its own of that name when F defines it (a scoped rule is consumed by its own
template); otherwise to every other file's row of that name — a shared template); otherwise to every other file's row of that name — a shared
sheet, or, when several files define it, all of them: the map says sheet, or, when several files define it, all of them: the map says
"ambiguous" by fanning out rather than guessing one.""" "ambiguous" by fanning out rather than guessing one.
A token ending in ``PREFIX_MARK`` is a PREFIX reference (#2970) — the
static head of a name the template concatenates, `status-*` from
`` `status-${s}` ``. It stands for every row whose symbol starts with
that head, each resolved by the same own-file-else-fan-out rule. The
template cannot tell us WHICH of them it built, so the map credits all
of them rather than calling live rules unused."""
# Lazy, like the extract_definitions import below: coverage reaches into
# this module during a refresh, so neither may import the other at load.
from scribe.services.coverage import PREFIX_MARK
by_symbol: dict[str, list[tuple[int, str]]] = {} by_symbol: dict[str, list[tuple[int, str]]] = {}
for sid, path, symbol in css_rows: for sid, path, symbol in css_rows:
by_symbol.setdefault(symbol, []).append((sid, path)) by_symbol.setdefault(symbol, []).append((sid, path))
out: dict[tuple[int, str], int] = {} out: dict[tuple[int, str], int] = {}
def credit(rows: list[tuple[int, str]], consumer: str, count: int) -> None:
own = [sid for sid, path in rows if path == consumer]
for sid in own or [sid for sid, _path in rows]:
out[(sid, consumer)] = out.get((sid, consumer), 0) + int(count)
for consumer, tokens in references.items(): for consumer, tokens in references.items():
for token, count in tokens.items(): for token, count in tokens.items():
rows = by_symbol.get(token) if token.endswith(PREFIX_MARK):
if not rows: head = token[: -len(PREFIX_MARK)]
for symbol, rows in by_symbol.items():
if symbol.startswith(head):
credit(rows, consumer, count)
continue continue
own = [sid for sid, path in rows if path == consumer] rows = by_symbol.get(token)
targets = own or [sid for sid, _path in rows] if rows:
for sid in targets: credit(rows, consumer, count)
out[(sid, consumer)] = out.get((sid, consumer), 0) + int(count)
return out return out
+39
View File
@@ -152,6 +152,45 @@ def test_class_references_reads_vue_static_and_dynamic_forms_only():
} }
def test_class_references_reads_framework_transition_names():
"""A transition `name=` is a class reference: the framework applies
`.toast-enter-active` and friends at runtime, so a stylesheet that
defines them is consumed even though no template spells one out (#2970).
Every spelling of the tag counts; a bound `:name` stays unknowable."""
refs = class_references(
"a/T.vue",
'<transition-group name="toast"><div class="toast-item" /></transition-group>',
)
for suffix in ("-enter-from", "-enter-active", "-leave-to", "-move"):
assert refs["toast" + suffix] == 1, suffix
assert refs["toast-item"] == 1 # the static attribute still counts
assert "peek-enter-active" in class_references("a/T.vue", '<Transition name="peek">')
assert "g-move" in class_references("a/T.vue", '<TransitionGroup name="g">')
# React's CSSTransition names the same idea with a different suffix set
react = class_references("a/T.jsx", '<CSSTransition classNames="fade">')
assert {"fade-enter-active", "fade-exit-active", "fade-exit-done"} <= set(react)
# A bound name is a variable, not a name we can read
assert class_references("a/T.vue", '<Transition :name="dyn">') == {}
def test_class_references_reads_a_concatenated_name_as_a_prefix():
"""`status-${s}` cannot be resolved to one class, but its static head is
real information: it is emitted as the prefix reference `status-*` so
the rows it could have built are not reported unused (#2970). A head too
short to mean anything, or a bare separator, says nothing."""
vue = (
'<div :class="`status-${task.status}`" />'
"<span :class=\"['pri-' + p]\" />"
'<b class="a-" /><u class="-" />'
)
assert class_references("a/P.vue", vue) == {"status-*": 1, "pri-*": 1}
# a server template interpolating into the middle of a name, same reading
assert class_references("t/p.html", '<i class="card-{{ v }}" />') == {"card-*": 1}
# an ordinary name never picks up the marker
assert class_references("a/P.vue", '<div class="page-header" />') == {"page-header": 1}
def test_class_references_reads_react_svelte_and_server_templates(): def test_class_references_reads_react_svelte_and_server_templates():
tsx = ( tsx = (
"export function X({ on }: { on: boolean }) {\n" "export function X({ on }: { on: boolean }) {\n"
+34
View File
@@ -473,6 +473,40 @@ def test_resolve_consumers_prefers_the_own_file_and_fans_out_for_shared_names():
assert all(sid != 6 for sid, _ in resolve_consumers(css_rows, refs)) assert all(sid != 6 for sid, _ in resolve_consumers(css_rows, refs))
def test_resolve_consumers_credits_every_row_a_prefix_could_have_built():
"""#2970: `status-${s}` names a class the map cannot pin down, so the
prefix reference `status-*` credits every row whose symbol starts with
the head — each under the same own-file-else-fan-out rule. Crediting all
of them is the honest reading: the alternative is calling live rules
unused, which is what the flag existed to avoid."""
from scribe.services.shape_ledger import resolve_consumers
css_rows = [
(1, "assets/app.css", "status-done"),
(2, "assets/app.css", "status-todo"),
(3, "v/Board.vue", "status-done"), # a scoped copy of one of them
(4, "assets/app.css", "btn-primary"),
(5, "assets/anim.css", "toast-enter-active"),
]
# A file that defines none of them fans out across every match.
assert resolve_consumers(css_rows, {"v/List.vue": {"status-*": 2}}) == {
(1, "v/List.vue"): 2, (2, "v/List.vue"): 2, (3, "v/List.vue"): 2,
}
# A file that DOES define one keeps the own-file rule, per symbol: its own
# status-done row, and the shared status-todo it does not define.
assert resolve_consumers(css_rows, {"v/Board.vue": {"status-*": 1}}) == {
(3, "v/Board.vue"): 1, (2, "v/Board.vue"): 1,
}
# A prefix that matches nothing is silent, and exact tokens are untouched.
assert resolve_consumers(css_rows, {"v/X.vue": {"zz-*": 1}}) == {}
assert resolve_consumers(css_rows, {"v/X.vue": {"btn-primary": 3}}) == {
(4, "v/X.vue"): 3,
}
# A transition class resolves exactly, like any other name.
assert resolve_consumers(css_rows, {"c/Toast.vue": {"toast-enter-active": 1}}) == {
(5, "c/Toast.vue"): 1,
}
def test_consumer_edges_table_cascades_with_the_shape(): def test_consumer_edges_table_cascades_with_the_shape():
from scribe.models import Base from scribe.models import Base
from scribe.models.code_shape import CONSUMER_BASES, CodeShapeConsumer from scribe.models.code_shape import CONSUMER_BASES, CodeShapeConsumer