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
|
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 ------------------------------
|
||||||
|
|||||||
@@ -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) --------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user