feat(ledger): the consumer map reads transition names and concatenated prefixes (#2970)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Failing after 10s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 23s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Failing after 10s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 23s
Paying down #2962 measured `flag="unused-css"` against a hand audit and found it had a permanent false-positive floor: roughly sixty classes it called unused are alive and always would be, because two ordinary authoring forms produce names no reader of `class=` attributes can see. A flag whose list you cannot act on line by line is worse than no flag — act on it and you delete live UI. - A transition `name=` IS a class reference. `<Transition name="toast">` makes Vue apply `.toast-enter-active` and its siblings at runtime, and React's `<CSSTransition classNames="fade">` does the same with a different suffix set. Every spelling of the tag is read (`Transition`, `TransitionGroup`, `transition-group`), and the emitted suffix set is the union of Vue 3, Vue 2 and React: naming a class no rule defines costs nothing, since it resolves to no row. A bound `:name` stays unknowable. - A concatenated name contributes its static head as a PREFIX reference. `` `status-${s}` ``, `'pri-' + p` and `class="card-{{ v }}"` all leave a head behind once the hole is blanked — and `_CLASS_TOKEN_RE` accepts a trailing hyphen, so until now the extractor emitted a junk token `"status-"` that matched nothing. It is now `status-*`, and resolve_consumers credits every row whose symbol starts with that head, each under the same own-file-else-fan-out rule as an exact token. `*` cannot occur in a class token, so the marker rides the existing dict[str, int] with no schema change. A head shorter than two characters says nothing and is dropped. Crediting every candidate row is the honest reading: the template genuinely does not say which one it built, and the alternative is reporting live rules as dead. What the map still cannot see is a name assembled in a script — `classList.add` — which stays deliberately out of scope; the skill, the `flag=` docstring and the refresh payload docs all say so now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -294,6 +294,34 @@ _DYNAMIC_CLASS_RE = re.compile(
|
||||
)
|
||||
# Svelte's directive form: class:active={cond}.
|
||||
_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,
|
||||
# quoted object keys) and the bare keys of object literals.
|
||||
_STR_LIT_RE = re.compile(r"""'([^'\\]*)'|"([^"\\]*)"|`([^`]*)`""")
|
||||
@@ -307,8 +335,20 @@ _MUSTACHE_RE = re.compile(r"\{[{%][^}]*[}%]\}")
|
||||
|
||||
def _class_tokens(value: str) -> list[str]:
|
||||
"""The class tokens of a static attribute value: whitespace-split, only
|
||||
well-formed names (an interpolation like `{{ cls }}` contributes none)."""
|
||||
return [t for t in _MUSTACHE_RE.sub(" ", value).split() if _CLASS_TOKEN_RE.match(t)]
|
||||
well-formed names. An interpolation (`{{ cls }}`, `${cls}`) is blanked
|
||||
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]:
|
||||
@@ -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=` /
|
||||
`className=` attributes, the Vue and React dynamic forms and Svelte's
|
||||
`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):
|
||||
return {}
|
||||
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), "")
|
||||
bump(_dynamic_class_tokens(expr))
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user