From fe63f3985b081fca4c57092d3dca6c6edc1dc451 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 28 Jul 2026 18:42:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(snippets):=20un-merge=20=E2=80=94=20revers?= =?UTF-8?q?e=20one=20source=20out=20of=20a=20merged=20survivor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last of milestone #232. The task said to settle the design before coding; here is what was settled and why. THE HAZARD. Restoring a merged-in source from the trash brought the record back but never stripped its locations off the survivor, so both claimed the same call sites and the reverse lookup read the duplicate claims as real. Subtracting blindly is not a fix: a location can arrive from a source AND genuinely be the survivor's own, and _normalize_locations dedups them into one, so blind subtraction would strip a call site the survivor owns. Same problem defeated partial un-merge — `merged_from` recorded ids, not which locations came from which source. THE ANSWER. Record per-source attribution AT MERGE TIME, where it is known exactly: each entry keeps only what that source ADDED, computed incrementally as sources fold in. Anything the survivor already had, or an earlier source already brought, is attributed to nobody. Both open questions fall out of that one change — partial un-merge is exact, and a survivor-owned location can never be stripped, because it was never attributed in the first place. The shape moved from [id] to [{id, locations, tags}]. Free to do: the corpus holds one snippet and zero merges, so there is no legacy data (rule #22). A bare int still normalizes to {"id": n} — not legacy tolerance, but because snippet_fields falls back to PARSING THE BODY when a row has no `data`, and the body's provenance line can only carry ids. Such an entry shows history and refuses un-merge with a reason rather than guessing. WHICH SURFACE. Neither option in the task, quite. Making trash-restore notice the merge would teach the generic trash path snippet semantics for one record type. Instead un-merge OWNS the restore: one operation, one authorization check, trash stays ignorant. Restoring by hand is still allowed and still leaves both records claiming the same places — so un-merge treats an already-alive source as the normal case and goes straight to the subtraction that repairs it. That is the state that motivated the feature, not an error. Adds trash.restore_entity(user_id, type, id) — the missing inverse of delete(), which returns a batch id callers don't keep. Restores the whole batch, since the batch is the entity plus its cascade. Refs #2165 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- frontend/src/api/snippets.ts | 20 ++- frontend/src/views/SnippetDetailView.vue | 83 +++++++++- src/scribe/mcp/tools/snippets.py | 45 +++++ src/scribe/routes/snippets.py | 30 ++++ src/scribe/services/snippets.py | 202 +++++++++++++++++++++-- src/scribe/services/trash.py | 30 ++++ tests/test_mcp_tool_snippets.py | 2 +- tests/test_routes_snippets.py | 5 +- tests/test_services_snippets.py | 46 +++++- tests/test_snippet_merge_provenance.py | 8 +- tests/test_snippet_unmerge.py | 183 ++++++++++++++++++++ 11 files changed, 624 insertions(+), 30 deletions(-) create mode 100644 tests/test_snippet_unmerge.py diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index 26c9bcd..0fae70b 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -20,9 +20,13 @@ export interface SnippetFields { path: string; symbol: string; locations: SnippetLocation[]; - /** Ids of the snippets folded into this one by merge, oldest first. Read-only: - * merge is the only thing that adds to it, and an edit carries it forward. */ - merged_from: number[]; + /** The snippets folded into this one by merge, oldest first. Read-only: merge + * is the only thing that adds to it, and an edit carries it forward. + * + * Each entry records what THAT source contributed — never what the survivor + * already had — which is what lets un-merge subtract exactly. An entry with + * no `locations`/`tags` predates that attribution and cannot be un-merged. */ + merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[]; code: string; } @@ -183,6 +187,16 @@ export async function verifySnippet( return apiPost(`/api/snippets/${id}/verify`, verdict); } +/** Pull one source back out of a merged survivor: restores it and strips exactly + * what it contributed. Also repairs a half-undone merge — a source restored + * from the trash by hand leaves the survivor still claiming its call sites. */ +export async function unmergeSnippet( + survivorId: number, + sourceId: number, +): Promise<{ survivor: Snippet; restored: Snippet | null }> { + return apiPost(`/api/snippets/${survivorId}/unmerge`, { source_id: sourceId }); +} + /** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged * survivor plus `merged_ids` — the sources actually folded in and trashed. */ export async function mergeSnippets( diff --git a/frontend/src/views/SnippetDetailView.vue b/frontend/src/views/SnippetDetailView.vue index 3d72f92..82bea87 100644 --- a/frontend/src/views/SnippetDetailView.vue +++ b/frontend/src/views/SnippetDetailView.vue @@ -1,7 +1,12 @@