feat(ledger): coverage refresh feeds the shape ledger; the readout inverts to accounting (#2788, milestone 294 step 2)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 44s

compute_coverage is now the ledger's sync point: every walk upserts the
extracted shapes (new → unclassified, the todo state; surviving → last-seen
bump; vanished → stamped, kept as history), re-files judgments whose snippet
target went away, and mechanically stamps snippet reference locations as
canonical — the one always-safe rule, self-healing only for its own stamps
(an agent's judgment is never unwound by machinery).

The covering predicate moves to shape_ledger.location_covers as the single
home (match_shapes retired with its consumer); coverage's payload and line
invert from 'N/M shapes recorded' to shape ACCOUNTING per note 2786:
accounted/total with a canonical·instance·variant·exempt breakdown, and
unclassified — THE todo — with its largest directories. Cache key bumps to
v2 so pre-ledger blobs honestly read 'not measured yet' instead of rendering
in a shape no longer spoken.

Readout is deliberately project-wide (all repos' live rows), while the walk
serves whichever repos the owner's keyring reaches this refresh.

Integration tests pin the new contract: rows for every extracted shape,
mechanical canonical stamps carrying snippet ids, idempotent recompute,
agent judgments surviving recompute AND vanish/return, vanished rows leaving
the readout but keeping their history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:37:18 -04:00
co-authored by Claude Fable 5
parent 19fdc9aa89
commit 9b1597a3c9
6 changed files with 427 additions and 129 deletions
+8 -5
View File
@@ -61,11 +61,14 @@ async def enter_project(project_id: int) -> dict:
open_tasks, recent_notes, design_system, systems, pattern_coverage —
plus systems_bootstrap, present only when it applies (see below).
`pattern_coverage` (usually null) is a one-line estimate of how much of
the bound repo's code has recorded snippets — e.g. "pattern-library
coverage: 34/210 shapes recorded (estimate); largest gaps: internal/api".
When present, treat the gaps as a standing invitation: as you touch code
in those areas, record the shapes you find with create_snippet.
`pattern_coverage` (usually null) is the shape-accounting line — how many
of the bound repo's extracted shapes carry a classification against canon
(note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for —
12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473
unclassified, largest: internal/api". Unclassified IS the todo: as you
touch code in those areas, classify the shapes you can (instances of
recorded canon, deliberate variants, one-off exemptions) and record the
canon that's missing with create_snippet.
`systems` is the project's vocabulary of named subsystems/areas. It is
returned here so you can TAG as you write: when creating or meaningfully
+1 -1
View File
@@ -123,7 +123,7 @@ async def delete_project_route(project_id: int):
@projects_bp.route("/<int:project_id>/coverage", methods=["GET"])
@login_required
async def get_coverage_route(project_id: int):
"""The cached pattern-library coverage summary — never computes.
"""The cached shape-accounting summary — never computes.
`configured` tells the card whether offering a Refresh button makes
sense; `coverage` is null until something has computed it (a webhook
+102 -83
View File
@@ -41,7 +41,10 @@ logger = logging.getLogger(__name__)
# Cache key in the settings KV, on the project OWNER's user_id — the same
# channel the scheduler's last-run summary uses for machine-written state.
_CACHE_KEY_PREFIX = "pattern_coverage_"
# v2 suffix with #2788: the payload shape inverted (accounted/unclassified);
# pre-ledger blobs under the old key simply stop being found, so the card
# honestly reads "not measured yet" until the first ledger-era refresh.
_CACHE_KEY_PREFIX = "pattern_coverage_v2_"
# Files whose content can't hold definitions — the hook's skip list, verbatim,
# plus sourcemaps (which are JSON in a trenchcoat).
@@ -157,77 +160,42 @@ def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
# --- matching shapes against recorded locations ------------------------------
def _norm_symbol(kind_or_symbol: str) -> str:
# CSS shapes and recorded CSS symbols may or may not carry the leading
# dot; compare without it so ".btn-primary" and "btn-primary" agree.
return kind_or_symbol.lstrip(".").strip()
def _location_covers(loc_path: str, loc_symbol: str, path: str, name: str) -> bool:
if _norm_symbol(loc_symbol) != _norm_symbol(name):
return False
if not loc_path:
# Symbol-only record: the symbol match is all the claim there is.
return True
# The drift check's location semantics, not a second copy of them: exact
# file, or the recorded path is a directory the file lives under.
from scribe.services.snippets import _path_touches
return _path_touches(loc_path, path)
def match_shapes(
shapes: list[tuple[str, str, str]],
recorded: list[tuple[str, str]],
) -> list[tuple[str, str, str, bool]]:
"""Each shape with whether some recorded (path, symbol) location covers it.
Symbol-less recorded locations never cover a shape — a whole-file record
makes no claim about any particular definition inside it. The recorded
repo NAME is deliberately not consulted: it is free-form ("Scribe") and
the project binding already did the scoping; on a project binding several
repos this can over-credit a same-named symbol, which the estimate label
owns.
"""
usable = [(p, s) for p, s in recorded if (s or "").strip()]
return [
(
path,
kind,
name,
any(_location_covers(lp, ls, path, name) for lp, ls in usable),
)
for path, kind, name in shapes
]
# The covering predicate (location_covers) lives in services/shape_ledger.py
# since #2788 — the ledger's canonical marking and this module's readout are
# two consumers of ONE doctrine, and the ledger is its home.
def largest_gaps(
matched: list[tuple[str, str, str, bool]], *, top: int = 3
accounted: list[tuple[str, str, str, bool]], *, top: int = 3
) -> list[dict]:
"""The directories with the most uncovered shapes — where a backlog
session should start, named the way the repo names them."""
"""The directories with the most unclassified shapes — where a
classification session should start, named the way the repo names them."""
by_dir: dict[str, dict[str, int]] = {}
for path, _kind, _name, covered in matched:
for path, _kind, _name, is_accounted in accounted:
d = posixpath.dirname(path) or "(root)"
row = by_dir.setdefault(d, {"total": 0, "uncovered": 0})
row = by_dir.setdefault(d, {"total": 0, "unclassified": 0})
row["total"] += 1
if not covered:
row["uncovered"] += 1
if not is_accounted:
row["unclassified"] += 1
ranked = sorted(
by_dir.items(), key=lambda kv: (-kv[1]["uncovered"], kv[0])
by_dir.items(), key=lambda kv: (-kv[1]["unclassified"], kv[0])
)
return [
{"dir": d, "uncovered": row["uncovered"], "total": row["total"]}
{"dir": d, "unclassified": row["unclassified"], "total": row["total"]}
for d, row in ranked[:top]
if row["uncovered"]
if row["unclassified"]
]
# --- compute, cache, surface -------------------------------------------------
async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str, str]]:
"""(path, symbol) for every location of every live snippet in a project."""
async def _recorded_locations(
user_id: int, project_id: int
) -> list[tuple[int, str, str]]:
"""(snippet_note_id, path, symbol) for every location of every live
snippet in a project — the canonical-marking input (#2788): the id is what
lets a ledger row point back at the snippet it references."""
from sqlalchemy import select
from scribe.models import async_session
@@ -244,34 +212,46 @@ async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str,
)
)
notes = list(rows.scalars().all())
out: list[tuple[str, str]] = []
out: list[tuple[int, str, str]] = []
for note in notes:
for loc in snippet_fields(note).get("locations") or []:
out.append((loc.get("path") or "", loc.get("symbol") or ""))
out.append(
(int(note.id), loc.get("path") or "", loc.get("symbol") or "")
)
return out
async def compute_coverage(
user_id: int, project_id: int, *, selector: ForgeSelector | None = None
) -> dict | None:
"""Measure a project's pattern-library coverage against its bound repos.
"""Sync the shape ledger from the bound repos and read the accounting.
Since #2788 this is the ledger's sync point, not just a measurement:
every walk upserts the extracted shapes (new → unclassified, vanished →
stamped), re-stamps snippet reference locations as canonical, and then
reports the ACCOUNTING — how many shapes carry a classification at all —
rather than the old "has a snippet" fraction. Unclassified is the todo
(note 2786).
None means "nothing to measure" — the owner's keyring serves none of the
project's bound repos (#2778). That is the ordinary state for a
forge-less user and every caller treats it as silence, not failure.
Forge errors (unreachable, bad token) RAISE — the two callers are a
refresh button and a background task, and both want to know.
forge-less user and every caller treats it as silence, not failure; the
ledger is untouched in that case. Forge errors (unreachable, bad token)
RAISE — the two callers are a refresh button and a background task, and
both want to know.
``user_id`` is the project OWNER's id: the cache lives there, and the
keyring resolved here must be the same one every other read uses.
"""
from scribe.services import shape_ledger
from scribe.services.forge import ForgeError
if selector is None:
selector = await get_forges(user_id, project_id)
if not selector.configured:
return None
repos: list[dict] = []
matched_all: list[tuple[str, str, str, bool]] = []
served: list[tuple[str, str]] = []
recorded = await _recorded_locations(user_id, project_id)
for key in await keys_for_project(user_id, project_id):
hit = selector.resolve(key)
@@ -280,26 +260,54 @@ async def compute_coverage(
forge, api_repo = hit
ref = await forge.default_branch(api_repo)
shapes = shapes_from_archive(await forge.archive(api_repo, ref))
matched = match_shapes(shapes, recorded)
matched_all.extend(matched)
repos.append({
"repo": key,
"ref": ref,
"total": len(matched),
"recorded": sum(1 for *_x, covered in matched if covered),
})
if not repos:
# The head commit is provenance sugar on the ledger rows; failing to
# learn it must not fail the sync — the ref names the point well
# enough and the row timestamps carry the when.
try:
marker = await forge.latest_commit(api_repo, "", ref) or ref
except ForgeError:
marker = ref
await shape_ledger.sync_repo_shapes(
project_id, key, shapes, seen_marker=marker
)
served.append((key, ref))
if not served:
return None
await shape_ledger.mark_canonicals(project_id, recorded)
# Project-wide readout, deliberately wider than this walk: a second bound
# repo that was unreachable today still has live rows, and they count.
rows = await shape_ledger.live_rows(project_id)
counts = {"canonical": 0, "instance": 0, "variant": 0, "exempt": 0,
"unclassified": 0}
for row in rows:
counts[row.status] = counts.get(row.status, 0) + 1
by_repo: dict[str, dict[str, int]] = {}
for row in rows:
agg = by_repo.setdefault(row.repo_key, {"total": 0, "accounted": 0})
agg["total"] += 1
agg["accounted"] += row.status != "unclassified"
unclassified = counts.pop("unclassified")
return {
"total": len(matched_all),
"recorded": sum(1 for *_x, covered in matched_all if covered),
"total": len(rows),
"accounted": len(rows) - unclassified,
"unclassified": unclassified,
"counts": counts,
# Honesty flag, not decoration: every surface that shows the number
# is expected to carry it through.
"estimate": True,
"computed_at": datetime.now(timezone.utc).isoformat(),
"repos": repos,
"largest_gaps": largest_gaps(matched_all),
"repos": [
{"repo": key, "ref": ref,
**by_repo.get(key, {"total": 0, "accounted": 0})}
for key, ref in served
],
"largest_gaps": largest_gaps([
(r.path, r.kind, r.symbol, r.status != "unclassified")
for r in rows
]),
}
@@ -330,12 +338,23 @@ async def cached_coverage(user_id: int, project_id: int) -> dict | None:
def coverage_line(coverage: dict) -> str:
"""The one-line evidence-carrying summary enter_project surfaces."""
day = (coverage.get("computed_at") or "")[:10]
line = (
f"pattern-library coverage: {coverage.get('recorded', 0)}"
f"/{coverage.get('total', 0)} shapes recorded"
f" (estimate{', computed ' + day if day else ''})"
counts = coverage.get("counts") or {}
breakdown = " · ".join(
f"{counts[k]} {k}"
for k in ("canonical", "instance", "variant", "exempt")
if counts.get(k)
)
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
if gaps:
line += "; largest gaps: " + ", ".join(gaps)
line = (
f"shape accounting: {coverage.get('accounted', 0)}"
f"/{coverage.get('total', 0)} shapes accounted for"
)
if breakdown:
line += f"{breakdown}"
line += f" (estimate{', computed ' + day if day else ''})"
unclassified = coverage.get("unclassified", 0)
if unclassified:
line += f"; {unclassified} unclassified"
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
if gaps:
line += ", largest: " + ", ".join(gaps)
return line
+176
View File
@@ -0,0 +1,176 @@
"""The shape ledger's write side — sync and mechanical marking (#2788).
The coverage walk (services/coverage.py) is the only feed that sees every
shape, so it is the ledger's sync point: each refresh upserts one repo's
extracted shapes — new shapes arrive `unclassified` (THE todo state, note
2786), surviving shapes bump their last-seen marker, vanished shapes get
stamped rather than deleted (history is the point). Classifications survive
recompute by construction: the upsert never touches a judgment, with two
deliberate exceptions —
- a judgment whose snippet target is gone (SET NULL on snippet deletion)
is re-filed as unclassified so it rejoins the todo instead of dangling;
- a MECHANICALLY-stamped canonical row whose snippet location no longer
covers it falls back to unclassified. Only mechanical stamps self-heal;
an agent's judgment is never unwound by machinery.
`location_covers` is the one covering predicate — the same doctrine the
recorded-location drift check uses — shared by the sync's canonical marking
and by anything else that must decide whether a recorded location speaks for
an extracted shape.
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.code_shape import CodeShape
# Statuses whose meaning requires a snippet target.
_NEEDS_TARGET = ("canonical", "instance", "variant")
def _norm_symbol(kind_or_symbol: str) -> str:
# CSS shapes and recorded CSS symbols may or may not carry the leading
# dot; compare without it so ".btn-primary" and "btn-primary" agree.
return kind_or_symbol.lstrip(".").strip()
def location_covers(loc_path: str, loc_symbol: str, path: str, name: str) -> bool:
"""Does a recorded (path, symbol) location speak for this shape?
Symbol-less locations never cover a shape — a whole-file record makes no
claim about any particular definition inside it. Path semantics are the
drift check's own: exact file, or the recorded path is a directory the
file lives under.
"""
if not (loc_symbol or "").strip():
return False
if _norm_symbol(loc_symbol) != _norm_symbol(name):
return False
if not loc_path:
# Symbol-only record: the symbol match is all the claim there is.
return True
from scribe.services.snippets import _path_touches
return _path_touches(loc_path, path)
async def sync_repo_shapes(
project_id: int,
repo_key: str,
shapes: list[tuple[str, str, str]],
*,
seen_marker: str,
) -> None:
"""Upsert one repo's extracted (path, kind, name) shapes into the ledger.
``seen_marker`` is the commit the archive was read at when the forge can
say, else the ref name — provenance sugar; the row timestamps carry the
when.
"""
now = datetime.now(timezone.utc)
async with async_session() as session:
rows = (
await session.execute(
select(CodeShape).where(
CodeShape.project_id == project_id,
CodeShape.repo_key == repo_key,
)
)
).scalars().all()
by_key = {(r.path, r.symbol, r.kind): r for r in rows}
seen: set[tuple[str, str, str]] = set()
for path, kind, name in shapes:
key = (path, name, kind)
if key in seen:
continue
seen.add(key)
row = by_key.get(key)
if row is None:
session.add(CodeShape(
project_id=project_id, repo_key=repo_key,
path=path, symbol=name, kind=kind,
first_seen_commit=seen_marker, last_seen_commit=seen_marker,
))
continue
row.last_seen_commit = seen_marker
# A shape that vanished and came back is live again — the vanish
# stays visible in history via updated_at, not as a dead flag.
row.vanished_at = None
if row.status in _NEEDS_TARGET and row.snippet_id is None:
row.status = "unclassified"
row.classified_by = None
row.classified_at = None
row.reason = None
for key, row in by_key.items():
if key not in seen and row.vanished_at is None:
row.vanished_at = now
await session.commit()
async def mark_canonicals(
project_id: int, recorded: list[tuple[int, str, str]]
) -> None:
"""Stamp snippet reference locations as `canonical` — the one mechanical
rule that is always safe (the judgment happened when the snippet was
minted; this row just makes it queryable).
``recorded`` is (snippet_note_id, path, symbol) for every live snippet
location in the project. Touches only rows machinery owns: unclassified
rows gain the stamp; mechanically-stamped canonicals no longer covered
fall back to unclassified. Agent judgments are never overwritten.
"""
usable = [(nid, p, s) for nid, p, s in recorded if (s or "").strip()]
now = datetime.now(timezone.utc)
async with async_session() as session:
rows = (
await session.execute(
select(CodeShape).where(
CodeShape.project_id == project_id,
CodeShape.vanished_at.is_(None),
)
)
).scalars().all()
for row in rows:
covering = next(
(
nid for nid, lp, ls in usable
if location_covers(lp, ls, row.path, row.symbol)
),
None,
)
if covering is not None and row.status == "unclassified":
row.status = "canonical"
row.snippet_id = covering
row.classified_by = "mechanical"
row.classified_at = now
elif (
covering is None
and row.status == "canonical"
and row.classified_by == "mechanical"
):
row.status = "unclassified"
row.snippet_id = None
row.classified_by = None
row.classified_at = None
await session.commit()
async def live_rows(project_id: int) -> list[CodeShape]:
"""Every un-vanished ledger row for a project — the accounting readout's
input, across ALL its repos (a repo unreachable this refresh still counts;
accounting is project-wide)."""
async with async_session() as session:
return list(
(
await session.execute(
select(CodeShape).where(
CodeShape.project_id == project_id,
CodeShape.vanished_at.is_(None),
)
)
).scalars().all()
)