Hooks say when Scribe didn't answer; the CSS consumer map (milestone 302 steps 1–3) #127
@@ -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 ------------------------------
|
||||
|
||||
@@ -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 <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) --------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user