diff --git a/src/scribe/mcp/tools/shapes.py b/src/scribe/mcp/tools/shapes.py index 753a597..9476cc7 100644 --- a/src/scribe/mcp/tools/shapes.py +++ b/src/scribe/mcp/tools/shapes.py @@ -295,7 +295,9 @@ async def refresh_pattern_coverage(project_id: int) -> dict: Returns the accounting payload — total, accounted, counts by status, unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting confirmation), `derive_groups` (the biggest repeats-with-no-canon - families), `proposer` (what this refresh examined) — plus + families), `derive_new` (copies that joined a family since the previous + refresh — the drift to act on now: derive the canon, don't queue an + audit), `proposer` (what this refresh examined) — plus `pattern_coverage`, the same one-line summary enter_project carries. """ uid = current_user_id() diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index 3c3565f..6dfd381 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -462,15 +462,20 @@ async def compute_coverage( await shape_ledger.apply_derive_groups(project_id) except Exception: logger.warning("derive-first grouping failed", exc_info=True) - # The button-B pass (#2793): shapes new since the PREVIOUS computation, - # where a canon dominates. The previous computation's stamp is the cache; - # a first seed has none, so it flags nothing (everything is new then). + # "Since the previous computation" — the cache's stamp. A first seed has + # none, so nothing is new then. Read once; two passes use it: the + # button-B flag (#2793) and the derive-new drift count (#2899). + since = None try: previous = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}") - since = None if previous: stamp = (json.loads(previous) or {}).get("computed_at") since = datetime.fromisoformat(stamp) if stamp else None + except Exception: + logger.warning("previous coverage stamp unreadable", exc_info=True) + # The button-B pass (#2793): shapes new since the PREVIOUS computation, + # where a canon dominates. + try: await shape_ledger.flag_divergence(project_id, since=since) except Exception: logger.warning("divergence pass failed", exc_info=True) @@ -491,6 +496,7 @@ async def compute_coverage( unclassified = counts.pop("unclassified") proposals = shape_ledger.proposal_summary(rows) divergence = shape_ledger.divergence_summary(rows) + derive_new = shape_ledger.derive_new_summary(rows, since=since) return { "total": len(rows), "accounted": len(rows) - unclassified, @@ -501,6 +507,10 @@ async def compute_coverage( "proposed": proposals["proposed"], "derive_groups": proposals["derive_groups"], "top_canon": proposals.get("top_canon"), + # Drift since the previous refresh (#2899): copies that joined a + # duplicate family — what the arrival line names so drift is noticed + # on entering, not found by an audit. + "derive_new": derive_new, "proposer": proposer_stats, # The divergence readout (#2793): button B where button A is canon, # and judged shapes whose bodies moved since they were judged. @@ -648,29 +658,46 @@ def coverage_line(coverage: dict) -> str: line += f" — {breakdown}" line += f" (estimate{', computed ' + day if day else ''})" unclassified = coverage.get("unclassified", 0) + # The standing work, built whatever the todo count (#2899). Since the + # scoped bucket (#2869) a ledger can read 100% accounted and still carry + # derive groups, proposals and divergence; gating this block on + # `unclassified > 0` is how 439 derive rows went unmentioned. + standing = [] + if coverage.get("proposed"): + standing.append(f"{coverage['proposed']} proposed") + n_groups = len(coverage.get("derive_groups") or []) + if n_groups: + standing.append(f"{n_groups} derive group{'s' if n_groups != 1 else ''}") + # Drift since the previous refresh: copies that joined a family, the + # first one named — the sentence the arrival moment exists to say. + new = coverage.get("derive_new") or {} + if new.get("count"): + n = new["count"] + first_new = (new.get("examples") or [{}])[0] + where = ( + f": {first_new['label']} in {first_new['path']}" + if first_new.get("label") and first_new.get("path") else "" + ) + standing.append(f"+{n} new cop{'y' if n == 1 else 'ies'} since last refresh{where}") + if coverage.get("divergent"): + standing.append(f"{coverage['divergent']} DIVERGENT") + # The next action, on the line (#2874): the canon with the biggest + # queue to confirm, and the widest body-identical copy to consolidate. + top = coverage.get("top_canon") or {} + if top.get("snippet_id"): + standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}") + first = (coverage.get("derive_groups") or [{}])[0] + if first.get("label") and first.get("files"): + standing.append(f"top copy {first['label']} ×{first['files']} files") if unclassified: line += f"; {unclassified} unclassified" - standing = [] - if coverage.get("proposed"): - standing.append(f"{coverage['proposed']} proposed") - n_groups = len(coverage.get("derive_groups") or []) - if n_groups: - standing.append(f"{n_groups} derive group{'s' if n_groups != 1 else ''}") - if coverage.get("divergent"): - standing.append(f"{coverage['divergent']} DIVERGENT") - # The next action, on the line (#2874): the canon with the biggest - # queue to confirm, and the widest body-identical copy to consolidate. - top = coverage.get("top_canon") or {} - if top.get("snippet_id"): - standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}") - first = (coverage.get("derive_groups") or [{}])[0] - if first.get("label") and first.get("files"): - standing.append(f"top copy {first['label']} ×{first['files']} files") if standing: line += f" ({', '.join(standing)})" gaps = [g["dir"] for g in coverage.get("largest_gaps") or []] if gaps: line += ", largest: " + ", ".join(gaps) + elif standing: + line += f"; standing: {', '.join(standing)}" if coverage.get("recheck"): line += f"; {coverage['recheck']} judged shape{'s' if coverage['recheck'] != 1 else ''} changed since judged — recheck" return line diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index 454501a..59f9c27 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -1404,6 +1404,34 @@ def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict: return {"proposed": proposed, "derive_groups": ranked[:top], "top_canon": top_canon} +def derive_new_summary( + rows: Iterable[CodeShape], *, since: datetime | None, top: int = 3 +) -> dict: + """The arrival-moment drift signal (#2899): derive-grouped rows FIRST + SEEN after ``since`` — the previous refresh's stamp, the same one + flag_divergence uses. "Since the last refresh, N more copies joined a + duplicate family" is the sentence that makes the derive queue a thing + you notice on entering, not a thing an audit finds. ``since`` None (a + first seed) means nothing is new. Judged rows never count.""" + if since is None: + return {"count": 0, "examples": []} + fresh = [ + r for r in rows + if r.proposal_basis == "derive" and r.proposal_group + and r.status in _MECHANICAL_TODO and r.vanished_at is None + and r.created_at is not None and r.created_at > since + ] + fresh.sort(key=lambda r: r.created_at, reverse=True) + return { + "count": len(fresh), + "examples": [ + {"label": ("." if r.kind == "css" else "") + r.symbol, + "path": r.path, "group": r.proposal_group} + for r in fresh[:top] + ], + } + + async def confirm_proposals( user_id: int, project_id: int, diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index 83e436e..c4281ba 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -597,6 +597,39 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded): assert {r.symbol for r in rows} == {"slug"} # 2 files < the name floor +@pytest.mark.integration +async def test_derive_new_names_the_copy_that_joined_a_family_since_the_stamp(seeded): + """#2899: the first sync seeds one `slug`; a later sync adds an identical + copy. Against the stamp between them, derive_new counts ONLY the + newcomer — the drift since the last refresh, not the whole family.""" + from datetime import datetime, timezone + + from scribe.services.shape_ledger import ( + apply_derive_groups, derive_new_summary, live_rows, + ) + + owner, pid = seeded["owner"], seeded["pid"] + first = _defs( + ("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"), + ) + await sync_repo_shapes(pid, REPO, first, seen_marker="m1") + stamp = datetime.now(timezone.utc) + second = _defs( + ("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"), + ("a/two.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"), + ) + await sync_repo_shapes(pid, REPO, second, seen_marker="m2") + assert await apply_derive_groups(pid) == 2 + + rows = await live_rows(pid) + out = derive_new_summary(rows, since=stamp) + assert out["count"] == 1 + assert out["examples"][0]["path"] == "a/two.py" + assert out["examples"][0]["label"] == "slug" + assert out["examples"][0]["group"].startswith("dup:") + assert derive_new_summary(rows, since=None)["count"] == 0 + + # --- #2793: the divergence readout against real rows ------------------------- diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index c82b7eb..ab70205 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.py @@ -276,6 +276,8 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded): assert coverage["largest_gaps"] == [ {"dir": "src", "unclassified": 2, "total": 3} ] + # #2899: a first computation has no previous stamp — nothing is "new". + assert coverage["derive_new"] == {"count": 0, "examples": []} # The walk fed the LEDGER (#2788): every extracted shape has a row, the # snippet reference locations are mechanically stamped canonical WITH @@ -512,6 +514,41 @@ def test_coverage_line_names_the_proposers_standing(): assert "top canon #2844 ×78" in line and "top copy closed-msg (identical body) ×3 files" in line +def test_coverage_line_shows_standing_work_even_with_nothing_unclassified(): + """#2899: since the scoped bucket a ledger can be fully accounted and + still carry derive groups / proposals / divergence — the line names + them as `standing:` instead of hiding them behind the todo count, and + names the drift since the previous refresh first-copy-first.""" + from scribe.services.coverage import coverage_line + + base = { + "total": 4693, "accounted": 4693, "unclassified": 0, + "counts": {"canonical": 37, "instance": 977, "variant": 73, "exempt": 1797, "scoped": 1809}, + "computed_at": "2026-08-22T00:00:00+00:00", "largest_gaps": [], + } + quiet = coverage_line(base) + assert "unclassified" not in quiet and "standing" not in quiet + line = coverage_line({ + **base, + "derive_groups": [{"group": "dup:abc", "label": "log-empty (identical body)", "files": 4}], + "derive_new": {"count": 2, "examples": [ + {"label": ".error-msg", "path": "frontend/src/components/InceptionCard.vue", "group": "dup:9f0"}, + {"label": ".error-msg", "path": "frontend/src/components/Other.vue", "group": "dup:9f0"}, + ]}, + "divergent": 1, + }) + assert "; standing: 1 derive group, +2 new copies since last refresh: .error-msg in " \ + "frontend/src/components/InceptionCard.vue, 1 DIVERGENT, top copy log-empty (identical body) ×4 files" in line + assert "unclassified" not in line + # One copy reads singular; with a todo the block keeps its old place. + one = coverage_line({**base, "derive_new": {"count": 1, "examples": []}}) + assert one.endswith("; standing: +1 new copy since last refresh") + todo = coverage_line({**base, "unclassified": 3, "accounted": 4690, "proposed": 2, + "derive_new": {"count": 1, "examples": [{"label": "x", "path": "a.py"}]}, + "largest_gaps": [{"dir": "src", "unclassified": 3, "total": 9}]}) + assert "; 3 unclassified (2 proposed, +1 new copy since last refresh: x in a.py), largest: src" in todo + + def test_coverage_line_names_divergence_and_recheck(): from scribe.services.coverage import coverage_line diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index cb07e65..faffe11 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -317,6 +317,38 @@ def test_derive_groups_copy_before_name_with_floors(): assert ("i.py", "sym", "one") not in g +def test_derive_new_summary_counts_copies_first_seen_since_the_previous_refresh(): + """#2899: the arrival-moment drift signal — derive-grouped rows created + after the previous refresh's stamp, newest first, judged rows and a + first seed (since=None) never count.""" + from datetime import datetime, timedelta, timezone + + from scribe.models.code_shape import CodeShape + from scribe.services.shape_ledger import derive_new_summary + + t0 = datetime(2026, 8, 22, 12, 0, tzinfo=timezone.utc) + + def row(path, symbol, at, kind="css", status="scoped", group="dup:abc", basis="derive"): + r = CodeShape(project_id=2, repo_key="r", path=path, symbol=symbol, kind=kind, + status=status, proposal_basis=basis, proposal_group=group) + r.created_at = at + return r + rows = [ + row("v/Old.vue", "error-msg", t0 - timedelta(days=3)), # before the stamp + row("v/InceptionCard.vue", "error-msg", t0 + timedelta(hours=1)), # new copy + row("v/Other.vue", "error-msg", t0 + timedelta(hours=2)), # newer copy + row("v/J.vue", "error-msg", t0 + timedelta(hours=3), status="exempt"), # judged: never + row("s/a.py", "load", t0 + timedelta(hours=1), kind="sym", group=None, basis=None), # no family + ] + out = derive_new_summary(rows, since=t0) + assert out["count"] == 2 + assert [e["path"] for e in out["examples"]] == ["v/Other.vue", "v/InceptionCard.vue"] + assert out["examples"][0] == {"label": ".error-msg", "path": "v/Other.vue", "group": "dup:abc"} + assert derive_new_summary(rows, since=None) == {"count": 0, "examples": []} + assert derive_new_summary(rows, since=t0, top=1)["examples"] == [ + {"label": ".error-msg", "path": "v/Other.vue", "group": "dup:abc"}] + + 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."""