feat(ledger): the consumer map surfaces — every css row carries used_by on list_shapes, flag="unused-css" (the map's negative space, surfaced never deleted), derive groups and the write-path family carry consumers, the derive line says "used by N template(s)", coverage payload unused_css + the standing block; docs, SKILL, plugin 0.1.44; the two step-2 tests expected 5 edges where fan-out makes 6 (milestone 302 step 3, #2936)
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / integration (push) Failing after 44s
CI & Build / Python tests (push) Successful in 1m26s
CI & Build / Build & push image (push) Successful in 31s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 14:03:35 -04:00
co-authored by Claude Fable 5
parent ffbdf19116
commit 8664d8ad14
11 changed files with 181 additions and 28 deletions
+1 -1
View File
@@ -173,7 +173,7 @@ endpoint at `/mcp`, not these REST routes.
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates | | GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync | | GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
| GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` | | GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` |
| GET / POST | `/api/projects/<id>/coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups`, `derive_new`, `divergence`, `recheck`) | | GET / POST | `/api/projects/<id>/coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups` — css groups carry `consumers`, `derive_new`, `unused_css`, `divergence`, `recheck`) |
| GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL | | GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL |
## Dashboard, Export, Trash, Users ## Dashboard, Export, Trash, Users
+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.43", "version": "0.1.44",
"author": { "name": "Bryan Van Deusen" }, "author": { "name": "Bryan Van Deusen" },
"mcpServers": { "mcpServers": {
"scribe": { "scribe": {
+8
View File
@@ -118,6 +118,14 @@ the last sweep left it. Three surfaces say so without anyone running an audit
under different names are never a family. Derive a CSS family by moving 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 the recipe to the shared sheet and recording it; a class name reused for
genuinely different things is dismissed with `reason_code="scoped-css"`. genuinely different things is dismissed with `reason_code="scoped-css"`.
The datum that decides between the two is **what renders it**: every css
row carries `used_by` (the files whose markup names the class — the CSS
consumer map, milestone 302), a derive group carries the family's
`consumers`, and the write-path line says "used by N template(s)". Many
templates, one recipe → derive; one template each, different purposes →
dismiss. `list_shapes(flag="unused-css")` is the map's negative space —
css rules no template names, a deletion candidate to look at, never
auto-deleted (a class built at runtime is invisible to the map).
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.
+19 -7
View File
@@ -116,10 +116,17 @@ async def list_shapes(
classify it: instance if it should use the canon, variant with classify it: instance if it should use the canon, variant with
the why if deliberate); "recheck": judged instances/variants the why if deliberate); "recheck": judged instances/variants
whose body changed since judged (the judgment stands; confirm whose body changed since judged (the judgment stands; confirm
it again with classify_shapes, or re-judge). it again with classify_shapes, or re-judge); "unused-css"
(milestone 302): live css rules no file's markup names — a
deletion candidate to look at, never auto-deleted (the map reads
templates only; a class built at runtime is invisible to it).
Returns {"shapes": [...], "total": N} — total counts every match, not Returns {"shapes": [...], "total": N} — total counts every match, not
just this page. Each row's `classified_by` says who judged: agent / just this page. Every css row carries `used_by` {count, paths} — the
files whose markup names its class (milestone 302, the CSS consumer
map: a scoped rule is used by its own template; a shared recipe by
many; a count of 0 is "no template names it"). Each row's
`classified_by` says who judged: agent /
audit / import are judgments; `mechanical` is the canonical stamp the audit / import are judgments; `mechanical` is the canonical stamp the
sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
a snippet and then wrote code referencing/resembling it, so the shape a snippet and then wrote code referencing/resembling it, so the shape
@@ -145,10 +152,12 @@ async def list_shapes(
include_vanished=include_vanished, limit=limit, offset=offset, include_vanished=include_vanished, limit=limit, offset=offset,
proposal=proposal, flag=flag, uses=uses, proposal=proposal, flag=flag, uses=uses,
) )
return { shapes = [r.to_compact() if compact else r.to_dict() for r in rows]
"shapes": [r.to_compact() if compact else r.to_dict() for r in rows], used_by = await shape_ledger_svc.used_by_map(rows)
"total": total, for row, out in zip(rows, shapes):
} if row.id in used_by:
out["used_by"] = used_by[row.id]
return {"shapes": shapes, "total": total}
async def classify_shapes_by_rule( async def classify_shapes_by_rule(
@@ -295,7 +304,10 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
Returns the accounting payload — total, accounted, counts by status, Returns the accounting payload — total, accounted, counts by status,
unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
confirmation), `derive_groups` (the biggest repeats-with-no-canon confirmation), `derive_groups` (the biggest repeats-with-no-canon
families), `derive_new` (copies that joined a family since the previous families, each css one with `consumers` — the files whose markup
render it, milestone 302), `unused_css` (css rules no template names;
None where the map has no evidence of templates), `derive_new` (copies
that joined a family since the previous
refresh — the drift to act on now: derive the canon, don't queue an refresh — the drift to act on now: derive the canon, don't queue an
audit), `proposer` (what this refresh examined) — plus audit), `proposer` (what this refresh examined) — plus
`pattern_coverage`, the same one-line summary enter_project carries. `pattern_coverage`, the same one-line summary enter_project carries.
+30 -2
View File
@@ -629,7 +629,23 @@ async def compute_coverage(
agg["accounted"] += row.status != "unclassified" agg["accounted"] += row.status != "unclassified"
unclassified = counts.pop("unclassified") unclassified = counts.pop("unclassified")
proposals = shape_ledger.proposal_summary(rows) # The CSS consumer map's readout (milestone 302): which files render each
# css row — on the derive groups (a shared recipe vs a scoped one is a
# count), and the negative space: css rules no template names. "Unused"
# is measured only where the map has evidence of templates at all (one
# edge somewhere); a repo of bare stylesheets is "not measured", not
# "all unused".
css_rows = [r for r in rows if r.kind == "css"]
consumer_paths: dict[int, list[str]] = {}
unused_css = None
try:
edges = await shape_ledger.consumers_of([r.id for r in css_rows])
consumer_paths = {sid: [e.path for e in es] for sid, es in edges.items()}
if consumer_paths:
unused_css = sum(1 for r in css_rows if r.id not in consumer_paths)
except Exception:
logger.warning("consumer map read failed", exc_info=True)
proposals = shape_ledger.proposal_summary(rows, consumer_paths=consumer_paths)
divergence = shape_ledger.divergence_summary(rows) divergence = shape_ledger.divergence_summary(rows)
derive_new = shape_ledger.derive_new_summary(rows, since=since) derive_new = shape_ledger.derive_new_summary(rows, since=since)
return { return {
@@ -646,6 +662,9 @@ async def compute_coverage(
# duplicate family — what the arrival line names so drift is noticed # duplicate family — what the arrival line names so drift is noticed
# on entering, not found by an audit. # on entering, not found by an audit.
"derive_new": derive_new, "derive_new": derive_new,
# The consumer map's negative space (milestone 302): live css rules
# no template names — None when the map has no evidence of templates.
"unused_css": unused_css,
"proposer": proposer_stats, "proposer": proposer_stats,
# The divergence readout (#2793): button B where button A is canon, # The divergence readout (#2793): button B where button A is canon,
# and judged shapes whose bodies moved since they were judged. # and judged shapes whose bodies moved since they were judged.
@@ -823,7 +842,16 @@ def coverage_line(coverage: dict) -> str:
standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}") standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}")
first = (coverage.get("derive_groups") or [{}])[0] first = (coverage.get("derive_groups") or [{}])[0]
if first.get("label") and first.get("files"): if first.get("label") and first.get("files"):
standing.append(f"top copy {first['label']} ×{first['files']} files") top_copy = f"top copy {first['label']} ×{first['files']} files"
# A css family says what renders it (milestone 302): the count that
# tells a shared recipe from a scoped convention.
if "consumers" in first:
n_t = (first.get("consumers") or {}).get("count", 0)
top_copy += f" · used by {n_t} template{'s' if n_t != 1 else ''}"
standing.append(top_copy)
if coverage.get("unused_css"):
n_u = coverage["unused_css"]
standing.append(f"{n_u} unused class{'es' if n_u != 1 else ''}")
if unclassified: if unclassified:
line += f"; {unclassified} unclassified" line += f"; {unclassified} unclassified"
if standing: if standing:
+12
View File
@@ -1073,6 +1073,18 @@ def _derive_line(path: str, derive: list[dict]) -> str:
# files. CSS is only ever grouped this way (note 2917) — a class # files. CSS is only ever grouped this way (note 2917) — a class
# is a recipe, and the recipe is what gets derived or dismissed. # 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)" what = f"is a repeated name with no canon — defined in {n} other file(s)"
# What renders a css family (milestone 302): the consumer count is
# the datum that separates a shared recipe from a scoped convention.
cons = f.get("consumers")
if cons is not None:
n_t = cons.get("count", 0)
used = f"; used by {n_t} template{'s' if n_t != 1 else ''}"
if cons.get("paths"):
used += ": " + ", ".join(f"`{x}`" for x in cons["paths"])
extra = n_t - len(cons["paths"])
if extra > 0:
used += f" +{extra} more"
files += used
# The dismissal reason the family most likely earns: a class name # The dismissal reason the family most likely earns: a class name
# reused for different purposes is scoped styling; a code name reused # reused for different purposes is scoped styling; a code name reused
# across modules is convention plumbing. # across modules is convention plumbing.
+62 -12
View File
@@ -354,6 +354,28 @@ async def consumers_of(shape_ids) -> dict[int, list[CodeShapeConsumer]]:
return out return out
# How many consumer files a readout names before "+N more".
_CONSUMERS_SHOWN = 4
def consumer_summary(paths: Iterable[str]) -> dict:
"""{"count", "paths"} — distinct consumer files, sorted, the first few
named. The one shape every surface uses for "used by N template(s)"."""
files = sorted(set(paths))
return {"count": len(files), "paths": files[:_CONSUMERS_SHOWN]}
async def used_by_map(rows: Iterable[CodeShape]) -> dict[int, dict]:
"""{shape_id: consumer_summary} for every css row given — a row with no
consumer gets {"count": 0, "paths": []}: "no template names it" is a
finding, not an absence."""
css = [r for r in rows if r.kind == "css"]
if not css:
return {}
edges = await consumers_of([r.id for r in css])
return {r.id: consumer_summary(e.path for e in edges.get(r.id, [])) for r in css}
async def mark_canonicals( async def mark_canonicals(
project_id: int, recorded: list[tuple[int, str, str]] project_id: int, recorded: list[tuple[int, str, str]]
) -> None: ) -> None:
@@ -666,7 +688,8 @@ async def list_project_shapes(
suggestion), "derive" (a repeats-with-no-canon group), or one basis suggestion), "derive" (a repeats-with-no-canon group), or one basis
name (symbol/reference/text/signature/semantic). ``flag`` narrows to name (symbol/reference/text/signature/semantic). ``flag`` narrows to
the readout's asks (#2793): "divergence" (new where a canon dominates, the readout's asks (#2793): "divergence" (new where a canon dominates,
`diverges_from` names it) or "recheck" (a judged shape whose body moved). `diverges_from` names it), "recheck" (a judged shape whose body moved),
or "unused-css" (milestone 302: a css rule no template names).
""" """
from sqlalchemy import func, or_ from sqlalchemy import func, or_
@@ -701,6 +724,13 @@ async def list_project_shapes(
conds.append(CodeShape.diverges_from.isnot(None)) conds.append(CodeShape.diverges_from.isnot(None))
elif flag == "recheck": elif flag == "recheck":
conds.append(CodeShape.recheck_at.isnot(None)) conds.append(CodeShape.recheck_at.isnot(None))
elif flag == "unused-css":
# The consumer map's negative space (milestone 302): a live css rule
# no file's markup names. A candidate for deletion, surfaced — never
# deleted — because the map reads templates only (a class built at
# runtime, or used from a script, is invisible to it).
conds.append(CodeShape.kind == "css")
conds.append(~CodeShape.id.in_(select(CodeShapeConsumer.shape_id)))
if uses: if uses:
# Consumers of a canon (#2870): rows with a uses edge to it, whatever # Consumers of a canon (#2870): rows with a uses edge to it, whatever
# shape they themselves are. # shape they themselves are.
@@ -1461,13 +1491,21 @@ async def apply_derive_groups(project_id: int) -> int:
return grouped return grouped
def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict: def proposal_summary(
rows: Iterable[CodeShape], *, top: int = 8,
consumer_paths: dict[int, list[str]] | None = None,
) -> dict:
"""The readout's view of the proposer's standing: how many canon """The readout's view of the proposer's standing: how many canon
proposals await confirmation, and the largest derive-first groups.""" proposals await confirmation, and the largest derive-first groups.
``consumer_paths`` (shape_id → files whose markup names it, milestone
302) puts `consumers` on each group — the family's distinct consumer
files across its members, the datum that separates a shared recipe
from a scoped convention."""
proposed = 0 proposed = 0
by_canon: dict[int, int] = {} by_canon: dict[int, int] = {}
groups: dict[str, dict] = {} groups: dict[str, dict] = {}
files: dict[str, set[str]] = {} files: dict[str, set[str]] = {}
consumers: dict[str, set[str]] = {}
for row in rows: for row in rows:
if row.status not in _MECHANICAL_TODO: if row.status not in _MECHANICAL_TODO:
continue continue
@@ -1488,8 +1526,14 @@ def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict:
files.setdefault(row.proposal_group, set()).add(row.path) files.setdefault(row.proposal_group, set()).add(row.path)
if len(g["paths"]) < 3: if len(g["paths"]) < 3:
g["paths"].append(row.path) g["paths"].append(row.path)
if consumer_paths is not None and row.kind == "css":
consumers.setdefault(row.proposal_group, set()).update(
consumer_paths.get(row.id) or ()
)
for key, g in groups.items(): for key, g in groups.items():
g["files"] = len(files[key]) g["files"] = len(files[key])
if key in consumers:
g["consumers"] = consumer_summary(consumers[key])
# Body-identical groups first (#2872): the things an audit actually # Body-identical groups first (#2872): the things an audit actually
# consolidated were identical bodies under different names/files; a # consolidated were identical bodies under different names/files; a
# name repeated across modules is usually convention. Within a tier, # name repeated across modules is usually convention. Within a tier,
@@ -1753,15 +1797,21 @@ async def write_time_derive(
group = grouped[0].proposal_group group = grouped[0].proposal_group
members = [r for r in grouped if r.proposal_group == group] members = [r for r in grouped if r.proposal_group == group]
files = sorted({r.path for r in members}) files = sorted({r.path for r in members})
out.append({ family = {
"symbol": name, "kind": kind, "key": group, "group": group, "label": label,
"family": { "identical": not group.startswith("name:"),
"group": group, "label": label, "files": files[:_DERIVE_FILES_SHOWN], "file_count": len(files),
"identical": not group.startswith("name:"), "size": len(members) + (1 if here is not None else 0),
"files": files[:_DERIVE_FILES_SHOWN], "file_count": len(files), }
"size": len(members) + (1 if here is not None else 0), if kind == "css":
}, # What renders the family (milestone 302): the members' consumer
}) # files, the row at `path` included when it already exists.
ids = [r.id for r in members] + ([here.id] if here is not None else [])
edges = await consumers_of(ids)
family["consumers"] = consumer_summary(
e.path for es in edges.values() for e in es
)
out.append({"symbol": name, "kind": kind, "key": group, "family": family})
return out return out
+20 -2
View File
@@ -703,11 +703,13 @@ async def test_consumer_map_syncs_edges_from_template_references(seeded):
"v/B.vue": {"error-msg": 1}, "v/B.vue": {"error-msg": 1},
"v/C.vue": {"error-msg": 1, "btn-primary": 4}, "v/C.vue": {"error-msg": 1, "btn-primary": 4},
} }
assert await sync_repo_consumers(pid, REPO, refs) == 5 # A and B consume their OWN error-msg; C defines none, so its use fans
# out to both rows; btn-primary resolves to the shared sheet from A and C.
assert await sync_repo_consumers(pid, REPO, refs) == 6
rows = {(r.path, r.symbol): r.id for r in await live_rows(pid) if r.kind == "css"} rows = {(r.path, r.symbol): r.id for r in await live_rows(pid) if r.kind == "css"}
edges = await consumers_of(rows.values()) edges = await consumers_of(rows.values())
view = {(p, s): [(e.path, e.count) for e in edges.get(i, [])] for (p, s), i in rows.items()} view = {(p, s): [(e.path, e.count) for e in edges.get(i, [])] for (p, s), i in rows.items()}
assert view[("v/A.vue", "error-msg")] == [("v/A.vue", 2)] assert view[("v/A.vue", "error-msg")] == [("v/A.vue", 2), ("v/C.vue", 1)]
assert view[("v/B.vue", "error-msg")] == [("v/B.vue", 1), ("v/C.vue", 1)] assert view[("v/B.vue", "error-msg")] == [("v/B.vue", 1), ("v/C.vue", 1)]
assert view[("assets/components.css", "btn-primary")] == [("v/A.vue", 1), ("v/C.vue", 4)] assert view[("assets/components.css", "btn-primary")] == [("v/A.vue", 1), ("v/C.vue", 4)]
assert view[("web/button.css", "btn")] == [] # the seeded row: no template names it assert view[("web/button.css", "btn")] == [] # the seeded row: no template names it
@@ -719,6 +721,22 @@ async def test_consumer_map_syncs_edges_from_template_references(seeded):
assert [(e.path, e.count) for e in edges[rows[("v/B.vue", "error-msg")]]] == [("v/B.vue", 1)] assert [(e.path, e.count) for e in edges[rows[("v/B.vue", "error-msg")]]] == [("v/B.vue", 1)]
assert [(e.path, e.count) for e in edges[rows[("assets/components.css", "btn-primary")]]] == [("v/A.vue", 2)] assert [(e.path, e.count) for e in edges[rows[("assets/components.css", "btn-primary")]]] == [("v/A.vue", 2)]
# The readout side: used_by per css row, the unused-css flag, and the
# family's consumers on the write-path check.
from scribe.services.shape_ledger import (
apply_derive_groups, used_by_map, write_time_derive,
)
owner = seeded["owner"]
live = [r for r in await live_rows(pid) if r.kind == "css"]
used = await used_by_map(live)
assert used[rows[("v/B.vue", "error-msg")]] == {"count": 1, "paths": ["v/B.vue"]}
assert used[rows[("web/button.css", "btn")]] == {"count": 0, "paths": []}
unused, n = await list_project_shapes(owner, pid, flag="unused-css")
assert n == 1 and [(r.path, r.symbol) for r in unused] == [("web/button.css", "btn")]
await apply_derive_groups(pid)
out = await write_time_derive(pid, "v/New.vue", [("css", "error-msg")])
assert out and out[0]["family"]["consumers"] == {"count": 2, "paths": ["v/A.vue", "v/B.vue"]}
# B's rule vanishes from the tree → its edges go with the pass. # B's rule vanishes from the tree → its edges go with the pass.
await sync_repo_shapes(pid, REPO, [d for d in defs if d[0] != "v/B.vue"], seen_marker="m2") await sync_repo_shapes(pid, REPO, [d for d in defs if d[0] != "v/B.vue"], seen_marker="m2")
await sync_repo_consumers(pid, REPO, refs2) await sync_repo_consumers(pid, REPO, refs2)
+11
View File
@@ -604,6 +604,17 @@ def test_coverage_line_names_the_proposers_standing():
assert "; 90 unclassified (40 proposed, 2 derive groups), largest: src" in line assert "; 90 unclassified (40 proposed, 2 derive groups), largest: src" in line
line = coverage_line({**base, "proposed": 0, "derive_groups": [{"group": "a"}]}) line = coverage_line({**base, "proposed": 0, "derive_groups": [{"group": "a"}]})
assert "(1 derive group)" in line assert "(1 derive group)" in line
# Milestone 302: a css top copy says what renders it; unused classes
# join the standing block only when measured (None = no evidence).
line = coverage_line({**base, "unclassified": 0, "proposed": 0, "derive_groups": [
{"group": "name:css:error-msg", "label": ".error-msg", "files": 6,
"consumers": {"count": 6, "paths": ["a.vue"]}}], "unused_css": 3})
assert "top copy .error-msg ×6 files · used by 6 templates" in line
assert "3 unused classes" in line
line = coverage_line({**base, "unclassified": 0, "proposed": 0, "derive_groups": [
{"group": "name:css:x", "label": ".x", "files": 2,
"consumers": {"count": 1, "paths": ["a.vue"]}}], "unused_css": None})
assert "top copy .x ×2 files · used by 1 template" in line and "unused" not in line
# #2874: the next action on the line — biggest canon queue, widest copy. # #2874: the next action on the line — biggest canon queue, widest copy.
line = coverage_line({ line = coverage_line({
**base, "proposed": 40, "top_canon": {"snippet_id": 2844, "count": 78}, **base, "proposed": 40, "top_canon": {"snippet_id": 2844, "count": 78},
+12 -1
View File
@@ -380,6 +380,17 @@ def test_proposal_summary_ranks_body_identical_groups_first_and_sees_scoped_rows
row("v/J.vue", "closed-msg", "dup:abc", status="exempt"), row("v/J.vue", "closed-msg", "dup:abc", status="exempt"),
] ]
out = proposal_summary(rows) out = proposal_summary(rows)
# Milestone 302: with consumer paths in hand, each css group says what
# renders it — distinct files across the members; absent otherwise.
assert "consumers" not in out["derive_groups"][0]
for i, r in enumerate(rows): # unsaved rows have no id; give them one
r.id = i + 1
cpaths = {rows[0].id: ["v/0.vue", "v/Z.vue"], rows[1].id: ["v/1.vue"], rows[2].id: ["v/0.vue"]}
with_c = proposal_summary(rows, consumer_paths=cpaths)
badge = next(g for g in with_c["derive_groups"] if g["group"] == "name:css:status-badge")
assert badge["consumers"] == {"count": 3, "paths": ["v/0.vue", "v/1.vue", "v/Z.vue"]}
dup = next(g for g in with_c["derive_groups"] if g["group"] == "dup:abc")
assert dup["consumers"] == {"count": 0, "paths": []}
assert [g["group"] for g in out["derive_groups"]] == ["dup:abc", "dup:def", "name:css:status-badge"] assert [g["group"] for g in out["derive_groups"]] == ["dup:abc", "dup:def", "name:css:status-badge"]
assert out["derive_groups"][0]["files"] == 3 and out["derive_groups"][0]["size"] == 3 assert out["derive_groups"][0]["files"] == 3 and out["derive_groups"][0]["size"] == 3
assert out["derive_groups"][0]["label"] == "closed-msg (identical body)" assert out["derive_groups"][0]["label"] == "closed-msg (identical body)"
@@ -455,7 +466,7 @@ def test_resolve_consumers_prefers_the_own_file_and_fans_out_for_shared_names():
(1, "v/A.vue"): 2, # own row, not B's (1, "v/A.vue"): 2, # own row, not B's
(3, "v/A.vue"): 1, # the shared sheet (3, "v/A.vue"): 1, # the shared sheet
(2, "v/B.vue"): 1, # own row (2, "v/B.vue"): 1, # own row
(2, "v/C.vue"): 1, # C defines nothing → the other file's row (1, "v/C.vue"): 1, (2, "v/C.vue"): 1, # C defines none → every other definition
(4, "v/C.vue"): 3, (5, "v/C.vue"): 3, # ambiguous: both, not a guess (4, "v/C.vue"): 3, (5, "v/C.vue"): 3, # ambiguous: both, not a guess
} }
# Unknown tokens and an unreferenced row leave no trace. # Unknown tokens and an unreferenced row leave no trace.
+5 -2
View File
@@ -1306,7 +1306,8 @@ async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band():
{"symbol": "log-empty", "kind": "css", "key": "name:css:log-empty", {"symbol": "log-empty", "kind": "css", "key": "name:css:log-empty",
"family": {"group": "name:css:log-empty", "label": ".log-empty", "identical": False, "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,
"consumers": {"count": 6, "paths": ["a/TaskLogSection.vue", "a/V.vue"]}}},
{"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"}},
@@ -1344,8 +1345,10 @@ async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band():
assert "Shape ledger at `frontend/src/components/New.vue`" in ctx assert "Shape ledger at `frontend/src/components/New.vue`" in ctx
# A CSS family is a repeated NAME (note 2917) and its dismissal is scoped-css; # 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. # a code dup family is an identical body and dismisses as convention-plumbing.
# A css family says what renders it (milestone 302) before the ask.
assert "`.log-empty` is a repeated name with no canon — defined in 5 other file(s): " \ 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; used by 6 templates: " \
"`a/TaskLogSection.vue`, `a/V.vue` +4 more; derive it now" in ctx
assert "`slugify` is a duplicate family with no canon — identical body in 2 other file(s): " \ 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 "`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