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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:57:13 -04:00
co-authored by Claude Fable 5
parent 31383bcebe
commit dffbf43d84
2 changed files with 184 additions and 2 deletions
+107 -2
View File
@@ -269,6 +269,91 @@ def scoped_definitions(path: str, text: str, defs: list[Definition]) -> set[tupl
return out 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]]: def extract_shapes(text: str) -> list[tuple[str, str]]:
"""Every (kind, name) this text DEFINES — kind is "css" or "sym". """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)] 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]: 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/); Forge archives wrap content in a single top-level directory (repo-ref/);
that component is stripped so paths match recorded snippet locations, that component is stripped so paths match recorded snippet locations,
which are repo-relative. Non-UTF-8 files are binaries and skipped. which are repo-relative. Non-UTF-8 files are binaries and skipped.
""" """
shapes: list[ArchiveShape] = [] shapes: list[ArchiveShape] = []
references: dict[str, dict[str, int]] = {}
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar: with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
for member in tar: for member in tar:
if not member.isfile() or "/" not in member.name: 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 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 ------------------------------ # --- matching shapes against recorded locations ------------------------------
+77
View File
@@ -16,7 +16,10 @@ import pytest
import pytest_asyncio import pytest_asyncio
from scribe.services.coverage import ( from scribe.services.coverage import (
ArchiveScan,
class_references,
coverage_line, coverage_line,
scan_archive,
extract_shapes, extract_shapes,
largest_gaps, largest_gaps,
scannable, scannable,
@@ -118,6 +121,80 @@ def test_shapes_from_archive_strips_the_wrapper_and_gates_files():
assert shapes_from_archive(_tarball(TREE)) == TREE_SHAPES assert shapes_from_archive(_tarball(TREE)) == TREE_SHAPES
# --- unit: template class references — the CSS consumer map (milestone 302) --
def test_class_references_reads_vue_static_and_dynamic_forms_only():
"""A template's class attributes name the classes it consumes: the static
`class=`, the Vue dynamic object/array/ternary forms (string literals and
bare object keys), never a selector in <style>, a `class Foo` in
<script>, a `querySelector('.x')`, or a look-alike attribute."""
vue = (
"<template>\n"
' <div class="card card--wide" :class="{ active: isOpen, \'is-error\': err }">\n'
' <span :class="[ \'pill\', cond ? \'pill-on\' : \'pill-off\', other ]" />\n'
' <p class="card" v-bind:class="open ? openCls : \'closed\'">{{ t }}</p>\n'
' <i data-class="nope" headerClass="nope2" />\n'
" </div>\n"
"</template>\n"
'<script setup lang="ts">\n'
"class Foo {}\n"
"const el = document.querySelector('.zap')\n"
"</script>\n"
"<style scoped>\n"
".card { color: red; }\n"
".zap { color: blue; }\n"
"</style>\n"
)
assert class_references("a/B.vue", vue) == {
"card": 2, "card--wide": 1, "active": 1, "is-error": 1,
"pill": 1, "pill-on": 1, "pill-off": 1, "closed": 1,
}
def test_class_references_reads_react_svelte_and_server_templates():
tsx = (
"export function X({ on }: { on: boolean }) {\n"
' return <button className="btn btn-primary" data-x="y">\n'
" <i className={on ? 'tab tab-on' : 'tab'} />\n"
" <b className={`chip ${on ? 'chip-on' : ''} chip-sm`} />\n"
" <u className={cn({ pill: on, 'pill-off': !on })} />\n"
" </button>\n"
"}\n"
)
# A template literal's static text counts; its `${…}` hole is unknowable
# (chip-on sits inside the hole's own ternary and is NOT claimed).
assert class_references("a/x.tsx", tsx) == {
"btn": 1, "btn-primary": 1, "tab": 2, "tab-on": 1,
"chip": 1, "chip-sm": 1, "pill": 1, "pill-off": 1,
}
assert class_references("a/y.svelte", '<div class:active={on} class="row">') == {
"row": 1, "active": 1,
}
# A server-side interpolation contributes no token; a literal class inside
# a template conditional still does.
html = '<div class="row {{ cls }} col-2 {% if x %}y{% endif %}">'
assert class_references("t/p.html", html) == {"row": 1, "col-2": 1, "y": 1}
# Not a template-bearing file: nothing, however it reads.
assert class_references("a/z.py", 'html = \'<div class="row">\'') == {}
def test_scan_archive_returns_definitions_and_references_from_one_walk():
tree = dict(TREE)
tree["web/Card.vue"] = (
b'<template><div class="btn card">x</div></template>\n'
b"<style scoped>\n.card {\n color: red;\n}\n</style>\n"
)
scan = scan_archive(_tarball(tree))
assert isinstance(scan, ArchiveScan)
assert [(d.path, d.kind, d.name) for d in scan.definitions] == TREE_SHAPES + [
("web/Card.vue", "css", "card"),
]
# Only files whose markup names a class appear; the .py/.css files don't.
assert scan.references == {"web/Card.vue": {"btn": 1, "card": 1}}
assert shapes_from_archive(_tarball(tree)) == [(d.path, d.kind, d.name) for d in scan.definitions]
# --- unit: the covering predicate (lives with the ledger since #2788) -------- # --- unit: the covering predicate (lives with the ledger since #2788) --------