From 4b27acfcaeffa972921abb1f77333cc874b4642e Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 10:01:49 -0700 Subject: [PATCH] F3 SLICE-4: persistence/re-anchor/orphan decision-logic tests (INV-1/INV-6) (#6) Co-Authored-By: Claude Opus 4.8 (1M context) --- test/attributionPersistence.test.ts | 54 +++++++++++++++++++++++++++++ test/attributionTracker.test.ts | 26 ++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 test/attributionPersistence.test.ts diff --git a/test/attributionPersistence.test.ts b/test/attributionPersistence.test.ts new file mode 100644 index 0000000..76670e1 --- /dev/null +++ b/test/attributionPersistence.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { buildFingerprint, resolve } from "../src/anchorer"; +import { applyChange, type LiveSpan } from "../src/attributionTracker"; +import type { Provenance } from "../src/model"; + +const HUMAN: Provenance = { kind: "human", id: "ben" }; +const DOC = "intro\nClaude wrote this sentence here.\noutro\n"; + +function liveSpan(): LiveSpan { + const start = DOC.indexOf("Claude wrote this sentence"); + return { + id: "at_1", start, end: start + "Claude wrote this sentence".length, + author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "r1" } }, + createdAt: "2026-06-10T00:00:00.000Z", + }; +} + +describe("attribution persistence decision logic (SLICE-4, spec §6.5 PUC-4)", () => { + it("save → reload: fingerprint built from the live span resolves to the same range", () => { + const s = liveSpan(); + const fp = buildFingerprint(DOC, { start: s.start, end: s.end }); + expect(resolve(DOC, fp)).toEqual({ start: s.start, end: s.end }); + }); + + it("external edit above: fingerprint re-resolves at the moved range", () => { + const s = liveSpan(); + const fp = buildFingerprint(DOC, { start: s.start, end: s.end }); + const edited = "a new line\n" + DOC; + const moved = edited.indexOf("Claude wrote this sentence"); + expect(resolve(edited, fp)).toEqual({ start: moved, end: moved + (s.end - s.start) }); + }); + + it("anchored text mangled: orphaned, never guessed (INV-1/INV-6)", () => { + const s = liveSpan(); + const fp = buildFingerprint(DOC, { start: s.start, end: s.end }); + const mangled = DOC.replace("Claude wrote this sentence", "Someone rewrote everything"); + expect(resolve(mangled, fp)).toBe("orphaned"); + }); + + it("in-session edits keep the live span fingerprint-able (tracker → anchorer round trip)", () => { + let spans = [liveSpan()]; + let doc = DOC; + const insertAt = DOC.indexOf("intro") + "intro".length; + const inserted = " (note)"; + doc = doc.slice(0, insertAt) + inserted + doc.slice(insertAt); + spans = applyChange(spans, { start: insertAt, end: insertAt, newLength: inserted.length }, HUMAN, { + newId: () => "n1", now: () => "2026-06-10T01:00:00.000Z", + }); + const agent = spans.find((x) => x.author.kind === "agent")!; + expect(doc.slice(agent.start, agent.end)).toBe("Claude wrote this sentence"); + const fp = buildFingerprint(doc, { start: agent.start, end: agent.end }); + expect(resolve(doc, fp)).toEqual({ start: agent.start, end: agent.end }); + }); +}); diff --git a/test/attributionTracker.test.ts b/test/attributionTracker.test.ts index c590a8e..de0c9e8 100644 --- a/test/attributionTracker.test.ts +++ b/test/attributionTracker.test.ts @@ -125,3 +125,29 @@ describe("coalesce", () => { expect(coalesce([span("a", 5, 5, HUMAN)])).toEqual([]); }); }); + +describe("edge probes (post-review)", () => { + it("applyChange does not mutate its input array or spans", () => { + const input = [span("a", 0, 10, AGENT)]; + const snapshot = JSON.parse(JSON.stringify(input)); + applyChange(input, { start: 2, end: 4, newLength: 1 }, HUMAN, ctx()); + expect(input).toEqual(snapshot); + }); + it("an edit swallowing a whole middle span across three spans removes only it", () => { + // Deletion [3,9): a → [0,3); b swallowed; c's remainder [9,12) shifts -6 to + // [3,6). The two surviving HUMAN spans are adjacent at 3 with the same + // (undefined) turnId, so coalesce merges them into ONE span — only the + // agent-authored middle span's characters are gone (INV-6/INV-7). + const r = applyChange( + [span("a", 0, 4, HUMAN), span("b", 4, 8, AGENT), span("c", 8, 12, HUMAN)], + { start: 3, end: 9, newLength: 0 }, HUMAN, ctx(), + ); + expect(ranges(r)).toEqual([[0, 6]]); + expect(authors(r)).toEqual(["human"]); + }); + it("a replacement exactly covering a span replaces it with the editor's span", () => { + const r = applyChange([span("a", 4, 8, AGENT)], { start: 4, end: 8, newLength: 4 }, HUMAN, ctx()); + expect(ranges(r)).toEqual([[4, 8]]); + expect(authors(r)).toEqual(["human"]); + }); +});