diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index a1b8bc3..99009eb 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -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"""(? 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 ------------------------------ diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index 54fa989..e3f462c 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.py @@ -16,7 +16,10 @@ import pytest import pytest_asyncio from scribe.services.coverage import ( + ArchiveScan, + class_references, coverage_line, + scan_archive, extract_shapes, largest_gaps, scannable, @@ -118,6 +121,80 @@ def test_shapes_from_archive_strips_the_wrapper_and_gates_files(): 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 \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 \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", '
') == { + "row": 1, "active": 1, + } + # A server-side interpolation contributes no token; a literal class inside + # a template conditional still does. + html = '
' + 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 = \'
\'') == {} + + +def test_scan_archive_returns_definitions_and_references_from_one_walk(): + tree = dict(TREE) + tree["web/Card.vue"] = ( + b'\n' + b"\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) --------