feat(coverage): class_references + scan_archive — each template-bearing file's class tokens (static class=/className=, Vue :class object/array/ternary, React className={…}, Svelte class:x) read in the same tar walk as definitions; the CSS consumer map's extractor (milestone 302 step 1, #2934)
CI & Build / Plugin hooks (push) Failing after 1s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 29s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 29s
CI & Build / Plugin hooks (push) Failing after 1s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 29s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 29s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -269,6 +269,91 @@ def scoped_definitions(path: str, text: str, defs: list[Definition]) -> set[tupl
|
||||
return out
|
||||
|
||||
|
||||
# --- template class references: the CSS consumer map (milestone 302) ---------
|
||||
|
||||
# Files whose MARKUP can consume a class. Styling consumers are templates —
|
||||
# `querySelector('.x')` / classList in scripts are deliberately not read in
|
||||
# v1 (note 2917: watch CSS by name, by recipe, by token and by what uses it;
|
||||
# "what uses it" is the template).
|
||||
_TEMPLATE_SUFFIXES = (
|
||||
".vue", ".html", ".htm", ".jsx", ".tsx", ".js", ".ts", ".svelte", ".astro",
|
||||
)
|
||||
_CLASS_TOKEN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
|
||||
# Static: class="a b" / class='a b' / className="a b". The lookbehind keeps
|
||||
# `:class=`, `v-bind:class=`, `data-class=` and `headerClass=` out of the
|
||||
# static form (the Vue/React dynamic forms are read below; the others are
|
||||
# not class attributes).
|
||||
_STATIC_CLASS_RE = re.compile(
|
||||
r"""(?<![:\w.-])(?:class|className)\s*=\s*(?:"([^"]*)"|'([^']*)')"""
|
||||
)
|
||||
# Dynamic: Vue `:class="…"` / `v-bind:class="…"`, React `className={…}` (one
|
||||
# level of nested braces — an object literal inside the expression).
|
||||
_DYNAMIC_CLASS_RE = re.compile(
|
||||
r""":class\s*=\s*(?:"([^"]*)"|'([^']*)')"""
|
||||
r"""|(?<![:\w.-])className\s*=\s*\{((?:[^{}]|\{[^{}]*\})*)\}"""
|
||||
)
|
||||
# Svelte's directive form: class:active={cond}.
|
||||
_SVELTE_CLASS_RE = re.compile(r"(?<![:\w.-])class:([A-Za-z_][A-Za-z0-9_-]*)\s*=")
|
||||
# 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"""'([^'\\]*)'|"([^"\\]*)"|`([^`]*)`""")
|
||||
_OBJ_SPAN_RE = re.compile(r"\{([^{}]*)\}")
|
||||
_OBJ_KEY_RE = re.compile(r"(?:^|[{,\s])([A-Za-z_][A-Za-z0-9_-]*)\s*:(?!:)")
|
||||
_TEMPLATE_HOLE_RE = re.compile(r"\$\{[^}]*\}")
|
||||
# A server-side / mustache interpolation inside a static value (`{{ cls }}`,
|
||||
# `{% if %}`): unknowable at read time, contributes no token.
|
||||
_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)]
|
||||
|
||||
|
||||
def _dynamic_class_tokens(expr: str) -> list[str]:
|
||||
"""Class tokens named by a dynamic class expression: every string
|
||||
literal's tokens (a template literal's static text only — its `${…}`
|
||||
holes are unknowable) and the bare keys of object literals. Bare
|
||||
identifiers elsewhere (`cond ? clsA : clsB`) are variables, not names."""
|
||||
out: list[str] = []
|
||||
for m in _STR_LIT_RE.finditer(expr):
|
||||
literal = m.group(1) if m.group(1) is not None else (
|
||||
m.group(2) if m.group(2) is not None else m.group(3)
|
||||
)
|
||||
if m.group(3) is not None:
|
||||
literal = _TEMPLATE_HOLE_RE.sub(" ", literal)
|
||||
out.extend(_class_tokens(literal))
|
||||
for span in _OBJ_SPAN_RE.finditer(expr):
|
||||
# Quoted keys were read as literals above; bare keys here.
|
||||
body = _STR_LIT_RE.sub(" ", span.group(1))
|
||||
out.extend(k for k in _OBJ_KEY_RE.findall(body) if _CLASS_TOKEN_RE.match(k))
|
||||
return out
|
||||
|
||||
|
||||
def class_references(path: str, text: str) -> dict[str, int]:
|
||||
"""class token → how many times this file's markup names it. Empty for
|
||||
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')`."""
|
||||
if not (path or "").lower().endswith(_TEMPLATE_SUFFIXES):
|
||||
return {}
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
def bump(tokens: list[str]) -> None:
|
||||
for t in tokens:
|
||||
counts[t] = counts.get(t, 0) + 1
|
||||
|
||||
for m in _STATIC_CLASS_RE.finditer(text):
|
||||
bump(_class_tokens(m.group(1) if m.group(1) is not None else m.group(2)))
|
||||
for m in _DYNAMIC_CLASS_RE.finditer(text):
|
||||
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)])
|
||||
return counts
|
||||
|
||||
|
||||
def extract_shapes(text: str) -> list[tuple[str, str]]:
|
||||
"""Every (kind, name) this text DEFINES — kind is "css" or "sym".
|
||||
|
||||
@@ -307,14 +392,31 @@ def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
|
||||
return [(d.path, d.kind, d.name) for d in definitions_from_archive(blob)]
|
||||
|
||||
|
||||
class ArchiveScan(NamedTuple):
|
||||
"""One walk of a repo tarball: what each file DEFINES (the ledger rows)
|
||||
and which class names each file's markup REFERENCES (the CSS consumer
|
||||
map, milestone 302) — read together because the bodies are in hand once."""
|
||||
|
||||
definitions: list[ArchiveShape]
|
||||
references: dict[str, dict[str, int]] # path → class token → count
|
||||
|
||||
|
||||
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
|
||||
"""Every definition in a repo tarball, with its fingerprint and body.
|
||||
"""Every definition in a repo tarball, with its fingerprint and body —
|
||||
the definitions half of scan_archive."""
|
||||
return scan_archive(blob).definitions
|
||||
|
||||
|
||||
def scan_archive(blob: bytes) -> ArchiveScan:
|
||||
"""Every definition in a repo tarball, with its fingerprint and body,
|
||||
plus each template-bearing file's class references.
|
||||
|
||||
Forge archives wrap content in a single top-level directory (repo-ref/);
|
||||
that component is stripped so paths match recorded snippet locations,
|
||||
which are repo-relative. Non-UTF-8 files are binaries and skipped.
|
||||
"""
|
||||
shapes: list[ArchiveShape] = []
|
||||
references: dict[str, dict[str, int]] = {}
|
||||
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
|
||||
for member in tar:
|
||||
if not member.isfile() or "/" not in member.name:
|
||||
@@ -338,7 +440,10 @@ def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
|
||||
)
|
||||
for d in defs
|
||||
)
|
||||
return shapes
|
||||
refs = class_references(path, text)
|
||||
if refs:
|
||||
references[path] = refs
|
||||
return ArchiveScan(shapes, references)
|
||||
|
||||
|
||||
# --- matching shapes against recorded locations ------------------------------
|
||||
|
||||
Reference in New Issue
Block a user