feat(ledger): CSS derive families are names, never bodies — name floor 2 for css, dup: grouping sym-only; derive line says "repeated name" and dismisses scoped-css; plugin 0.1.41 (note #2917)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Build & push image (push) Successful in 25s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 00:21:17 -04:00
co-authored by Claude Fable 5
parent a2b377b74d
commit 85111442a6
9 changed files with 104 additions and 43 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.40", "version": "0.1.41",
"author": { "name": "Bryan Van Deusen" }, "author": { "name": "Bryan Van Deusen" },
"mcpServers": { "mcpServers": {
"scribe": { "scribe": {
+3 -2
View File
@@ -45,8 +45,9 @@ through recall/auto-inject; this skill is the active reflex around that.
duplicate — reuse it and drop yours — or it isn't, and the record needs the new duplicate — reuse it and drop yours — or it isn't, and the record needs the new
location adding. Both are cheaper now than after the duplicate settles in. location adding. Both are cheaper now than after the duplicate settles in.
- **A `Shape ledger at …` line is the ledger speaking, not the record.** It - **A `Shape ledger at …` line is the ledger speaking, not the record.** It
names a duplicate family ("identical body in N other files, no canon") or a names a duplicate family ("identical body in N other files, no canon"), a
canon elsewhere for a name you just wrote — for edits made through Bash repeated name ("defined in N other files") or a canon elsewhere for a name
you just wrote — for edits made through Bash
(sed, heredocs, scripts) as much as through Write/Edit. Derive the family or (sed, heredocs, scripts) as much as through Write/Edit. Derive the family or
reuse the canon *now*; a family that is convention rather than copies is reuse the canon *now*; a family that is convention rather than copies is
dismissed with `classify_shapes(..., status="exempt", dismissed with `classify_shapes(..., status="exempt",
+10 -1
View File
@@ -94,7 +94,8 @@ the last sweep left it. Three surfaces say so without anyone running an audit
- **At the write** — the prior-art hint (the Write/Edit hook, and since - **At the write** — the prior-art hint (the Write/Edit hook, and since
0.1.39 the after-write hook on Bash, so sed/heredoc/script edits count too) 0.1.39 the after-write hook on Bash, so sed/heredoc/script edits count too)
carries a `Shape ledger at <path>` line when a name just written is a known carries a `Shape ledger at <path>` line when a name just written is a known
**duplicate family** ("identical body in N other files, no canon") or a **duplicate family** ("identical body in N other files, no canon"), a
**repeated name** ("defined in N other files, no canon") or a
**canon elsewhere** ("snippet #N at <path> — reuse, don't redefine"). Act **canon elsewhere** ("snippet #N at <path> — reuse, don't redefine"). Act
on it *then*: pull the canon and build from it, or derive the family now — on it *then*: pull the canon and build from it, or derive the family now —
`create_snippet` the dominant form, repoint the copies, `classify_shapes` `create_snippet` the dominant form, repoint the copies, `classify_shapes`
@@ -109,6 +110,14 @@ the last sweep left it. Three surfaces say so without anyone running an audit
reason_code="convention-plumbing", reason=…)` (or `classify_shapes_by_rule` reason_code="convention-plumbing", reason=…)` (or `classify_shapes_by_rule`
for a whole family) removes it from the queue. Dismissal is a judgment and for a whole family) removes it from the queue. Dismissal is a judgment and
it is recorded; silence is not. it is recorded; silence is not.
- **CSS is watched by name, never by body** (note 2917). Classes serving
different purposes share declarations because the style system makes them
alike — `.text-muted` and `.pin-badge-auto` carrying the same `color:` are
two meanings, not two copies — so a CSS family is the *same class defined
in ≥2 files* (a recipe living in several places), and identical bodies
under different names are never a family. Derive a CSS family by moving
the recipe to the shared sheet and recording it; a class name reused for
genuinely different things is dismissed with `reason_code="scoped-css"`.
After the one-time pay-down the derive queue reads empty; anything in it After the one-time pay-down the derive queue reads empty; anything in it
afterwards is drift of the moment, and the hint already said so at the write. afterwards is drift of the moment, and the hint already said so at the write.
+10 -9
View File
@@ -198,11 +198,13 @@ def extract_definitions(text: str) -> list[Definition]:
break break
block = lines[i:end] block = lines[i:end]
# A CSS rule's fingerprint is its DECLARATIONS, not its selector # A CSS rule's fingerprint is its DECLARATIONS, not its selector
# (#2872): the row's identity already carries the selector, and the # (#2872): the row's identity already carries the selector. Since
# question the fingerprint answers for derive grouping is "is this the # note 2917 the derive grouping no longer reads CSS bodies at all (a
# same rule under another name?" — .closed-msg / .error-block / # class is grouped by name only), so for CSS the fingerprint is the
# .success-msg with identical bodies are one dup group, not three # recheck identity — "did this rule's body change since it was
# lonely rows. Sym blocks keep their signature line in the hash. # judged?" — and nothing more. The shape of the hash is kept as-is on
# purpose: changing it would flip every judged CSS row to recheck on
# the next sync. Sym blocks keep their signature line in the hash.
if kind == "css": if kind == "css":
# One-line rules (`.x { color: red; }`) carry their declarations on # One-line rules (`.x { color: red; }`) carry their declarations on
# the selector line itself; a block that is only the selector plus # the selector line itself; a block that is only the selector plus
@@ -217,10 +219,9 @@ def extract_definitions(text: str) -> list[Definition]:
# A SINGLE declaration is not a shape (#2903): `color: var(--fs- # A SINGLE declaration is not a shape (#2903): `color: var(--fs-
# text-tertiary)` under .text-muted, .task-mark and .pin-badge-auto # text-tertiary)` under .text-muted, .task-mark and .pin-badge-auto
# is three meanings sharing one line, not three copies of one # is three meanings sharing one line, not three copies of one
# rule — the first pay-down found 5-file "families" of exactly # rule. Keep the selector in the hash for one-liners; two
# this and nobody would consolidate them. Keep the selector in the # declarations and up stay selector-agnostic. (Moot for grouping
# hash for one-liners, so they group only with same-name copies; # since note 2917, kept for fingerprint stability — see above.)
# two declarations and up stay selector-agnostic.
elif _declaration_count(hashed) < 2: elif _declaration_count(hashed) < 2:
hashed = block hashed = block
else: else:
+14 -4
View File
@@ -1061,17 +1061,27 @@ def _derive_line(path: str, derive: list[dict]) -> str:
) )
continue continue
f = d["family"] f = d["family"]
how = "identical body" if f.get("identical") else "same name defined"
files = ", ".join(f"`{x}`" for x in f.get("files") or []) files = ", ".join(f"`{x}`" for x in f.get("files") or [])
more = f.get("file_count", 0) - len(f.get("files") or []) more = f.get("file_count", 0) - len(f.get("files") or [])
if more > 0: if more > 0:
files += f" +{more} more" files += f" +{more} more"
n = f.get("file_count", 0)
if f.get("identical"):
what = f"is a duplicate family with no canon — identical body in {n} other file(s)"
else:
# A name family: the same definition name living in several
# files. CSS is only ever grouped this way (note 2917) — a class
# is a recipe, and the recipe is what gets derived or dismissed.
what = f"is a repeated name with no canon — defined in {n} other file(s)"
# The dismissal reason the family most likely earns: a class name
# reused for different purposes is scoped styling; a code name reused
# across modules is convention plumbing.
dismiss = "scoped-css" if d.get("kind") == "css" else "convention-plumbing"
parts.append( parts.append(
f"`{f['label']}` is a duplicate family with no canon — {how} in " f"`{f['label']}` {what}: {files}; derive it now: "
f"{f.get('file_count', 0)} other file(s): {files}; derive it now: "
"record the canon (create_snippet) and make the copies instances " "record the canon (create_snippet) and make the copies instances "
"(classify_shapes) — or, if these are convention not copies, " "(classify_shapes) — or, if these are convention not copies, "
"`classify_shapes(..., status=\"exempt\", reason_code=\"convention-plumbing\")` " f"`classify_shapes(..., status=\"exempt\", reason_code=\"{dismiss}\")` "
"dismisses the family — rather than adding another copy" "dismisses the family — rather than adding another copy"
) )
return f"> Shape ledger at `{path}`: " + "; ".join(parts) + "." return f"> Shape ledger at `{path}`: " + "; ".join(parts) + "."
+19 -9
View File
@@ -923,6 +923,13 @@ async def stamp_write_path_instances(
# recur by convention, not by duplication). # recur by convention, not by duplication).
_DERIVE_MIN_DUP = 2 _DERIVE_MIN_DUP = 2
_DERIVE_MIN_NAME = 3 _DERIVE_MIN_NAME = 3
# CSS is never grouped by body (note 2917): classes for different purposes
# share declarations because the style system makes them alike — `.text-muted`
# and `.pin-badge-auto` carrying the same `color: var(--fs-text-tertiary)` are
# two meanings, not two copies. A CSS family is a NAME defined in more than
# one file: that is a recipe living in several places, and two is already
# the signal (a class name is deliberate in a way `setup`/`load` are not).
_DERIVE_MIN_NAME_CSS = 2
# Semantic checks per repo per refresh — an embedding each (local fastembed), # Semantic checks per repo per refresh — an embedding each (local fastembed),
# bounded so a 4,000-row ledger is worked through over refreshes, not in one. # bounded so a 4,000-row ledger is worked through over refreshes, not in one.
_SEMANTIC_CAP = 150 _SEMANTIC_CAP = 150
@@ -1298,15 +1305,16 @@ def derive_groups(
rows: Iterable[tuple[str, str, str, str]] rows: Iterable[tuple[str, str, str, str]]
) -> dict[tuple[str, str, str], str]: ) -> dict[tuple[str, str, str], str]:
"""The derive-first grouping over (path, kind, symbol, body_sha) rows """The derive-first grouping over (path, kind, symbol, body_sha) rows
that matched no canon: {(path, kind, symbol): group_key}. Identical that matched no canon: {(path, kind, symbol): group_key}. For code
bodies in ≥2 places group as `dup:<sha>`; the same name defined in ≥3 (kind `sym`) identical bodies in ≥2 places group as `dup:<sha>` and the
files groups as `name:<kind>:<symbol>`; a row joins at most one group, same name defined in ≥3 files groups as `name:sym:<symbol>`, the copy
the copy before the name.""" before the name. CSS groups by name only — the same class defined in
≥2 files is `name:css:<symbol>`; its body never groups it (note 2917)."""
by_sha: dict[str, list[tuple[str, str, str]]] = {} by_sha: dict[str, list[tuple[str, str, str]]] = {}
by_name: dict[tuple[str, str], list[tuple[str, str, str]]] = {} by_name: dict[tuple[str, str], list[tuple[str, str, str]]] = {}
for path, kind, symbol, sha in rows: for path, kind, symbol, sha in rows:
key = (path, kind, symbol) key = (path, kind, symbol)
if sha: if sha and kind != "css":
by_sha.setdefault(sha, []).append(key) by_sha.setdefault(sha, []).append(key)
by_name.setdefault((kind, _norm_symbol(symbol)), []).append(key) by_name.setdefault((kind, _norm_symbol(symbol)), []).append(key)
out: dict[tuple[str, str, str], str] = {} out: dict[tuple[str, str, str], str] = {}
@@ -1315,7 +1323,8 @@ def derive_groups(
for key in keys: for key in keys:
out.setdefault(key, f"dup:{sha}") out.setdefault(key, f"dup:{sha}")
for (kind, symbol), keys in by_name.items(): for (kind, symbol), keys in by_name.items():
if len({k[0] for k in keys}) >= _DERIVE_MIN_NAME: floor = _DERIVE_MIN_NAME_CSS if kind == "css" else _DERIVE_MIN_NAME
if len({k[0] for k in keys}) >= floor:
for key in keys: for key in keys:
out.setdefault(key, f"name:{kind}:{symbol}") out.setdefault(key, f"name:{kind}:{symbol}")
return out return out
@@ -1606,9 +1615,10 @@ async def write_time_derive(
named at ``path``, what the ledger already knows about that name named at ``path``, what the ledger already knows about that name
elsewhere in the project — elsewhere in the project —
family the name sits in a derive-first group (identical body in N family the name sits in a derive-first group (code: identical body
files, or the same name in ≥3): "this is a known duplicate in N files or the same name in ≥3; CSS: the same class in
family with no canon — derive it now, don't add a copy"; ≥2 files, note 2917): "this is a known family with no canon
— derive it now, don't add a copy";
canon a `canonical` row of that name at another path: "this is canon a `canonical` row of that name at another path: "this is
canon #N at <path> — reuse, don't redefine". canon #N at <path> — reuse, don't redefine".
+18 -8
View File
@@ -588,21 +588,30 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded):
assert summary["derive_groups"][1]["label"] == ".card" assert summary["derive_groups"][1]["label"] == ".card"
assert summary["derive_groups"][0]["size"] == 2 and summary["derive_groups"][1]["size"] == 3 assert summary["derive_groups"][0]["size"] == 2 and summary["derive_groups"][1]["size"] == 3
# One of the css copies gets judged → the group shrinks on the next pass. # One of the css copies gets judged → the group shrinks on the next pass
# but stays a family: a class in two files is already a recipe living in
# two places (css name floor 2, note 2917). Judge the second and it's gone.
await classify_shapes(owner, pid, [ await classify_shapes(owner, pid, [
{"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"}, {"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
]) ])
await apply_derive_groups(pid) await apply_derive_groups(pid)
rows, _ = await list_project_shapes(owner, pid, proposal="derive") rows, _ = await list_project_shapes(owner, pid, proposal="derive")
assert {r.symbol for r in rows} == {"slug"} # 2 files < the name floor assert {r.symbol for r in rows} == {"slug", "card"}
assert {r.path for r in rows if r.symbol == "card"} == {"b/x.css", "b/y.css"}
await classify_shapes(owner, pid, [
{"path": "b/y.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
])
await apply_derive_groups(pid)
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
assert {r.symbol for r in rows} == {"slug"} # 1 file < the css name floor
@pytest.mark.integration @pytest.mark.integration
async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded): async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded):
"""#2900: against real rows — a name in a dup family → the family (other """#2900: against real rows — a name in a family → the family (other
files, count); a name whose canonical row lives elsewhere → that canon; files, count; for CSS a NAME family, never a body one — note 2917); a
a judged row at the path, the canon's own file, or an unknown name → name whose canonical row lives elsewhere → that canon; a judged row at
silence.""" the path, the canon's own file, or an unknown name → silence."""
from scribe.services.shape_ledger import apply_derive_groups, write_time_derive from scribe.services.shape_ledger import apply_derive_groups, write_time_derive
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
@@ -622,9 +631,10 @@ async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded
out = await write_time_derive(pid, "v/D.vue", [("css", "log-empty"), ("css", "unknown")]) out = await write_time_derive(pid, "v/D.vue", [("css", "log-empty"), ("css", "unknown")])
assert len(out) == 1 and out[0]["symbol"] == "log-empty" and out[0]["kind"] == "css" assert len(out) == 1 and out[0]["symbol"] == "log-empty" and out[0]["kind"] == "css"
fam = out[0]["family"] fam = out[0]["family"]
assert fam["identical"] is True and fam["label"] == ".log-empty" # Three identical bodies, and still a NAME family: CSS never groups by body.
assert fam["identical"] is False and fam["label"] == ".log-empty"
assert fam["files"] == ["v/A.vue", "v/B.vue", "v/C.vue"] and fam["file_count"] == 3 assert fam["files"] == ["v/A.vue", "v/B.vue", "v/C.vue"] and fam["file_count"] == 3
assert out[0]["key"] == fam["group"] and fam["group"].startswith("dup:") assert out[0]["key"] == fam["group"] == "name:css:log-empty"
# Editing one existing member still names the OTHER members. # Editing one existing member still names the OTHER members.
out = await write_time_derive(pid, "v/A.vue", [("css", "log-empty")]) out = await write_time_derive(pid, "v/A.vue", [("css", "log-empty")])
assert out[0]["family"]["files"] == ["v/B.vue", "v/C.vue"] and out[0]["family"]["size"] == 3 assert out[0]["family"]["files"] == ["v/B.vue", "v/C.vue"] and out[0]["family"]["size"] == 3
+10
View File
@@ -308,6 +308,12 @@ def test_derive_groups_copy_before_name_with_floors():
("d.css", "css", "btn", "s1"), ("e.css", "css", "btn", "s2"), ("f.css", "css", "btn", "s3"), ("d.css", "css", "btn", "s1"), ("e.css", "css", "btn", "s2"), ("f.css", "css", "btn", "s3"),
("g.py", "sym", "main", "s4"), ("h.py", "sym", "main", "s5"), # only 2 files → no name group ("g.py", "sym", "main", "s4"), ("h.py", "sym", "main", "s5"), # only 2 files → no name group
("i.py", "sym", "one", "s6"), ("i.py", "sym", "one", "s6"),
# CSS (note 2917): identical bodies under different names are NOT a
# copy — two meanings sharing the style system's look; the same
# class in two files IS a family (the name floor is 2 for css).
("j.css", "css", "muted", "same"), ("k.css", "css", "pin-auto", "same"),
("l.css", "css", "card", "c1"), ("m.css", "css", "card", "c2"),
("n.css", "css", "alone", "c3"),
] ]
g = derive_groups(rows) g = derive_groups(rows)
assert g[("a.py", "sym", "helper")] == "dup:sha1" == g[("b.py", "sym", "helper")] assert g[("a.py", "sym", "helper")] == "dup:sha1" == g[("b.py", "sym", "helper")]
@@ -315,6 +321,10 @@ def test_derive_groups_copy_before_name_with_floors():
assert g[("d.css", "css", "btn")] == "name:css:btn" assert g[("d.css", "css", "btn")] == "name:css:btn"
assert ("g.py", "sym", "main") not in g assert ("g.py", "sym", "main") not in g
assert ("i.py", "sym", "one") not in g assert ("i.py", "sym", "one") not in g
assert ("j.css", "css", "muted") not in g and ("k.css", "css", "pin-auto") not in g
assert g[("l.css", "css", "card")] == "name:css:card" == g[("m.css", "css", "card")]
assert ("n.css", "css", "alone") not in g
assert not any(v.startswith("dup:") for k, v in g.items() if k[1] == "css")
def test_derive_new_summary_counts_copies_first_seen_since_the_previous_refresh(): def test_derive_new_summary_counts_copies_first_seen_since_the_previous_refresh():
+19 -9
View File
@@ -1250,13 +1250,16 @@ async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band():
channel silences a family already named.""" channel silences a family already named."""
from scribe.services import plugin_context as pc from scribe.services import plugin_context as pc
found = [ found = [
{"symbol": "log-empty", "kind": "css", "key": "dup:483a", {"symbol": "log-empty", "kind": "css", "key": "name:css:log-empty",
"family": {"group": "dup:483a", "label": ".log-empty", "identical": True, "family": {"group": "name:css:log-empty", "label": ".log-empty", "identical": False,
"files": ["a/TaskLogSection.vue", "a/WorkspaceTaskPanel.vue"], "files": ["a/TaskLogSection.vue", "a/WorkspaceTaskPanel.vue"],
"file_count": 5, "size": 6}}, "file_count": 5, "size": 6}},
{"symbol": "btn-primary", "kind": "css", "key": "canon:2855", {"symbol": "btn-primary", "kind": "css", "key": "canon:2855",
"canon": {"snippet_id": 2855, "path": "frontend/src/assets/components.css", "canon": {"snippet_id": 2855, "path": "frontend/src/assets/components.css",
"label": ".btn-primary"}}, "label": ".btn-primary"}},
{"symbol": "slugify", "kind": "sym", "key": "dup:483a",
"family": {"group": "dup:483a", "label": "slugify", "identical": True,
"files": ["a/x.py", "a/y.py"], "file_count": 2, "size": 3}},
{"symbol": "load", "kind": "sym", "key": "name:sym:load", {"symbol": "load", "kind": "sym", "key": "name:sym:load",
"family": {"group": "name:sym:load", "label": "load", "identical": False, "family": {"group": "name:sym:load", "label": "load", "identical": False,
"files": ["a/X.vue", "a/Y.vue", "a/Z.vue"], "file_count": 3, "size": 4}}, "files": ["a/X.vue", "a/Y.vue", "a/Z.vue"], "file_count": 3, "size": 4}},
@@ -1274,20 +1277,27 @@ async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band():
patch.object(pc.shape_ledger_svc, "write_time_derive", check): patch.object(pc.shape_ledger_svc, "write_time_derive", check):
out = await pc.build_write_path_hint( out = await pc.build_write_path_hint(
1, "frontend/src/components/New.vue", code=REAL_CODE, project_id=24, 1, "frontend/src/components/New.vue", code=REAL_CODE, project_id=24,
stamp_shapes=[("css", "log-empty"), ("css", "btn-primary"), ("sym", "load")], stamp_shapes=[("css", "log-empty"), ("css", "btn-primary"), ("sym", "slugify"),
("sym", "load")],
exclude_derive=["name:sym:load"], exclude_derive=["name:sym:load"],
) )
check.assert_awaited_once_with(24, "frontend/src/components/New.vue", check.assert_awaited_once_with(24, "frontend/src/components/New.vue",
[("css", "log-empty"), ("css", "btn-primary"), ("sym", "load")]) [("css", "log-empty"), ("css", "btn-primary"),
# The excluded family is gone; the other two render and are keyed. ("sym", "slugify"), ("sym", "load")])
assert [d["key"] for d in out["derive"]] == ["dup:483a", "canon:2855"] # The excluded family is gone; the other three render and are keyed.
assert out["derive_keys"] == ["dup:483a", "canon:2855"] assert [d["key"] for d in out["derive"]] == ["name:css:log-empty", "canon:2855", "dup:483a"]
assert out["derive_keys"] == ["name:css:log-empty", "canon:2855", "dup:483a"]
ctx = out["context"] ctx = out["context"]
assert "Shape ledger at `frontend/src/components/New.vue`" in ctx assert "Shape ledger at `frontend/src/components/New.vue`" in ctx
assert "`.log-empty` is a duplicate family with no canon — identical body in 5 other file(s): " \ # A CSS family is a repeated NAME (note 2917) and its dismissal is scoped-css;
# a code dup family is an identical body and dismisses as convention-plumbing.
assert "`.log-empty` is a repeated name with no canon — defined in 5 other file(s): " \
"`a/TaskLogSection.vue`, `a/WorkspaceTaskPanel.vue` +3 more; derive it now" in ctx "`a/TaskLogSection.vue`, `a/WorkspaceTaskPanel.vue` +3 more; derive it now" in ctx
assert "`slugify` is a duplicate family with no canon — identical body in 2 other file(s): " \
"`a/x.py`, `a/y.py`; derive it now" in ctx
assert "`.btn-primary` is canon — snippet #2855 at `frontend/src/assets/components.css`" in ctx assert "`.btn-primary` is canon — snippet #2855 at `frontend/src/assets/components.css`" in ctx
assert "convention-plumbing" in ctx assert 'reason_code=\"scoped-css\")` dismisses' in ctx
assert 'reason_code=\"convention-plumbing\")` dismisses' in ctx
assert "`load`" not in ctx assert "`load`" not in ctx
# No project → no check; a failing check never sinks the hint. # No project → no check; a failing check never sinks the hint.
check.reset_mock() check.reset_mock()