From 3a4031d7f8f7913fc1d9e91cc20a6b4be7a7016d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 15:00:48 -0400 Subject: [PATCH 01/12] =?UTF-8?q?fix(ledger):=20proposer=20v3=20=E2=80=94?= =?UTF-8?q?=20sym=20bases=20gated=20by=20language=20family,=20reference=20?= =?UTF-8?q?skips=20generic=20verbs,=20semantic=20held=20to=20the=20shape's?= =?UTF-8?q?=20own=20project=20(#2871,=20milestone=20294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-08 audit examined 407 proposals. Every cross-language hit was wrong: the Python MCP tool-module canon (symbol `register`) was offered for each auth view's handleSubmit (it calls authStore.register()) and for the TS auth store's own `register`; Minstrel/Forge TS canon matched Python bodies by resemblance. Every cross-project semantic proposal was noise. - Canon carries the snippet's language; match_canon skips a sym canon whose family (py / js / css / sh / sql, by language ↔ by path extension) differs from the shape's. Unknown on either side = no gate. - The reference basis ignores a stoplist of generic verbs (register, load, save, get, …): a bare mention is not a call site of THIS canon; the symbol basis still catches a second definition, and the call-site relation moves to `uses` edges with #2870. - The semantic arm only reaches canon in the shape's own project and family; symbol/text still reach family canon elsewhere (note 2786). - _PROPOSER_VERSION 2 → 3 so standing proposals re-examine on the next refresh. Co-Authored-By: Claude Fable 5 --- src/scribe/services/shape_ledger.py | 82 +++++++++++++++++++++++++++-- tests/test_shape_ledger.py | 43 +++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index deadb38..4f8d463 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -718,7 +718,9 @@ _SEMANTIC_CAP = 150 _SEMANTIC_FLOOR = 0.8 # Bump when a basis's rule changes: rows remember the (body, ruleset) they # were examined under, so a tightened rule re-examines everything once. -_PROPOSER_VERSION = 2 +# v3: language-family gate on the sym bases, reference stoplist, semantic +# restricted to the shape's own project (#2871). +_PROPOSER_VERSION = 3 # Signature resemblance floor, name blanked (difflib ratio) — and a length # floor, because `def NAME():` resembles `def NAME(x):` at 0.95 while saying # nothing; a family shape has parameters to resemble. @@ -737,6 +739,64 @@ class Canon(NamedTuple): signature: str code_norm: str project_id: int = 0 + language: str = "" # the snippet's recorded language; "" = unknown, no gate + + +# Language families: the sym bases only propose within one. The 2026-08 +# audit (#2871) found every cross-language hit wrong — a Python tool-module +# canon named `register` proposed for Vue `handleSubmit`s that call +# `authStore.register()`, and a TS store's `register` matched it by symbol; +# Minstrel/Forge TS canon proposed for Python bodies by resemblance. CSS is +# its own kind and is not gated here. +_FAMILY_BY_LANG = { + "python": "py", "py": "py", + "typescript": "js", "ts": "js", "tsx": "js", "javascript": "js", "js": "js", + "jsx": "js", "vue": "js", "mjs": "js", "cjs": "js", + "css": "css", "scss": "css", "sass": "css", "less": "css", + "bash": "sh", "sh": "sh", "shell": "sh", "zsh": "sh", + "sql": "sql", +} +_FAMILY_BY_EXT = { + ".py": "py", ".pyi": "py", + ".ts": "js", ".tsx": "js", ".js": "js", ".jsx": "js", ".vue": "js", ".mjs": "js", ".cjs": "js", + ".css": "css", ".scss": "css", ".sass": "css", ".less": "css", + ".sh": "sh", ".bash": "sh", ".zsh": "sh", + ".sql": "sql", +} + + +def language_family(language: str) -> str: + """The family a recorded snippet language belongs to ("" when unknown).""" + return _FAMILY_BY_LANG.get((language or "").strip().lower(), "") + + +def path_family(path: str) -> str: + """The family a file path belongs to, by extension ("" when unknown).""" + p = (path or "").lower() + for ext, fam in _FAMILY_BY_EXT.items(): + if p.endswith(ext): + return fam + return "" + + +def same_family(path: str, canon_language: str) -> bool: + """A sym basis may propose this canon for this path: both families known + and equal, or either unknown (no evidence either way → no gate).""" + a = path_family(path) + b = language_family(canon_language) + return not a or not b or a == b + + +# Reference basis: generic verbs name too many unrelated things to count a +# bare mention as a call site of THIS canon (`register`, `load`, `save` …). +# The symbol basis still catches a second definition of such a name; the +# call-site relation for these becomes a `uses` edge once #2870 lands. +_REFERENCE_STOPLIST = frozenset({ + "get", "set", "put", "post", "load", "save", "run", "main", "init", "setup", + "register", "restore", "reset", "toggle", "close", "open", "submit", "handler", + "update", "create", "delete", "remove", "add", "start", "stop", "send", + "receive", "render", "mount", "dispatch", "call", "apply", "execute", +}) def _norm_text(text: str) -> str: @@ -789,11 +849,17 @@ def match_canon( for c in canons: if c.kind != kind: continue + if kind == "sym" and not same_family(path, c.language): + continue # a Python canon says nothing about a Vue body, and vice versa if c.symbol and _norm_symbol(c.symbol) == norm_sym: if not any(location_covers(lp, ls, path, symbol) for lp, ls in c.locations): offer("symbol", 1.0, c) continue # its own location is canonical territory, not a proposal - if c.symbol and references_symbol(body, c.symbol, kind): + if ( + c.symbol + and _norm_symbol(c.symbol).lower() not in _REFERENCE_STOPLIST + and references_symbol(body, c.symbol, kind) + ): offer("reference", 0.9, c) if c.code_norm and text_contains(body, c.code_norm): offer("text", 0.95, c) @@ -849,6 +915,7 @@ async def canon_catalog(user_id: int) -> list[Canon]: for loc in fields.get("locations") or [] ), signature, _norm_text(code), int(note.project_id or 0), + (fields.get("language") or "").strip().lower(), )) return out @@ -907,7 +974,14 @@ async def propose_for_repo( if canons is None: canons = await canon_catalog(user_id) by_key = {(d[0], d[1], d[2]): d for d in definitions} - sym_canon_ids = {c.snippet_id for c in canons if c.kind == "sym"} + # The semantic arm is the widest net and, across projects, was pure noise + # in the 2026-08 audit (#2871): it is held to the shape's own project and + # language family. The precise bases (symbol/text) still reach family + # canon in other projects (note 2786). + sym_canons = [c for c in canons if c.kind == "sym" and c.project_id == project_id] + + def semantic_allowed(path: str) -> set[int]: + return {c.snippet_id for c in sym_canons if same_family(path, c.language)} now = datetime.now(timezone.utc) examined = proposed = checked = 0 async with async_session() as session: @@ -955,7 +1029,7 @@ async def propose_for_repo( continue checked += 1 try: - found = await _semantic_canon(user_id, d[5], sym_canon_ids) + found = await _semantic_canon(user_id, d[5], semantic_allowed(row.path)) except Exception: logger.warning("semantic proposal failed", exc_info=True) found = None diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index 6e062d9..6b13a49 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -248,6 +248,49 @@ def test_match_canon_orders_bases_strongest_first_and_respects_kind(): assert match_canon("sym", "x.py", "unrelated", "def unrelated(a, b, c, d, e):", "return 1", canons) is None +def test_match_canon_gates_sym_bases_by_language_family(): + """A Python canon says nothing about a Vue body (and vice versa): the + 2026-08 audit's worst proposals were `register` (MCP tool module, python) + offered for every auth view's handleSubmit that calls authStore.register() + and for a TS store's own `register`. Unknown language on either side → + no gate (the canons recorded without a language keep proposing).""" + from scribe.services.shape_ledger import Canon, _norm_text, match_canon, same_family + py_register = Canon(46, "sym", "register", (("src/scribe/mcp/tools/notes.py", "register"),), + "def register(mcp) -> None:", _norm_text("def register(mcp) -> None: ..."), 2, "python") + ts_helper = Canon(53, "sym", "apiErrorMessage", (("frontend/src/api/client.ts", "apiErrorMessage"),), + "export function apiErrorMessage(e: unknown, fallback: string): string {", + _norm_text("export function apiErrorMessage(e, fallback) { return fallback }"), 2, "typescript") + canons = [py_register, ts_helper] + vue_body = "async function handleSubmit() {\n await authStore.register(username.value);\n error.value = apiErrorMessage(e, 'x');\n}" + # The Vue handler references the TS helper, never the Python canon. + assert match_canon("sym", "frontend/src/views/RegisterView.vue", "handleSubmit", + "async function handleSubmit() {", vue_body, canons) == (53, "reference", 0.9) + # A TS store's own `register` is not a second definition of the Python one. + assert match_canon("sym", "frontend/src/stores/auth.ts", "register", + "async function register(u: string) {", "return apiPost('/api/auth/register', {u})", + [py_register]) is None + # Same family still proposes by symbol; unknown language still proposes. + assert match_canon("sym", "src/scribe/mcp/tools/other.py", "register", + "def register(mcp) -> None:", "pass", [py_register]) == (46, "symbol", 1.0) + unknown = py_register._replace(language="") + assert match_canon("sym", "frontend/src/stores/auth.ts", "register", + "async function register(u: string) {", "", [unknown]) == (46, "symbol", 1.0) + assert same_family("a.py", "python") and same_family("a.vue", "typescript") + assert same_family("a.py", "") and same_family("", "python") + assert not same_family("a.py", "vue") + + +def test_match_canon_reference_skips_generic_verbs(): + """A bare mention of `load`/`save`/`register` is not a call site of THIS + canon; the symbol basis still catches a second definition of the name.""" + from scribe.services.shape_ledger import Canon, _norm_text, match_canon + loader = Canon(70, "sym", "load", (("frontend/src/components/A.vue", "load"),), + "async function load() {", _norm_text("async function load() { await fetch() }"), 2, "vue") + body = "async function refresh() {\n await load();\n}" + assert match_canon("sym", "frontend/src/components/B.vue", "refresh", "async function refresh() {", body, [loader]) is None + assert match_canon("sym", "frontend/src/components/B.vue", "load", "async function load() {", "", [loader]) == (70, "symbol", 1.0) + + def test_match_canon_symbol_beats_everything_including_css_copies(): """The previous test's css `btn-primary`-elsewhere case, stated plainly: a second definition of the canon's own name is the symbol basis.""" -- 2.54.0 From 9abc4443fbb2c1c0b80a1594709e8616a161b8ea Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 15:02:55 -0400 Subject: [PATCH 02/12] =?UTF-8?q?feat(ledger):=20audit=20surfaces=20?= =?UTF-8?q?=E2=80=94=20list=5Fshapes(compact=3DTrue)=20and=20classify=5Fsh?= =?UTF-8?q?apes=5Fby=5Frule,=20the=20sweep=20form=20of=20a=20judgment=20(#?= =?UTF-8?q?2868,=20milestone=20294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-08 audit judged 3,427 rows in 14 hand-driven batches through a raw MCP client because a list_shapes page overflowed the tool budget and every row had to be sent back one by one. Now: - list_shapes(compact=True): path · symbol · kind · status · signature, plus snippet_id / by / proposal / diverges_from / recheck only when set. A full 500-row page fits. CodeShape.to_compact() is the row shape. - classify_shapes_by_rule(project_id, path, status, pattern=, kind=, snippet_id=, reason=, via=, include_judged=): ONE judgment over every unclassified live row under a directory whose symbol matches a glob; judged rows are untouched unless include_judged; canonical is refused; same gates as the row form; one transaction; returns count + sample. shape_ledger.classify_shapes_where / rule_matches (pure) carry it. Tests: compact row pinned, rule_matches directory/glob/kind semantics, the tool mount, and an integration sweep (unclassified-only, include_judged, gates). Co-Authored-By: Claude Fable 5 --- src/scribe/mcp/tools/shapes.py | 68 ++++++++++++++++++++- src/scribe/models/code_shape.py | 25 ++++++++ src/scribe/services/shape_ledger.py | 76 ++++++++++++++++++++++++ tests/test_integration_shape_classify.py | 41 +++++++++++++ tests/test_shape_ledger.py | 48 +++++++++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) diff --git a/src/scribe/mcp/tools/shapes.py b/src/scribe/mcp/tools/shapes.py index 6fbebdd..0ad044c 100644 --- a/src/scribe/mcp/tools/shapes.py +++ b/src/scribe/mcp/tools/shapes.py @@ -64,6 +64,7 @@ async def list_shapes( offset: int = 0, proposal: str = "", flag: str = "", + compact: bool = False, ) -> dict: """Read a project's shape ledger — `status="unclassified"` IS the todo. @@ -77,6 +78,11 @@ async def list_shapes( snippet_id: rows classified against this snippet — a consumer map. include_vanished: include shapes no longer in the tree (history). limit/offset: page through big ledgers (limit caps at 500). + compact: rows as `path · symbol · kind · status · signature` plus + snippet_id / by / proposal / diverges_from / recheck only when + set — no commits, shas or timestamps. THE form for an audit: + a full 500-row page fits the tool budget. The default rows carry + everything (shape_history-grade bookkeeping). proposal: the proposer's queue (#2792) — "any", "canon" (rows the machine thinks are an instance of a snippet: `proposal` carries snippet_id, basis, score), "derive" (rows that repeat with NO @@ -118,7 +124,63 @@ async def list_shapes( include_vanished=include_vanished, limit=limit, offset=offset, proposal=proposal, flag=flag, ) - return {"shapes": [r.to_dict() for r in rows], "total": total} + return { + "shapes": [r.to_compact() if compact else r.to_dict() for r in rows], + "total": total, + } + + +async def classify_shapes_by_rule( + project_id: int, + path: str, + status: str, + pattern: str = "", + kind: str = "", + snippet_id: int = 0, + reason: str = "", + via: str = "agent", + include_judged: bool = False, +) -> dict: + """The sweep form of classify_shapes: ONE judgment applied to every + unclassified shape under a directory whose symbol matches a glob. + + For the long tail an audit judges by family, not by row — "every scoped + rule under frontend/src/views is exempt: styles one element of its view", + "every `*_scheduler.py` symbol is an instance of ScheduledJob" — where + listing 900 rows and sending them back is the whole cost. The row form + stays the precise tool; reach for it when each row gets its own reason. + + Args: + project_id: The project whose ledger is being judged. + path: A file, or a directory and everything beneath it. Required — + a sweep names what it judges. + status: instance | variant | exempt | unclassified (canonical is the + sync's stamp, not a sweep's). + pattern: Shell glob on the symbol (`*_rows`, `_*`, `modal-*`, `*`); + "" = every symbol under path. + kind: "sym" or "css" to narrow; "" = both. + snippet_id: Required for instance/variant — the canon judged against. + reason: Required for variant/exempt — the why, recorded on every row. + via: "agent" (default) | "audit" | "import". + include_judged: By default only `unclassified` rows are touched — a + sweep never silently overwrites a judgment. True re-judges every + matching live row (use to re-confirm after a recheck, or to + revise a family you judged earlier). + + One transaction: applies whole or not at all. Returns + {"classified": N, "sample": ["path::symbol", ...]} (first 12, sorted) + so you can see what the rule reached; N = 0 means the rule matched + nothing live and unclassified — widen the pattern or refresh coverage. + """ + uid = current_user_id() + try: + return await shape_ledger_svc.classify_shapes_where( + uid, project_id, path=path, status=status, pattern=pattern, + kind=kind, snippet_id=snippet_id or None, reason=reason or None, + via=via, include_judged=include_judged, + ) + except ValueError as exc: + return {"error": str(exc)} async def shape_history( @@ -217,7 +279,7 @@ async def refresh_pattern_coverage(project_id: int) -> dict: def register(mcp) -> None: for fn in ( - classify_shapes, list_shapes, refresh_pattern_coverage, - confirm_shape_proposals, shape_history, + classify_shapes, classify_shapes_by_rule, list_shapes, + refresh_pattern_coverage, confirm_shape_proposals, shape_history, ): mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/models/code_shape.py b/src/scribe/models/code_shape.py index 5dc0ab5..884ee32 100644 --- a/src/scribe/models/code_shape.py +++ b/src/scribe/models/code_shape.py @@ -167,6 +167,31 @@ class CodeShape(Base, TimestampMixin): "updated_at": iso(self.updated_at), } + def to_compact(self) -> dict: + """The row as an audit reads it (#2868): identity, standing, the + definition line and the proposer's word — none of the bookkeeping + (commits, shas, timestamps). A 500-row page of these fits the tool + budget; a page of to_dict() does not.""" + out = { + "path": self.path, + "symbol": self.symbol, + "kind": self.kind, + "status": self.status, + "signature": self.signature, + } + if self.snippet_id is not None: + out["snippet_id"] = self.snippet_id + if self.classified_by: + out["by"] = self.classified_by + proposal = self.proposal + if proposal: + out["proposal"] = proposal + if self.diverges_from is not None: + out["diverges_from"] = self.diverges_from + if self.recheck_at is not None: + out["recheck"] = True + return out + # What a shape's history records (#2793). Not "appeared" — first_seen and # created_at already say that on the row; history is for what CHANGED: diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index 4f8d463..95eeb0e 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -370,6 +370,82 @@ async def classify_shapes( return {"classified": classified, "unmatched": unmatched} +def rule_matches(row: CodeShape, *, path: str, pattern: str, kind: str) -> bool: + """Does a ledger row fall under a rule-form classification (#2868)? + ``path`` is a file or a directory (everything beneath it), ``pattern`` + a shell glob on the symbol (``""`` = every symbol), ``kind`` narrows to + sym/css. Pure, so the sweep's reach can be tested without a database.""" + import fnmatch + + clean = (path or "").strip().strip("/") + if clean and not (row.path == clean or row.path.startswith(clean + "/")): + return False + if kind and row.kind != kind: + return False + if pattern and not fnmatch.fnmatchcase(_norm_symbol(row.symbol), pattern): + return False + return True + + +async def classify_shapes_where( + user_id: int, + project_id: int, + *, + path: str, + status: str, + pattern: str = "", + kind: str = "", + snippet_id: int | None = None, + reason: str | None = None, + via: str = "agent", + include_judged: bool = False, +) -> dict: + """The sweep form of classify_shapes (#2868): one judgment applied to + every live row under ``path`` whose symbol matches ``pattern`` (and + ``kind``). By default only `unclassified` rows are touched — a sweep + must never silently overwrite a judgment; ``include_judged`` opts in. + Same gates as the row form (status vocabulary, snippet target, reason + for variant/exempt, write access); one transaction, so it applies whole + or not at all. Returns the count and a sample of what it judged.""" + from scribe.services import access + from scribe.services import snippets as snippets_svc + + if via not in _CALLER_VIAS: + raise ValueError(f"via must be one of: {', '.join(_CALLER_VIAS)}") + if not (path or "").strip(): + raise ValueError("path is required — a sweep names the directory it judges") + if status == "canonical": + raise ValueError("canonical is the sync's stamp on a snippet's own location — a sweep cannot set it") + probe = {"path": path, "symbol": "*", "status": status, + "snippet_id": snippet_id or 0, "reason": reason or ""} + error = validate_classifications([probe]) + if error: + raise ValueError(error.replace("classifications[0]", "rule")) + if not await access.can_write_project(user_id, project_id): + raise ValueError(f"project {project_id} not found or no write access") + if status in _NEEDS_TARGET and await snippets_svc.get_snippet(user_id, int(snippet_id)) is None: + raise ValueError(f"snippet {snippet_id} not found (or not readable)") + + now = datetime.now(timezone.utc) + judged: list[str] = [] + async with async_session() as session: + conds = [CodeShape.project_id == project_id, CodeShape.vanished_at.is_(None)] + if not include_judged: + conds.append(CodeShape.status == "unclassified") + rows = (await session.execute(select(CodeShape).where(*conds))).scalars().all() + for row in rows: + if not rule_matches(row, path=path, pattern=pattern, kind=kind): + continue + await _judge( + session, row, status=status, + snippet_id=int(snippet_id) if status in _NEEDS_TARGET else None, + by=via, reason=reason, at=now, + ) + judged.append(f"{row.path}::{row.symbol}") + await session.commit() + return {"classified": len(judged), "sample": sorted(judged)[:12]} + + async def list_project_shapes( user_id: int, project_id: int, diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index 50206e8..40b1100 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -15,6 +15,7 @@ from scribe.models.project import Project from scribe.models.user import User from scribe.services.shape_ledger import ( classify_shapes, + classify_shapes_where, list_project_shapes, snippet_consumers, sync_repo_shapes, @@ -128,6 +129,46 @@ async def test_classification_is_write_gated_and_listing_read_gated(seeded): assert await list_project_shapes(other, pid) == ([], 0) +@pytest.mark.integration +async def test_rule_form_sweeps_unclassified_rows_only_and_applies_whole(seeded): + """#2868: one judgment over a directory + glob; judged rows are left + alone unless include_judged; the same gates as the row form.""" + owner, other, pid, sid = seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"] + await classify_shapes(owner, pid, [ + {"path": "src/app.py", "symbol": "Config", "status": "exempt", "reason": "settings holder"}, + ]) + out = await classify_shapes_where( + owner, pid, path="src", status="instance", snippet_id=sid, via="audit", + ) + # make_app + helper swept; Config (already judged) untouched; css not under src/. + assert out["classified"] == 2 + assert out["sample"] == ["src/app.py::make_app", "src/util.py::helper"] + rows, _ = await list_project_shapes(owner, pid) + by_symbol = {r.symbol: r for r in rows} + assert by_symbol["make_app"].status == "instance" and by_symbol["make_app"].classified_by == "audit" + assert by_symbol["Config"].status == "exempt" and by_symbol["Config"].reason == "settings holder" + assert by_symbol["btn"].status == "unclassified" + # Glob + kind narrow; include_judged re-judges. + out = await classify_shapes_where( + owner, pid, path="web", status="exempt", pattern="btn*", kind="css", + reason="one toolbar button", include_judged=True, + ) + assert out["classified"] == 1 + out = await classify_shapes_where( + owner, pid, path="src", status="unclassified", include_judged=True, + ) + assert out["classified"] == 3 # withdrawal sweeps judged rows when asked + # Gates: reason for exempt, snippet for instance, write access, a path. + with pytest.raises(ValueError): + await classify_shapes_where(owner, pid, path="src", status="exempt") + with pytest.raises(ValueError): + await classify_shapes_where(owner, pid, path="src", status="instance") + with pytest.raises(ValueError): + await classify_shapes_where(owner, pid, path="", status="exempt", reason="x") + with pytest.raises(ValueError): + await classify_shapes_where(other, pid, path="src", status="exempt", reason="x") + + @pytest.mark.integration async def test_list_filters_compose(seeded): owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index 6b13a49..a8b17fb 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -334,6 +334,54 @@ def test_proposer_tools_are_mounted(): assert mcp._tool_manager.get_tool("confirm_shape_proposals") is not None tool = mcp._tool_manager.get_tool("list_shapes") assert "proposal" in tool.parameters.get("properties", {}) + # #2868: the audit surfaces — compact pages and the sweep form. + assert "compact" in tool.parameters.get("properties", {}) + rule = mcp._tool_manager.get_tool("classify_shapes_by_rule") + assert rule is not None + for name in ("path", "status", "pattern", "kind", "snippet_id", "reason", "include_judged"): + assert name in rule.parameters.get("properties", {}), name + + +# --- #2868: the bulk surfaces (pure) ----------------------------------------- + + +def test_compact_row_carries_identity_standing_and_the_proposers_word_only(): + """A 500-row compact page must fit the tool budget: no commits, shas or + timestamps; optional fields only when set.""" + from scribe.models.code_shape import CodeShape + row = CodeShape(project_id=2, repo_key="r", path="src/a.py", symbol="f", kind="sym", + status="unclassified", signature="def f(x):", body_sha="abc", + first_seen_commit="c1", last_seen_commit="c2") + assert row.to_compact() == { + "path": "src/a.py", "symbol": "f", "kind": "sym", + "status": "unclassified", "signature": "def f(x):", + } + row.status, row.snippet_id, row.classified_by = "instance", 9, "audit" + row.proposed_snippet_id, row.proposal_basis, row.proposal_score = 9, "symbol", 1.0 + compact = row.to_compact() + assert compact["snippet_id"] == 9 and compact["by"] == "audit" + assert compact["proposal"]["basis"] == "symbol" + for noisy in ("first_seen_commit", "last_seen_commit", "body_sha", "created_at", "classified_at"): + assert noisy not in compact + + +def test_rule_matches_is_directory_glob_and_kind_aware(): + from scribe.models.code_shape import CodeShape + from scribe.services.shape_ledger import rule_matches + + def row(path, symbol, kind="sym"): + return CodeShape(project_id=2, repo_key="r", path=path, symbol=symbol, kind=kind, status="unclassified") + + r = row("frontend/src/views/LoginView.vue", "auth-card", "css") + assert rule_matches(r, path="frontend/src/views", pattern="", kind="") + assert rule_matches(r, path="frontend/src/views", pattern="auth-*", kind="css") + assert not rule_matches(r, path="frontend/src/views", pattern="auth-*", kind="sym") + assert not rule_matches(r, path="frontend/src/view", pattern="", kind="") # directory, not prefix + assert rule_matches(r, path="frontend/src/views/LoginView.vue", pattern="", kind="") + # CSS symbols compare without the leading dot, like everywhere else. + assert rule_matches(row("w/a.css", ".btn-primary", "css"), path="w", pattern="btn-*", kind="css") + assert rule_matches(row("src/scribe/services/backup.py", "_note_rows"), path="src/scribe/services", pattern="_*_rows", kind="") + assert not rule_matches(row("src/scribe/services/backup.py", "export_full_backup"), path="src/scribe/services", pattern="_*_rows", kind="") # --- step 7: the divergence readout (pure) ---------------------------------- -- 2.54.0 From 1ab614bfbe040623a28d65f717b85b1cfdf42de7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 15:06:52 -0400 Subject: [PATCH 03/12] =?UTF-8?q?feat(ledger):=20the=20`scoped`=20bucket?= =?UTF-8?q?=20=E2=80=94=20by-construction=20one-offs=20are=20stamped=20by?= =?UTF-8?q?=20the=20sync,=20not=20judged=20by=20a=20person=20(#2869,=20mil?= =?UTF-8?q?estone=20294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-08 audit left 77% of Scribe's ledger `exempt`, most of it a Vue component's scoped \n" + "\n" + ) + defs = extract_definitions(vue) + names = {(d.kind, d.name) for d in defs} + assert {("sym", "load"), ("sym", "save"), ("css", "card"), ("css", "title"), ("css", "global-toast")} <= names + scoped = scoped_definitions("frontend/src/views/A.vue", vue, defs) + assert scoped == {("sym", "load"), ("sym", "save"), ("css", "card"), ("css", "title")} + # Definitions know their line, which is what the scoped-style range uses. + assert next(d for d in defs if d.name == "card").line > next(d for d in defs if d.name == "save").line + # Not a .vue: nothing is scoped, whatever it contains. + assert scoped_definitions("frontend/src/assets/components.css", ".card {\n x: 1;\n}\n", + extract_definitions(".card {\n x: 1;\n}\n")) == set() + assert scoped_definitions("src/a.py", "def load():\n pass\n", extract_definitions("def load():\n pass\n")) == set() + diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index a8b17fb..f63c86e 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -30,7 +30,7 @@ def test_the_todo_state_is_the_default(): assert CodeShape.__table__.c.status.default.arg == "unclassified" assert "unclassified" in SHAPE_STATUSES assert set(SHAPE_STATUSES) == { - "canonical", "instance", "variant", "exempt", "unclassified", + "canonical", "instance", "variant", "exempt", "scoped", "unclassified", } assert set(SHAPE_CLASSIFIERS) == { "agent", "audit", "hook", "mechanical", "import", -- 2.54.0 From 1126bbe84f201ffde4e0ce2d6bf321edccbf0a31 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 15:07:48 -0400 Subject: [PATCH 04/12] =?UTF-8?q?test(ledger):=20the=20divergence=20accept?= =?UTF-8?q?ance=20case=20uses=20TS=20canon=20=E2=80=94=20proposer=20v3=20g?= =?UTF-8?q?ates=20sym=20bases=20by=20language=20family=20(#2871)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- tests/test_integration_shape_classify.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index aa1e643..c4f0f32 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -562,7 +562,20 @@ async def test_a_second_confirm_dialog_is_detected_and_named(seeded): write_time_divergence, ) - owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] + from scribe.services import snippets as snippets_svc + + owner, pid = seeded["owner"], seeded["pid"] + # The confirm helper is TS canon: since #2871 a sym basis only proposes + # within the shape's language family, so the fixture's Python snippet + # says nothing about these Vue bodies — the canon must be one of theirs. + canon = await snippets_svc.create_snippet( + owner, name="cls_confirm_factory", + code="export async function factory(): Promise {\n return true;\n}\n", + language="typescript", repo="Widget", + path="frontend/src/composables/useConfirm.ts", symbol="factory", + project_id=pid, + ) + sid = int(canon.id) comp = "frontend/src/components" base = _defs( *[(f"{comp}/{n}.vue", "sym", f"on{n}", f"async function on{n}() {{", -- 2.54.0 From 57d68c935569cc1d1b78be606c3810bd2df4d7b9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 15:08:57 -0400 Subject: [PATCH 05/12] feat(ledger): derive readout ranks body-identical groups first; CSS fingerprints are declarations, not selectors (#2872, milestone 294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-08 audit consolidated identical bodies under different names and files (five auth views' CSS, two Workspace formatDate()s, four modal blocks) while the derive readout led with name groups (to_dict ×25, main ×5, load ×6) that were convention or coincidence. - proposal_summary ranks dup: groups above name groups, wider file spread first, and carries `files`; scoped rows (#2869) are in the readout, since that is where view-level copies live. - extract_definitions fingerprints a CSS rule by its declarations — the row identity already carries the selector — so .closed-msg / .error-block / .success-msg with one body are one dup group. One-time effect: judged css rows whose stored fingerprint predates this may show a recheck on the next refresh (the judgment stands; re-confirm). Co-Authored-By: Claude Fable 5 --- src/scribe/services/coverage.py | 9 ++++++++- src/scribe/services/shape_ledger.py | 23 +++++++++++++++++------ tests/test_pattern_coverage.py | 5 +++++ tests/test_shape_ledger.py | 26 ++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index a4ee20a..a1e44fd 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -185,8 +185,15 @@ def extract_definitions(text: str) -> list[Definition]: end = j break block = lines[i:end] + # A CSS rule's fingerprint is its DECLARATIONS, not its selector + # (#2872): the row's identity already carries the selector, and the + # question the fingerprint answers for derive grouping is "is this the + # same rule under another name?" — .closed-msg / .error-block / + # .success-msg with identical bodies are one dup group, not three + # lonely rows. Sym blocks keep their signature line in the hash. + hashed = block[1:] if kind == "css" and len(block) > 1 else block out.append(Definition( - kind, name, lines[i].strip()[:_SIGNATURE_CAP], _block_sha(block), + kind, name, lines[i].strip()[:_SIGNATURE_CAP], _block_sha(hashed), "\n".join(block), i, )) return out diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index e37a363..e0dc983 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -1215,25 +1215,36 @@ def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict: proposals await confirmation, and the largest derive-first groups.""" proposed = 0 groups: dict[str, dict] = {} + files: dict[str, set[str]] = {} for row in rows: - if row.status != "unclassified": + if row.status not in _MECHANICAL_TODO: continue if row.proposed_snippet_id is not None: proposed += 1 elif row.proposal_group: + dup = not row.proposal_group.startswith("name:") g = groups.setdefault(row.proposal_group, { "group": row.proposal_group, "kind": row.kind, "label": ( - ("." if row.kind == "css" else "") + row.symbol - if row.proposal_group.startswith("name:") - else f"{row.symbol} (identical body)" + f"{row.symbol} (identical body)" if dup + else ("." if row.kind == "css" else "") + row.symbol ), - "size": 0, "paths": [], + "size": 0, "files": 0, "paths": [], }) g["size"] += 1 + files.setdefault(row.proposal_group, set()).add(row.path) if len(g["paths"]) < 3: g["paths"].append(row.path) - ranked = sorted(groups.values(), key=lambda g: (-g["size"], g["group"])) + for key, g in groups.items(): + g["files"] = len(files[key]) + # Body-identical groups first (#2872): the things an audit actually + # consolidated were identical bodies under different names/files; a + # name repeated across modules is usually convention. Within a tier, + # the group spread over more files is the bigger copy. + ranked = sorted( + groups.values(), + key=lambda g: (g["group"].startswith("name:"), -g["files"], -g["size"], g["group"]), + ) return {"proposed": proposed, "derive_groups": ranked[:top]} diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index c8e7997..5bdd4fd 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.py @@ -468,6 +468,11 @@ def test_extract_definitions_fingerprints_each_block(): # And the identity view is unchanged for the hook mirror. from scribe.services.coverage import extract_shapes assert extract_shapes(text) == [(d.kind, d.name) for d in extract_definitions(text)] + # A CSS rule's fingerprint is its declarations (#2872): the same body + # under another selector is the same shape to the derive grouping. + css = ".closed-msg {\n text-align: center;\n padding: 0.5rem 0;\n}\n.error-block {\n text-align: center;\n padding: 0.5rem 0;\n}\n.other {\n text-align: left;\n}\n" + d = {x.name: x for x in extract_definitions(css)} + assert d["closed-msg"].body_sha == d["error-block"].body_sha != d["other"].body_sha def test_coverage_line_names_the_proposers_standing(): diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index f63c86e..c0f923f 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -317,6 +317,32 @@ def test_derive_groups_copy_before_name_with_floors(): assert ("i.py", "sym", "one") not in g +def test_proposal_summary_ranks_body_identical_groups_first_and_sees_scoped_rows(): + """#2872: dup groups (the real copies) outrank name groups (usually + convention), wider spread first; #2869: scoped rows are in the readout.""" + from scribe.models.code_shape import CodeShape + from scribe.services.shape_ledger import proposal_summary + + def row(path, symbol, group, kind="css", status="scoped"): + return CodeShape(project_id=2, repo_key="r", path=path, symbol=symbol, kind=kind, + status=status, proposal_basis="derive", proposal_group=group) + rows = [ + # a name group of 6 across 6 files + *[row(f"v/{i}.vue", "status-badge", "name:css:status-badge") for i in range(6)], + # a dup group of 3 across 3 files (different selector names, one body) + row("v/Login.vue", "closed-msg", "dup:abc"), row("v/Reset.vue", "error-block", "dup:abc"), + row("v/Forgot.vue", "success-msg", "dup:abc"), + # a dup group of 2 in ONE file — a copy, but not across files + row("v/A.vue", "x", "dup:def"), row("v/A.vue", "y", "dup:def"), + # judged rows never count + row("v/J.vue", "closed-msg", "dup:abc", status="exempt"), + ] + out = proposal_summary(rows) + 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]["label"] == "closed-msg (identical body)" + + def test_confirm_requires_a_named_scope(): import asyncio -- 2.54.0 From 1209e1c2d96fcd9d2b6e0ddacabae6e41d1228ab Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 15:10:54 -0400 Subject: [PATCH 06/12] =?UTF-8?q?feat(ledger):=20a=20repo=20binding=20name?= =?UTF-8?q?s=20the=20branch=20its=20ledger=20follows=20=E2=80=94=20bind=5F?= =?UTF-8?q?repo(ref=3D)=20(#2873,=20milestone=20294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project 2 is bound to main, so every consolidation of the 2026-08 audit was invisible to the ledger until the dev→main merge; the operator works on dev (rule 1). repo_bindings.ref (migration 0082, nullable) is the branch the coverage refresh reads; NULL keeps the forge default branch. set_binding takes ref (name sets, "" clears, None leaves standing); bindings_for_project feeds the refresh; bind_repo exposes ref ("-" clears). to_dict carries it. Operator decision on #2873 (2026-08-21): per-binding ref, chosen at bind time, default the repo default branch. Co-Authored-By: Claude Fable 5 --- alembic/versions/0082_repo_binding_ref.py | 26 ++++++++++++++++++++ src/scribe/mcp/tools/repos.py | 21 +++++++++++++--- src/scribe/models/repo_binding.py | 5 ++++ src/scribe/services/coverage.py | 9 ++++--- src/scribe/services/repo_bindings.py | 26 ++++++++++++++++++-- tests/test_pattern_coverage.py | 30 ++++++++++++++++++++++- 6 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 alembic/versions/0082_repo_binding_ref.py diff --git a/alembic/versions/0082_repo_binding_ref.py b/alembic/versions/0082_repo_binding_ref.py new file mode 100644 index 0000000..30f4916 --- /dev/null +++ b/alembic/versions/0082_repo_binding_ref.py @@ -0,0 +1,26 @@ +"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294) + +Revision ID: 0082 +Revises: 0081 +Create Date: 2026-08-21 + +A repo binding used to imply the repo's default branch; the shape ledger +therefore only saw work after a merge to main, while the operator's work +lands on dev (rule 1). `ref` names the branch the coverage refresh reads — +NULL keeps today's behaviour (the forge's default branch). +""" +import sqlalchemy as sa +from alembic import op + +revision = "0082" +down_revision = "0081" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("repo_bindings", "ref") diff --git a/src/scribe/mcp/tools/repos.py b/src/scribe/mcp/tools/repos.py index f652587..a08ec3c 100644 --- a/src/scribe/mcp/tools/repos.py +++ b/src/scribe/mcp/tools/repos.py @@ -13,28 +13,43 @@ from scribe.services import projects as projects_svc from scribe.services import repo_bindings as repo_bindings_svc -async def bind_repo(repo_url: str, project_id: int) -> dict: +async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict: """Bind a git repository to a Scribe project for session-start context. After this, any session started in that repo auto-loads the project's context (the SessionStart hook sends the repo's remote; the server resolves it here). Idempotent — re-binding the same repo updates the target project. + The binding is also what the shape ledger reads (refresh_pattern_coverage): + `ref` names the branch it follows. Default (""): the repo's default branch + — which means the ledger only sees work after a merge. A dev-first project + (rule 1: dev is home) should bind with ref="dev" so classification follows + the push, not the merge. Re-binding with ref="" keeps the standing ref; + pass ref="-" to clear it back to the default branch. + Args: repo_url: the repo's git remote (e.g. the output of `git remote get-url origin` — ssh or https form, both work). project_id: the Scribe project this repo represents. + ref: branch the ledger follows ("" = leave as is / default branch on + a new binding; "-" = clear to the default branch). """ uid = current_user_id() project = await projects_svc.get_project(uid, project_id) if project is None: raise ValueError(f"project {project_id} not found") - binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id) + ref_arg = None if not ref else ("" if ref.strip() == "-" else ref) + binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id, ref_arg) + follows = binding.ref or "the default branch" return { "repo_key": binding.repo_key, "project_id": binding.project_id, "project_title": project.title, - "message": f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}).", + "ref": binding.ref, + "message": ( + f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}); " + f"the ledger follows {follows}." + ), } diff --git a/src/scribe/models/repo_binding.py b/src/scribe/models/repo_binding.py index 8686bcf..7be94eb 100644 --- a/src/scribe/models/repo_binding.py +++ b/src/scribe/models/repo_binding.py @@ -28,6 +28,10 @@ class RepoBinding(Base, TimestampMixin): Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False ) repo_key: Mapped[str] = mapped_column(Text, nullable=False) + # The branch the coverage refresh reads for this binding (#2873); NULL = + # the forge's default branch. Chosen at bind time so a dev-first project + # can have its ledger follow dev instead of waiting for the merge. + ref: Mapped[str | None] = mapped_column(Text, nullable=True) def to_dict(self) -> dict: return { @@ -35,6 +39,7 @@ class RepoBinding(Base, TimestampMixin): "user_id": self.user_id, "project_id": self.project_id, "repo_key": self.repo_key, + "ref": self.ref, "created_at": iso(self.created_at), "updated_at": iso(self.updated_at), } diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index a1e44fd..2d1db2d 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -36,7 +36,7 @@ from typing import NamedTuple from datetime import datetime, timedelta, timezone from scribe.services.forge import ForgeSelector, get_forges -from scribe.services.repo_bindings import keys_for_project +from scribe.services.repo_bindings import bindings_for_project from scribe.services.settings import get_setting, set_setting logger = logging.getLogger(__name__) @@ -407,12 +407,15 @@ async def compute_coverage( # the project's repos (#2792). canons = None proposer_stats = {"examined": 0, "proposed": 0, "semantic_checked": 0} - for key in await keys_for_project(user_id, project_id): + for binding in await bindings_for_project(user_id, project_id): + key = binding.repo_key hit = selector.resolve(key) if hit is None: continue # bound to a host no connection serves forge, api_repo = hit - ref = await forge.default_branch(api_repo) + # The binding's own ref when it names one (#2873: a dev-first project + # has its ledger follow dev), else the forge's default branch. + ref = binding.ref or await forge.default_branch(api_repo) definitions = definitions_from_archive(await forge.archive(api_repo, ref)) # 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 diff --git a/src/scribe/services/repo_bindings.py b/src/scribe/services/repo_bindings.py index 724a339..5d0b85b 100644 --- a/src/scribe/services/repo_bindings.py +++ b/src/scribe/services/repo_bindings.py @@ -68,8 +68,16 @@ async def resolve_project(user_id: int, raw_repo: str) -> int | None: return row.scalar_one_or_none() -async def set_binding(user_id: int, raw_repo: str, project_id: int) -> RepoBinding: - """Create or update the binding for a repo. Idempotent on (user, repo_key).""" +async def set_binding( + user_id: int, raw_repo: str, project_id: int, ref: str | None = None, +) -> RepoBinding: + """Create or update the binding for a repo. Idempotent on (user, repo_key). + + ``ref`` (#2873) is the branch the coverage refresh reads for this + binding: a name sets it, ``""`` clears it back to the forge's default + branch, ``None`` leaves whatever stands (a re-bind that only moves the + project keeps the ref it had). + """ key = normalize_repo_key(raw_repo) if not key: raise ValueError("repo remote is empty or unparseable") @@ -85,6 +93,8 @@ async def set_binding(user_id: int, raw_repo: str, project_id: int) -> RepoBindi session.add(binding) else: binding.project_id = project_id + if ref is not None: + binding.ref = ref.strip() or None await session.commit() await session.refresh(binding) return binding @@ -100,6 +110,18 @@ async def list_bindings(user_id: int) -> list[RepoBinding]: return list(rows.scalars().all()) +async def bindings_for_project(user_id: int, project_id: int) -> list[RepoBinding]: + """Every binding of a project — key AND the ref its ledger follows (#2873).""" + async with async_session() as session: + rows = await session.execute( + select(RepoBinding).where( + RepoBinding.user_id == user_id, + RepoBinding.project_id == project_id, + ).order_by(RepoBinding.repo_key) + ) + return list(rows.scalars().all()) + + async def keys_for_project(user_id: int, project_id: int) -> list[str]: """Every repo key bound to a project — the snippet→forge join (#2691). diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index 5bdd4fd..0d1c0cb 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.py @@ -168,6 +168,13 @@ def test_coverage_line_is_evidence_carrying_and_labeled_estimate(): assert "internal/api, web/src/components" in line +def test_bind_repo_tool_takes_a_ref(): + """#2873: the binding names the branch the ledger follows.""" + from scribe.mcp.server import build_mcp_server + tool = build_mcp_server()._tool_manager.get_tool("bind_repo") + assert "ref" in tool.parameters.get("properties", {}) + + def test_coverage_routes_are_registered(): from scribe.app import create_app @@ -188,7 +195,8 @@ def _forge(tar_bytes: bytes): path = request.url.path if path == "/api/v1/repos/alice/widget": return httpx.Response(200, json={"default_branch": "main"}) - if path == "/api/v1/repos/alice/widget/archive/main.tar.gz": + if path in ("/api/v1/repos/alice/widget/archive/main.tar.gz", + "/api/v1/repos/alice/widget/archive/dev.tar.gz"): return httpx.Response(200, content=tar_bytes) return httpx.Response(404, json={"message": "not found"}) @@ -533,3 +541,23 @@ def test_scoped_definitions_are_vue_script_setup_and_scoped_style_only(): extract_definitions(".card {\n x: 1;\n}\n")) == set() assert scoped_definitions("src/a.py", "def load():\n pass\n", extract_definitions("def load():\n pass\n")) == set() + +@pytest.mark.integration +async def test_binding_ref_is_the_branch_the_ledger_follows(seeded): + """#2873: a binding that names a ref is read at that ref (not the forge's + default branch); "" clears it; None on a re-bind leaves it standing.""" + from scribe.services.repo_bindings import bindings_for_project, set_binding + uid, pid = seeded["uid"], seeded["pid"] + b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid, "dev") + assert b.ref == "dev" + coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE))) + assert coverage["repos"][0]["ref"] == "dev" + # A re-bind without a ref keeps it; "" clears it back to the default branch. + b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid) + assert b.ref == "dev" + b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid, "") + assert b.ref is None + assert [x.ref for x in await bindings_for_project(uid, pid)] == [None] + coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE))) + assert coverage["repos"][0]["ref"] == "main" + -- 2.54.0 From d01201539b9a1480c25b4c1d9464d6c5d90fbf19 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 15:11:25 -0400 Subject: [PATCH 07/12] =?UTF-8?q?test(ledger):=20derive=20summary=20expect?= =?UTF-8?q?ation=20follows=20#2872=20=E2=80=94=20dup=20group=20before=20na?= =?UTF-8?q?me=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- tests/test_integration_shape_classify.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index c4f0f32..8b74470 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -531,7 +531,11 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded): summary = proposal_summary(await live_rows(pid)) assert summary["proposed"] == 1 - assert [g["group"] for g in summary["derive_groups"]][0] == "name:css:card" + # #2872: the body-identical group (a real copy) outranks the bigger name + # group (usually convention), even at size 2 vs 3. + order = [g["group"] for g in summary["derive_groups"]] + assert order[0].startswith("dup:") and order[1] == "name:css:card" + assert summary["derive_groups"][0]["files"] == 2 assert summary["derive_groups"][0]["label"] == ".card" assert summary["derive_groups"][0]["size"] == 3 -- 2.54.0 From 1a8e5787e83b50c16dafcf8e323a9581b7c7bea9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 15:14:34 -0400 Subject: [PATCH 08/12] feat(ledger): reason codes, case-insensitive repo filter, next action on the coverage line (#2874, milestone 294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - code_shapes.reason_code (migration 0083): an optional code from a fixed catalogue (scoped-css, one-off-handler, test-helper, convention-plumbing, pure-helper, generated, script, typed-record) beside the prose reason, so the ledger can be filtered/aggregated by kind of one-off; validated in classify_shapes and classify_shapes_by_rule; on to_dict/to_compact. - Snippet location lookups match repo case-insensitively in both dialects (location_matches / location_jsonpath via like_regex flag "i") — "Scribe" vs "FabledScribe" vs "fabledscribe" recorded free-form hid half the canon from list_snippets(repo=, path=). - coverage line names the next action: "top canon #N ×k" (biggest proposal queue) and "top copy