Paying down #2962 measured `flag="unused-css"` against a hand audit and found it had a permanent false-positive floor: roughly sixty classes it called unused are alive and always would be, because two ordinary authoring forms produce names no reader of `class=` attributes can see. A flag whose list you cannot act on line by line is worse than no flag — act on it and you delete live UI. - A transition `name=` IS a class reference. `<Transition name="toast">` makes Vue apply `.toast-enter-active` and its siblings at runtime, and React's `<CSSTransition classNames="fade">` does the same with a different suffix set. Every spelling of the tag is read (`Transition`, `TransitionGroup`, `transition-group`), and the emitted suffix set is the union of Vue 3, Vue 2 and React: naming a class no rule defines costs nothing, since it resolves to no row. A bound `:name` stays unknowable. - A concatenated name contributes its static head as a PREFIX reference. `` `status-${s}` ``, `'pri-' + p` and `class="card-{{ v }}"` all leave a head behind once the hole is blanked — and `_CLASS_TOKEN_RE` accepts a trailing hyphen, so until now the extractor emitted a junk token `"status-"` that matched nothing. It is now `status-*`, and resolve_consumers credits every row whose symbol starts with that head, each under the same own-file-else-fan-out rule as an exact token. `*` cannot occur in a class token, so the marker rides the existing dict[str, int] with no schema change. A head shorter than two characters says nothing and is dropped. Crediting every candidate row is the honest reading: the template genuinely does not say which one it built, and the alternative is reporting live rules as dead. What the map still cannot see is a name assembled in a script — `classList.add` — which stays deliberately out of scope; the skill, the `flag=` docstring and the refresh payload docs all say so now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.5 KiB
name, description
| name | description |
|---|---|
| shape-accounting | Use when a project's shape accounting needs attention — the pattern_coverage line from enter_project shows unclassified shapes or is missing on a forge-served project, the operator asks about coverage/accounting/canon, or you just proved a code-to-canon relationship (an audit enumerated call sites, a consolidation repointed consumers, a verify pass confirmed a helper's users). Triggers on "coverage", "accounted", "unclassified", "classify shapes", "what uses this", or finishing any consolidation. |
Shape accounting — every shape classified against canon
The snippet library records canon (small); the shape ledger accounts for every extracted definition in a project's bound repos (total). Each ledger row carries a status:
canonical— IS a snippet's reference (the coverage sync stamps these mechanically; you rarely set it).instanceof snippet N — conforms to recorded canon. Canon in another project counts (a family-level button shape fully accounts for a local use).variantof snippet N — a deliberate, named departure. Reason required — the why IS the record.exempt— judged genuinely one-off. Reason required. A recorded judgment, not silence — it stops the next pass re-litigating it.scoped— one-off by construction, stamped by the coverage sync (a Vue component's scoped<style>rules and its<script setup>functions — unreachable from any other file). Accounted for without a judgment; still proposed against, grouped and flagged; any judgment you make overrides it. Not the todo.unclassified— nobody has judged it yet. This is the todo list.
The loop
- Seed / refresh — the ledger fills from coverage computation. Entering a
project triggers a background seed automatically; when you need it current
now (before a classification batch, or when the line is missing on a
forge-served project), call
refresh_pattern_coverage(project_id)— it returns the fresh accounting line. Takes seconds; it moves repo archives. - Read the todo —
list_shapes(project_id, status="unclassified"), optionally scoped bypathto the directories the coverage line names as largest.snippet_id=Nreads a consumer map. - Judge in batches —
classify_shapes(project_id, [{path, symbol, status, snippet_id?, reason?}]). All-or-nothing: a bad item applies nothing. Rows, never prose — a consumer list in a note or verification detail cannot be sorted, queried, or diffed.
Rows that arrive on their own
Two feeds keep the ledger current between your batches, so most shapes never need a hand judgment:
- The sync stamps a snippet's own reference location
canonical(classified_by: mechanical). - The write path stamps instances as you work: when you
get_snippeta canon and then Write/Edit code that references or resembles it, the definitions being written land asinstancerows (classified_by: hook, the evidence inreason), and the prior-art hook tells you what landed ("Shape accounting: recorded at … → instance of #N"). Offered-but-unopened snippets stamp nothing — so pull the canon you are instantiating; that pull is what turns your reuse into accounting. A hook row is evidence, not judgment: it never overrides a classification you made, and aclassify_shapescall overrides it.
The machine proposes, judgment classifies
Every coverage refresh runs the mechanical proposer over the unclassified rows: same symbol as a canon elsewhere → textual containment → body references a canon → signature resemblance → semantic (capped per refresh). A hit is a proposal on the row, never a classification. Work the queue in bulk:
list_shapes(project_id, proposal="canon", snippet_id=N)orpath="dir"— read the page;proposalcarries snippet_id, basis, score.confirm_shape_proposals(project_id, snippet_id=N)(orpath=,basis=) for the ones that hold — hundreds at a time;symbolandreferenceproposals are near-certain,semanticdeserves a look.classify_shapesthe rest — variant, exempt, or instance of a different snippet. Any judgment retires the proposal.
list_shapes(project_id, proposal="derive") lists the derive-first
candidates — the same body in ≥2 places or the same name defined in ≥3
files, with no canon at all (proposal.group names the family; the coverage
payload's derive_groups ranks the biggest). That is the consolidation
queue, not a classification queue: see below.
The derive-first rule
N same-shaped occurrences matching no recorded canon is never N loose
classifications — it is a consolidation candidate: derive one reference from
the dominant form, create_snippet it, migrate the outliers, then classify
the rest as instances. Canon is determined from the code; consistency comes
from the derivation, not from asking permission.
Derive groups are drift, not audit material
The catalogue exists so the codebase is DRY from inception, not as DRY as the last sweep left it. Three surfaces say so without anyone running an audit (milestone 299):
- 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)
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"), a repeated name ("defined in N other files, no canon") or a canon elsewhere ("snippet #N at — reuse, don't redefine"). Act on it then: pull the canon and build from it, or derive the family now —create_snippetthe dominant form, repoint the copies,classify_shapestheminstance. A family is named once per session. - On arrival — the coverage line's
standing:block (shown even when nothing is unclassified) andderive_new("+N new copies since last refresh: .x in ") name what drifted since the previous refresh. That is the todo of the moment, sized to the last batch — not a backlog. - A family that is convention, not copies — component-local
load/toggle/savethat happen to share a name — is dismissed, not consolidated:classify_shapes(..., status="exempt", reason_code="convention-plumbing", reason=…)(orclassify_shapes_by_rulefor a whole family) removes it from the queue. Dismissal is a judgment and 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-mutedand.pin-badge-autocarrying the samecolor: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 withreason_code="scoped-css". The datum that decides between the two is what renders it: every css row carriesused_by(the files whose markup names the class — the CSS consumer map, milestone 302), a derive group carries the family'sconsumers, 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. The map reads the two class forms templates don't spell out — a<Transition name="x">'s generated classes, and the prefix of a concatenated name (`status-${s}`credits everystatus-…rule) — so what it flags is worth reading. What it still cannot see is a name assembled in a script (classList.add), so confirm before deleting.
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.
The divergence readout — button B where button A is canon
Three questions the ledger answers mechanically (#2793):
- Divergence —
list_shapes(project_id, flag="divergence")(and the coverage line's "N DIVERGENT"): a shape new since the previous refresh, in a directory where one canon dominates the judged siblings, that the proposer did not match to that canon.diverges_fromnames the canon. Judge it:instanceif it should be built from the canon (and rebuild it),variantwith the why if the departure is deliberate. The write-path hook asks the same question in-band the moment such a shape is written. - History —
shape_history(project_id, path, symbol?): the current rows plus everyclassified/vanished/reappeared/driftedevent with its commit — "instance of #N from , re-judged variant of #M because R, vanished at C". Rows, not recollection. - Recheck —
list_shapes(project_id, flag="recheck"): judged instances/variants whose body changed since judged. The judgment stands; re-confirm it (classify again with the same status) or re-judge.
What this buys
Divergence becomes mechanical: when button B appears where button A is canon,
the ledger says unintended divergence or justified variant with its
reason — nobody re-derives the history. get_snippet shows each snippet's
instances and variants, so "what uses this?" is answered from rows before
any contract change lands on its consumers.