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
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:
@@ -424,15 +424,17 @@ async function loadNotes() {
|
||||
|
||||
interface CoverageGap {
|
||||
dir: string;
|
||||
uncovered: number;
|
||||
unclassified: number;
|
||||
total: number;
|
||||
}
|
||||
interface Coverage {
|
||||
total: number;
|
||||
recorded: number;
|
||||
accounted: number;
|
||||
unclassified: number;
|
||||
counts: Record<string, number>;
|
||||
estimate: boolean;
|
||||
computed_at: string;
|
||||
repos: { repo: string; ref: string; total: number; recorded: number }[];
|
||||
repos: { repo: string; ref: string; total: number; accounted: number }[];
|
||||
largest_gaps: CoverageGap[];
|
||||
}
|
||||
|
||||
@@ -712,32 +714,42 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<template v-if="coverage">
|
||||
<div class="coverage-numbers">
|
||||
<span class="coverage-count">{{ coverage.recorded }}/{{ coverage.total }}</span>
|
||||
<span class="coverage-label">shapes recorded</span>
|
||||
<span class="coverage-count">{{ coverage.accounted }}/{{ coverage.total }}</span>
|
||||
<span class="coverage-label">shapes accounted for</span>
|
||||
</div>
|
||||
<div
|
||||
class="coverage-bar"
|
||||
role="progressbar"
|
||||
:aria-valuenow="coverage.recorded"
|
||||
:aria-valuenow="coverage.accounted"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="coverage.total"
|
||||
aria-label="Shapes with a recorded snippet"
|
||||
aria-label="Shapes classified against canon"
|
||||
>
|
||||
<div
|
||||
class="coverage-bar-fill"
|
||||
:style="{ width: (coverage.total ? (coverage.recorded / coverage.total) * 100 : 0) + '%' }"
|
||||
:style="{ width: (coverage.total ? (coverage.accounted / coverage.total) * 100 : 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div v-if="coverage.counts" class="coverage-gaps">
|
||||
<span
|
||||
v-for="k in ['canonical', 'instance', 'variant', 'exempt']"
|
||||
:key="k"
|
||||
>
|
||||
<span v-if="coverage.counts[k]" class="coverage-gap-chip">
|
||||
{{ coverage.counts[k] }} {{ k }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="coverage.largest_gaps?.length" class="coverage-gaps">
|
||||
<span class="coverage-gaps-label">Largest gaps:</span>
|
||||
<span class="coverage-gaps-label">Most unclassified:</span>
|
||||
<span v-for="gap in coverage.largest_gaps" :key="gap.dir" class="coverage-gap-chip">
|
||||
{{ gap.dir }} <span class="coverage-gap-count">{{ gap.uncovered }}</span>
|
||||
{{ gap.dir }} <span class="coverage-gap-count">{{ gap.unclassified }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="coverage-empty">
|
||||
Not measured yet — Refresh compares the bound repo's definitions
|
||||
against recorded snippets.
|
||||
Not measured yet — Refresh reads the bound repo's definitions into
|
||||
the shape ledger and reports how many are classified against canon.
|
||||
</p>
|
||||
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
|
||||
<!-- Forge pin (#2778): owner-only, because the eligible set is the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
+116
-28
@@ -19,10 +19,10 @@ from scribe.services.coverage import (
|
||||
coverage_line,
|
||||
extract_shapes,
|
||||
largest_gaps,
|
||||
match_shapes,
|
||||
scannable,
|
||||
shapes_from_archive,
|
||||
)
|
||||
from scribe.services.shape_ledger import location_covers
|
||||
|
||||
# --- unit: the definition extractor (shared vectors with the hook) -----------
|
||||
|
||||
@@ -117,51 +117,53 @@ def test_shapes_from_archive_strips_the_wrapper_and_gates_files():
|
||||
assert shapes_from_archive(_tarball(TREE)) == TREE_SHAPES
|
||||
|
||||
|
||||
# --- unit: matching shapes against recorded locations ------------------------
|
||||
# --- unit: the covering predicate (lives with the ledger since #2788) --------
|
||||
|
||||
|
||||
def test_match_covers_by_exact_path_dir_prefix_and_css_dot():
|
||||
recorded = [
|
||||
("src/app.py", "make_app"), # exact file
|
||||
("web", ".btn"), # dir prefix + css dot normalization
|
||||
]
|
||||
matched = match_shapes(TREE_SHAPES, recorded)
|
||||
covered = {name for _p, _k, name, ok in matched if ok}
|
||||
assert covered == {"make_app", "btn"}
|
||||
def test_location_covers_by_exact_path_dir_prefix_and_css_dot():
|
||||
assert location_covers("src/app.py", "make_app", "src/app.py", "make_app")
|
||||
# dir prefix + css dot normalization
|
||||
assert location_covers("web", ".btn", "web/button.css", "btn")
|
||||
assert not location_covers("src/app.py", "make_app", "src/util.py", "make_app")
|
||||
|
||||
|
||||
def test_a_symbol_less_record_covers_nothing():
|
||||
"""A whole-file snippet makes no claim about any particular definition
|
||||
inside it — crediting all of them would inflate the number for free."""
|
||||
matched = match_shapes(TREE_SHAPES, [("src/app.py", "")])
|
||||
assert not any(ok for *_x, ok in matched)
|
||||
assert not location_covers("src/app.py", "", "src/app.py", "make_app")
|
||||
|
||||
|
||||
def test_no_prefix_bleed_between_sibling_directories():
|
||||
matched = match_shapes(
|
||||
[("src/library/x.py", "sym", "helper")], [("src/lib", "helper")]
|
||||
)
|
||||
assert not matched[0][3]
|
||||
assert not location_covers("src/lib", "helper", "src/library/x.py", "helper")
|
||||
|
||||
|
||||
def test_largest_gaps_ranks_by_uncovered_and_drops_clean_dirs():
|
||||
matched = match_shapes(TREE_SHAPES, [("src/app.py", "make_app"), ("web", ".btn")])
|
||||
gaps = largest_gaps(matched)
|
||||
assert gaps == [{"dir": "src", "uncovered": 2, "total": 3}]
|
||||
def test_largest_gaps_ranks_by_unclassified_and_drops_clean_dirs():
|
||||
accounted = [
|
||||
("src/app.py", "sym", "make_app", True),
|
||||
("src/app.py", "sym", "Config", False),
|
||||
("src/util.py", "sym", "helper", False),
|
||||
("web/button.css", "css", "btn", True),
|
||||
]
|
||||
gaps = largest_gaps(accounted)
|
||||
assert gaps == [{"dir": "src", "unclassified": 2, "total": 3}]
|
||||
|
||||
|
||||
def test_coverage_line_is_evidence_carrying_and_labeled_estimate():
|
||||
line = coverage_line({
|
||||
"total": 210, "recorded": 34, "estimate": True,
|
||||
"total": 4573, "accounted": 3100, "unclassified": 1473,
|
||||
"counts": {"canonical": 12, "instance": 2900, "variant": 0, "exempt": 188},
|
||||
"estimate": True,
|
||||
"computed_at": "2026-08-16T12:00:00+00:00",
|
||||
"largest_gaps": [
|
||||
{"dir": "internal/api", "uncovered": 40, "total": 60},
|
||||
{"dir": "web/src/components", "uncovered": 25, "total": 30},
|
||||
{"dir": "internal/api", "unclassified": 40, "total": 60},
|
||||
{"dir": "web/src/components", "unclassified": 25, "total": 30},
|
||||
],
|
||||
})
|
||||
assert "34/210 shapes recorded" in line
|
||||
assert "3100/4573 shapes accounted for" in line
|
||||
assert "12 canonical · 2900 instance · 188 exempt" in line # zero variant elided
|
||||
assert "estimate" in line
|
||||
assert "2026-08-16" in line
|
||||
assert "1473 unclassified" in line
|
||||
assert "internal/api, web/src/components" in line
|
||||
|
||||
|
||||
@@ -257,19 +259,52 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
|
||||
refresh_coverage,
|
||||
)
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.code_shape import CodeShape
|
||||
|
||||
uid, pid = seeded["uid"], seeded["pid"]
|
||||
selector = _selector(_tarball(TREE))
|
||||
|
||||
coverage = await compute_coverage(uid, pid, selector=selector)
|
||||
assert coverage is not None
|
||||
assert coverage["total"] == 4
|
||||
assert coverage["recorded"] == 2
|
||||
assert coverage["accounted"] == 2
|
||||
assert coverage["unclassified"] == 2
|
||||
assert coverage["counts"] == {
|
||||
"canonical": 2, "instance": 0, "variant": 0, "exempt": 0,
|
||||
}
|
||||
assert coverage["estimate"] is True
|
||||
assert coverage["repos"] == [{
|
||||
"repo": "git.example.com/alice/widget", "ref": "main",
|
||||
"total": 4, "recorded": 2,
|
||||
"total": 4, "accounted": 2,
|
||||
}]
|
||||
assert coverage["largest_gaps"] == [{"dir": "src", "uncovered": 2, "total": 3}]
|
||||
assert coverage["largest_gaps"] == [
|
||||
{"dir": "src", "unclassified": 2, "total": 3}
|
||||
]
|
||||
|
||||
# The walk fed the LEDGER (#2788): every extracted shape has a row, the
|
||||
# snippet reference locations are mechanically stamped canonical WITH
|
||||
# their snippet id, and the rest sit in the todo state.
|
||||
async with async_session() as s:
|
||||
rows = (await s.execute(
|
||||
select(CodeShape).where(CodeShape.project_id == pid)
|
||||
)).scalars().all()
|
||||
by_symbol = {r.symbol: r for r in rows}
|
||||
assert set(by_symbol) == {"make_app", "Config", "helper", "btn"}
|
||||
assert by_symbol["make_app"].status == "canonical"
|
||||
assert by_symbol["make_app"].snippet_id is not None
|
||||
assert by_symbol["make_app"].classified_by == "mechanical"
|
||||
assert by_symbol["btn"].status == "canonical"
|
||||
assert by_symbol["Config"].status == "unclassified"
|
||||
assert by_symbol["helper"].status == "unclassified"
|
||||
assert all(r.vanished_at is None for r in rows)
|
||||
assert all(r.first_seen_commit for r in rows) # ref at minimum
|
||||
|
||||
# Idempotence: a second walk changes nothing about the readout.
|
||||
again = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
|
||||
assert (again["total"], again["accounted"]) == (4, 2)
|
||||
|
||||
# Nothing computed → nothing cached; refresh writes; the cache reads back
|
||||
# byte-equal, because enter_project will serve exactly this.
|
||||
@@ -278,6 +313,57 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
|
||||
assert (await cached_coverage(uid, pid)) == json.loads(json.dumps(stored))
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_ledger_keeps_judgments_and_stamps_vanished_shapes(seeded):
|
||||
"""The two survival rules (#2788): an agent's classification outlives
|
||||
recompute, and a shape that leaves the tree is stamped vanished — kept
|
||||
for history, dropped from the readout."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.code_shape import CodeShape
|
||||
from scribe.services.coverage import compute_coverage
|
||||
|
||||
uid, pid = seeded["uid"], seeded["pid"]
|
||||
await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
|
||||
|
||||
# An agent judges `helper` a deliberate one-off.
|
||||
async with async_session() as s:
|
||||
helper = (await s.execute(select(CodeShape).where(
|
||||
CodeShape.project_id == pid, CodeShape.symbol == "helper",
|
||||
))).scalar_one()
|
||||
helper.status = "exempt"
|
||||
helper.reason = "test scaffolding, deliberately local"
|
||||
helper.classified_by = "agent"
|
||||
helper.classified_at = datetime.now(timezone.utc)
|
||||
await s.commit()
|
||||
|
||||
# The tree moves on: util.py (helper) is gone entirely, app.py loses
|
||||
# nothing. The judgment on `helper` must survive AS HISTORY (vanished,
|
||||
# still exempt), never be reset by the sync.
|
||||
smaller = {k: v for k, v in TREE.items() if k != "src/util.py"}
|
||||
coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(smaller)))
|
||||
assert coverage["total"] == 3 # helper's row left the readout
|
||||
assert coverage["accounted"] == 2
|
||||
assert coverage["counts"]["exempt"] == 0 # vanished rows don't count
|
||||
|
||||
async with async_session() as s:
|
||||
helper = (await s.execute(select(CodeShape).where(
|
||||
CodeShape.project_id == pid, CodeShape.symbol == "helper",
|
||||
))).scalar_one()
|
||||
assert helper.vanished_at is not None
|
||||
assert helper.status == "exempt" # the judgment is history, kept
|
||||
assert helper.reason == "test scaffolding, deliberately local"
|
||||
|
||||
# And it returns: the shape reappearing clears the stamp, judgment intact.
|
||||
coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
|
||||
assert coverage["total"] == 4
|
||||
assert coverage["accounted"] == 3 # exempt counts as accounted again
|
||||
assert coverage["counts"]["exempt"] == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_enter_project_surfaces_the_line_only_once_computed(seeded):
|
||||
from scribe.mcp._context import _user_id_ctx
|
||||
@@ -296,8 +382,10 @@ async def test_enter_project_surfaces_the_line_only_once_computed(seeded):
|
||||
after = await enter_project(project_id=pid)
|
||||
line = after["pattern_coverage"]
|
||||
assert line.startswith(
|
||||
"pattern-library coverage: 2/4 shapes recorded (estimate, computed "
|
||||
"shape accounting: 2/4 shapes accounted for — 2 canonical "
|
||||
"(estimate, computed "
|
||||
)
|
||||
assert "2 unclassified, largest: src" in line
|
||||
assert line.endswith("; largest gaps: src")
|
||||
finally:
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
Reference in New Issue
Block a user