From 2324c15418274c12c28851ade124f8e371c15630 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 22 Aug 2026 13:30:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(write-path):=20the=20derive=20arm=20?= =?UTF-8?q?=E2=80=94=20the=20hint=20names=20a=20duplicate=20family=20(no?= =?UTF-8?q?=20canon)=20or=20a=20canon=20elsewhere=20for=20the=20shapes=20b?= =?UTF-8?q?eing=20written;=20exclude=5Fderive=20channel=20(#2900,=20milest?= =?UTF-8?q?one=20299=20step=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shape_ledger.write_time_derive asks the ledger what it knows about each named (kind, symbol): a derive-grouped family (identical body / same name in N other files) -> "derive it now, do not add a copy"; a canonical row at another path -> "canon #N at , reuse". Judged rows at the path and the canon own file stay silent. Rendered by _derive_line beside the divergence line; keyed (group id / canon:) on a third per-session dedup channel in the hook (.derive.ids -> exclude_derive=). Co-Authored-By: Claude Fable 5 --- plugin/hooks/scribe_prior_art.sh | 17 +++- src/scribe/routes/plugin.py | 8 ++ src/scribe/services/plugin_context.py | 51 ++++++++++- src/scribe/services/shape_ledger.py | 69 ++++++++++++++ tests/test_integration_shape_classify.py | 43 +++++++++ tests/test_write_path_trigger.py | 110 +++++++++++++++++++++++ 6 files changed, 295 insertions(+), 3 deletions(-) diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh index bc8276c..8581a8c 100755 --- a/plugin/hooks/scribe_prior_art.sh +++ b/plugin/hooks/scribe_prior_art.sh @@ -258,14 +258,22 @@ fi # the sync nudge when the recorded file itself is edited later. state_dir="${TMPDIR:-/tmp}/scribe-priorart" mkdir -p "$state_dir" 2>/dev/null || true +# +# A THIRD channel (#2900): the ledger's derive arm names a duplicate family +# (a derive group id) or a canon elsewhere (`canon:`) for the +# shapes being written. Keyed by that token, not a note id, so it dedups on +# its own file and a family is named once per session, not at every edit. idfile="" syncfile="" +derivefile="" exclude_q="" sync_exclude_q="" +derive_exclude_q="" if [ -n "$session_id" ]; then safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_') idfile="$state_dir/${safe_sid}.ids" syncfile="$state_dir/${safe_sid}.sync.ids" + derivefile="$state_dir/${safe_sid}.derive.ids" if [ -f "$idfile" ]; then seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//') [ -n "$seen" ] && exclude_q="&exclude_ids=${seen}" @@ -274,13 +282,17 @@ if [ -n "$session_id" ]; then sync_seen=$(tr '\n' ',' < "$syncfile" 2>/dev/null | sed 's/,$//') [ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}" fi + if [ -f "$derivefile" ]; then + derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen="" + [ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}" + fi fi # `|| true`, not `|| exit 0`: an unreachable instance must not discard a local # finding that needed no instance to produce. body=$(curl -fsS --max-time 5 \ -H "Authorization: Bearer ${token}" \ - "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${shapes_q}" 2>/dev/null) || body="" + "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}" 2>/dev/null) || body="" context="" if [ -n "$body" ]; then @@ -295,6 +307,9 @@ if [ -n "$body" ]; then if [ -n "$syncfile" ]; then printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true fi + if [ -n "$derivefile" ]; then + printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true + fi fi fi diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index 71e48b5..51f0816 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -129,6 +129,10 @@ async def write_path_prior_art(): surfaced. A separate channel on purpose: a reuse hint shown early must not suppress the record-sync nudge when the recorded file is edited later. + exclude_derive (opt) — comma-separated derive keys (a derive group id + or `canon:`) already named this + session by the ledger arm (#2900); its own + channel, like the two above. shapes (opt) — comma-separated `kind:name` definitions the hook found in (or enclosing) the payload, kind being css|sym. The shape ledger's write-path feed @@ -144,6 +148,9 @@ async def write_path_prior_art(): project_id, repo, _unbound = await _project_scope() exclude_ids = _int_list(request.args.get("exclude_ids")) exclude_sync_ids = _int_list(request.args.get("exclude_sync_ids")) + exclude_derive = [ + p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip() + ] shapes = _parse_shapes(request.args.get("shapes") or "") api_key = getattr(g, "api_key", None) may_stamp = api_key is None or getattr(api_key, "scope", "") == "write" @@ -153,6 +160,7 @@ async def write_path_prior_art(): exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids, stamp_shapes=shapes if may_stamp else None, repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "", + exclude_derive=exclude_derive, ) return jsonify(result) diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index 15d8e2e..5b7eefb 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -706,6 +706,7 @@ async def build_write_path_hint( exclude_sync_ids: list[int] | None = None, stamp_shapes: list[tuple[str, str]] | None = None, repo_key: str = "", + exclude_derive: list[str] | None = None, ) -> dict: """Prior-art hint for the plugin's PreToolUse hook on Write/Edit. @@ -765,7 +766,7 @@ async def build_write_path_hint( """ cfg = await get_writepath_config(user_id) empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg, - "stamped": [], "divergence": []} + "stamped": [], "divergence": [], "derive": [], "derive_keys": []} path = (path or "").strip() if not cfg["enabled"] or not path: return empty @@ -935,7 +936,20 @@ async def build_write_path_hint( ) except Exception: logger.warning("write-time divergence check failed", exc_info=True) - if not synced and not menu and not stamped and not divergence: + # The in-band DERIVE check (#2900): the ledger's own knowledge of the + # names being written — a duplicate family with no canon, or a canon + # recorded elsewhere. This is the arm the by-name local grep could not + # be: it knows whether the other copies are canon or stray. Keyed per + # session (`exclude_derive`) so a family is named once, not per edit. + derive: list[dict] = [] + if stamp_shapes and project_id: + try: + found = await shape_ledger_svc.write_time_derive(project_id, path, stamp_shapes) + skip = set(exclude_derive or []) + derive = [d for d in found if d.get("key") not in skip] + except Exception: + logger.warning("write-time derive check failed", exc_info=True) + if not synced and not menu and not stamped and not divergence and not derive: return empty owners = await owner_names_for({ @@ -1003,6 +1017,8 @@ async def build_write_path_hint( lines.append(_stamp_line(path, stamped)) if divergence: lines.append(_divergence_line(path, divergence)) + if derive: + lines.append(_derive_line(path, derive)) # Split by arm, which is the whole reason this table exists. The place arm # carries no score and so has no home in retrieval_logs; before #2085 a @@ -1027,9 +1043,40 @@ async def build_write_path_hint( "config": cfg, "stamped": stamped, "divergence": divergence, + "derive": derive, + "derive_keys": [d["key"] for d in derive], } +def _derive_line(path: str, derive: list[dict]) -> str: + """The ledger's word on the names being written (#2900): a duplicate + family to derive, or a canon to reuse — said at the write.""" + parts = [] + for d in derive: + if d.get("canon"): + c = d["canon"] + parts.append( + f"`{c['label']}` is canon — snippet #{c['snippet_id']} at `{c['path']}`; " + "pull it and reuse, don't redefine" + ) + continue + f = d["family"] + how = "identical body" if f.get("identical") else "same name defined" + files = ", ".join(f"`{x}`" for x in f.get("files") or []) + more = f.get("file_count", 0) - len(f.get("files") or []) + if more > 0: + files += f" +{more} more" + parts.append( + f"`{f['label']}` is a duplicate family with no canon — {how} in " + f"{f.get('file_count', 0)} other file(s): {files}; derive it now: " + "record the canon (create_snippet) and make the copies instances " + "(classify_shapes) — or, if these are convention not copies, " + "`classify_shapes(..., status=\"exempt\", reason_code=\"convention-plumbing\")` " + "dismisses the family — rather than adding another copy" + ) + return f"> Shape ledger at `{path}`: " + "; ".join(parts) + "." + + def _divergence_line(path: str, divergence: list[dict]) -> str: """Button B where button A is canon — named at the write (#2793).""" parts = [ diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index 59f9c27..e7520d1 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -1594,6 +1594,75 @@ async def write_time_divergence( return out +# How many other files a family line names before "…" — enough to go look, +# not a wall. +_DERIVE_FILES_SHOWN = 4 + + +async def write_time_derive( + project_id: int, path: str, shapes: list[tuple[str, str]] +) -> list[dict]: + """The in-band DERIVE check (#2900): for each (kind, name) the hook + named at ``path``, what the ledger already knows about that name + elsewhere in the project — + + family the name sits in a derive-first group (identical body in N + files, or the same name in ≥3): "this is a known duplicate + family with no canon — derive it now, don't add a copy"; + canon a `canonical` row of that name at another path: "this is + canon #N at — reuse, don't redefine". + + Only for shapes not yet judged at ``path`` (a judged shape is not + re-litigated at every edit), never for the canon's own file. Returns + [{symbol, kind, key, family?|canon?}] — `key` is the dedup token the + hook keeps per session (the group id, or canon:).""" + wanted = {(k, _norm_symbol(n)): n for k, n in shapes if n} + if not wanted: + return [] + async with async_session() as session: + rows = ( + await session.execute( + select(CodeShape).where( + CodeShape.project_id == project_id, + CodeShape.vanished_at.is_(None), + CodeShape.symbol.in_({norm for (_k, norm) in wanted}), + ) + ) + ).scalars().all() + out: list[dict] = [] + for (kind, norm), name in wanted.items(): + same = [r for r in rows if r.kind == kind and _norm_symbol(r.symbol) == norm] + here = next((r for r in same if r.path == path), None) + if here is not None and here.status not in _MECHANICAL_TODO: + continue # judged here (or this IS the canon): nothing to say + others = [r for r in same if r.path != path] + label = ("." if kind == "css" else "") + name + canon = next((r for r in others if r.status == "canonical" and r.snippet_id), None) + if canon is not None: + out.append({"symbol": name, "kind": kind, "key": f"canon:{canon.snippet_id}", + "canon": {"snippet_id": canon.snippet_id, "path": canon.path, + "label": label}}) + continue + grouped = [r for r in others if r.proposal_group and r.status in _MECHANICAL_TODO] + if here is not None and here.proposal_group: + grouped = [r for r in grouped if r.proposal_group == here.proposal_group] or grouped + if not grouped: + continue + group = grouped[0].proposal_group + members = [r for r in grouped if r.proposal_group == group] + files = sorted({r.path for r in members}) + out.append({ + "symbol": name, "kind": kind, "key": group, + "family": { + "group": group, "label": label, + "identical": not group.startswith("name:"), + "files": files[:_DERIVE_FILES_SHOWN], "file_count": len(files), + "size": len(members) + (1 if here is not None else 0), + }, + }) + return out + + async def flag_divergence(project_id: int, *, since: datetime | None) -> int: """Flag shapes created after ``since`` (the previous refresh) that sit where a canon dominates and were not proposed as that canon. With no diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index c4281ba..ad9b231 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -597,6 +597,49 @@ 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_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded): + """#2900: against real rows — a name in a dup family → the family (other + files, count); a name whose canonical row lives elsewhere → that canon; + a judged row at the path, the canon's own file, or an unknown name → + silence.""" + from scribe.services.shape_ledger import apply_derive_groups, write_time_derive + + owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] + defs = _defs( + ("v/A.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"), + ("v/B.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"), + ("v/C.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"), + ("src/factory.py", "sym", "factory", "def factory():", "def factory():\n return 1"), + ) + await sync_repo_shapes(pid, REPO, defs, seen_marker="m1") + await classify_shapes(owner, pid, [ + {"path": "src/factory.py", "symbol": "factory", "status": "canonical", "snippet_id": sid}, + ]) + assert await apply_derive_groups(pid) >= 3 + + # A 4th copy about to be written → the family, naming the other files. + out = await write_time_derive(pid, "v/D.vue", [("css", "log-empty"), ("css", "unknown")]) + assert len(out) == 1 and out[0]["symbol"] == "log-empty" and out[0]["kind"] == "css" + fam = out[0]["family"] + assert fam["identical"] is True and fam["label"] == ".log-empty" + assert fam["files"] == ["v/A.vue", "v/B.vue", "v/C.vue"] and fam["file_count"] == 3 + assert out[0]["key"] == fam["group"] and fam["group"].startswith("dup:") + # Editing one existing member still names the OTHER members. + out = await write_time_derive(pid, "v/A.vue", [("css", "log-empty")]) + assert out[0]["family"]["files"] == ["v/B.vue", "v/C.vue"] and out[0]["family"]["size"] == 3 + # The canon's name elsewhere → the canon; in the canon's own file → silence. + out = await write_time_derive(pid, "src/other.py", [("sym", "factory")]) + assert out == [{"symbol": "factory", "kind": "sym", "key": f"canon:{sid}", + "canon": {"snippet_id": sid, "path": "src/factory.py", "label": "factory"}}] + assert await write_time_derive(pid, "src/factory.py", [("sym", "factory")]) == [] + # A judged row at the path is not re-litigated. + await classify_shapes(owner, pid, [ + {"path": "v/B.vue", "symbol": "log-empty", "status": "exempt", "reason": "print sheet"}, + ]) + assert await write_time_derive(pid, "v/B.vue", [("css", "log-empty")]) == [] + + @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 diff --git a/tests/test_write_path_trigger.py b/tests/test_write_path_trigger.py index fa9a905..72ac4f4 100644 --- a/tests/test_write_path_trigger.py +++ b/tests/test_write_path_trigger.py @@ -1243,6 +1243,116 @@ def test_hook_sends_the_enclosing_definition_for_a_body_edit(tmp_path): assert seen["shapes"] == ["sym:onTrash"] +@pytest.mark.asyncio +async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band(): + """#2900: the ledger's own word on the names being written — a duplicate + family with no canon, or a canon recorded elsewhere — rendered at the + write even when nothing else does; keyed so the session's exclude + channel silences a family already named.""" + from scribe.services import plugin_context as pc + found = [ + {"symbol": "log-empty", "kind": "css", "key": "dup:483a", + "family": {"group": "dup:483a", "label": ".log-empty", "identical": True, + "files": ["a/TaskLogSection.vue", "a/WorkspaceTaskPanel.vue"], + "file_count": 5, "size": 6}}, + {"symbol": "btn-primary", "kind": "css", "key": "canon:2855", + "canon": {"snippet_id": 2855, "path": "frontend/src/assets/components.css", + "label": ".btn-primary"}}, + {"symbol": "load", "kind": "sym", "key": "name:sym:load", + "family": {"group": "name:sym:load", "label": "load", "identical": False, + "files": ["a/X.vue", "a/Y.vue", "a/Z.vue"], "file_count": 3, "size": 4}}, + ] + check = AsyncMock(return_value=found) + patches = dict( + get_writepath_config=AsyncMock(return_value=_cfg()), + semantic_search_notes=AsyncMock(return_value=[]), + record_retrieval=MagicMock(), owner_names_for=AsyncMock(return_value={}), + ) + with patch.multiple(pc, **patches), \ + patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \ + patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \ + patch.object(pc.shape_ledger_svc, "write_time_divergence", AsyncMock(return_value=[])), \ + patch.object(pc.shape_ledger_svc, "write_time_derive", check): + out = await pc.build_write_path_hint( + 1, "frontend/src/components/New.vue", code=REAL_CODE, project_id=24, + stamp_shapes=[("css", "log-empty"), ("css", "btn-primary"), ("sym", "load")], + exclude_derive=["name:sym:load"], + ) + check.assert_awaited_once_with(24, "frontend/src/components/New.vue", + [("css", "log-empty"), ("css", "btn-primary"), ("sym", "load")]) + # The excluded family is gone; the other two render and are keyed. + assert [d["key"] for d in out["derive"]] == ["dup:483a", "canon:2855"] + assert out["derive_keys"] == ["dup:483a", "canon:2855"] + ctx = out["context"] + assert "Shape ledger at `frontend/src/components/New.vue`" in ctx + assert "`.log-empty` is a duplicate family with no canon — identical body in 5 other file(s): " \ + "`a/TaskLogSection.vue`, `a/WorkspaceTaskPanel.vue` +3 more; derive it now" in ctx + assert "`.btn-primary` is canon — snippet #2855 at `frontend/src/assets/components.css`" in ctx + assert "convention-plumbing" in ctx + assert "`load`" not in ctx + # No project → no check; a failing check never sinks the hint. + check.reset_mock() + with patch.multiple(pc, **patches), \ + patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \ + patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \ + patch.object(pc.shape_ledger_svc, "write_time_divergence", AsyncMock(return_value=[])), \ + patch.object(pc.shape_ledger_svc, "write_time_derive", check): + out = await pc.build_write_path_hint(1, "x.py", code=REAL_CODE, stamp_shapes=[("sym", "f")]) + check.assert_not_awaited() + assert out["derive"] == [] and out["derive_keys"] == [] + boom = AsyncMock(side_effect=RuntimeError("ledger down")) + with patch.multiple(pc, **patches), \ + patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \ + patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \ + patch.object(pc.shape_ledger_svc, "write_time_divergence", AsyncMock(return_value=[])), \ + patch.object(pc.shape_ledger_svc, "write_time_derive", boom): + out = await pc.build_write_path_hint(1, "x.py", code=REAL_CODE, project_id=24, + stamp_shapes=[("sym", "f")]) + assert out["context"] == "" and out["derive"] == [] + + +def test_the_hook_keeps_a_derive_channel_and_sends_it_back(tmp_path): + """#2900: derive keys the server returns land in the session's own + `.derive.ids` file and go back as `exclude_derive` on the next write — + a family is named once per session, not at every edit.""" + import http.server + import threading + import urllib.parse + + seen: list[dict] = [] + + class _Sink(http.server.BaseHTTPRequestHandler): + def do_GET(self): + seen.append(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"context":"> family","note_ids":[],"sync_note_ids":[],' + b'"derive_keys":["dup:483a","canon:2855"]}') + + def log_message(self, *a): + pass + + server = http.server.HTTPServer(("127.0.0.1", 0), _Sink) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{server.server_port}", + TMPDIR=str(tmp_path)) + payload = {"session_id": "s-derive-1", "cwd": str(tmp_path), "tool_name": "Write", + "tool_input": {"file_path": str(tmp_path / "a.css"), + "content": ".log-empty {\n color: red;\n}\n"}} + for _ in range(2): + out = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload), + capture_output=True, text=True, env=env) + assert out.returncode == 0, out.stderr + finally: + server.shutdown() + assert "exclude_derive" not in seen[0] + assert seen[1]["exclude_derive"] == ["dup:483a,canon:2855"] + state = tmp_path / "scribe-priorart" / "s-derive-1.derive.ids" + assert state.read_text().split() == ["dup:483a", "canon:2855", "dup:483a", "canon:2855"] + + @pytest.mark.asyncio async def test_the_write_time_divergence_check_is_named_in_band(): """#2793: the hook named a shape at a path whose directory a canon