From 452f5d193f10723a29648ea47f4323abf98480c0 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 23:03:15 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20mergeArtifacts=20=E2=80=94=20determinis?= =?UTF-8?q?tic=20union-by-id=20with=20surfaced=20conflicts=20(F5=20SLICE-3?= =?UTF-8?q?,=20INV-17,=20#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/mergeArtifacts.ts | 138 +++++++++++++++++++++++++++++++++++ test/mergeArtifacts.test.ts | 142 ++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 src/mergeArtifacts.ts create mode 100644 test/mergeArtifacts.test.ts diff --git a/src/mergeArtifacts.ts b/src/mergeArtifacts.ts new file mode 100644 index 0000000..1a6503e --- /dev/null +++ b/src/mergeArtifacts.ts @@ -0,0 +1,138 @@ +/** + * mergeArtifacts — the contract's merge semantics (INV-17) as a pure, vscode- + * free reference implementation (contract §6). 2-way union-by-id; every + * same-id divergence is resolved by the documented deterministic rules AND + * surfaced in `conflicts` — never silently guessed (INV-1's spirit at the + * artifact level). Refuses differing majors (INV-16: a rung never merges what + * it can't fully read). Not wired into any UI — rungs and humans invoke it; a + * .gitattributes merge driver is a later convenience (spec §1.7). + */ +import type { Artifact, Message, Thread } from "./model"; + +export interface MergeResult { + merged: Artifact; + /** ids (or root keys) whose divergence was rule-resolved — surfaced, never silent. */ + conflicts: string[]; +} + +type Rec = Record; + +/** Order-insensitive canonical form: all object keys sorted, no whitespace (contract §6). */ +function stableStringify(v: unknown): string { + if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`; + if (v !== null && typeof v === "object") { + const rec = v as Rec; + return `{${Object.keys(rec) + .sort() + .map((k) => `${JSON.stringify(k)}:${stableStringify(rec[k])}`) + .join(",")}}`; + } + return JSON.stringify(v); +} + +const same = (a: unknown, b: unknown): boolean => stableStringify(a) === stableStringify(b); + +/** Deterministic, symmetric tie-break: the lexicographically larger canonical form wins. */ +function lexLarger(a: T, b: T): T { + return stableStringify(a) >= stableStringify(b) ? a : b; +} + +/** + * Union two record arrays by id: one-side-only records pass through, identical + * records dedupe, same-id divergence is handed to `resolveDivergent` (which + * owns both picking the winner and reporting the conflict). Ours-order first, + * theirs-only appended in theirs-order. + */ +function unionById(ours: T[], theirs: T[], resolveDivergent: (o: T, t: T) => T): T[] { + const theirsById = new Map(theirs.map((t) => [t.id, t])); + const ourIds = new Set(ours.map((o) => o.id)); + const out: T[] = []; + for (const o of ours) { + const t = theirsById.get(o.id); + out.push(t === undefined || same(o, t) ? o : resolveDivergent(o, t)); + } + for (const t of theirs) if (!ourIds.has(t.id)) out.push(t); + return out; +} + +/** + * Same-id threads: the shell (every field but `messages`) and the messages + * merge independently (contract §6). Divergent shells tie-break and report the + * thread id; messages union by id ordered by (createdAt, id), per-message + * divergence tie-breaks and reports the message id. The forge reply-append + * case merges cleanly by construction (fresh uuids, disjoint additions). + */ +function mergeThread(o: Thread, t: Thread, conflicts: string[]): Thread { + const { messages: oMsgs, ...oShell } = o; + const { messages: tMsgs, ...tShell } = t; + let shell = oShell; + if (!same(oShell, tShell)) { + conflicts.push(o.id); + shell = lexLarger(oShell, tShell); + } + const messages: Message[] = unionById(oMsgs, tMsgs, (a, b) => { + conflicts.push(a.id); + return lexLarger(a, b); + }).sort((a, b) => + a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0, + ); + return { ...shell, messages } as Thread; +} + +export function mergeArtifacts(ours: Artifact, theirs: Artifact): MergeResult { + if (ours.schemaVersion !== theirs.schemaVersion) { + throw new Error( + `cannot merge differing schemaVersion majors: ${ours.schemaVersion} vs ${theirs.schemaVersion} (INV-16)`, + ); + } + const conflicts: string[] = []; + + // anchors: union by key; divergent same key → tie-break, reported. + const anchors: Artifact["anchors"] = { ...theirs.anchors, ...ours.anchors }; + for (const k of Object.keys(ours.anchors)) { + const t = theirs.anchors[k]; + if (t !== undefined && !same(ours.anchors[k], t)) { + conflicts.push(k); + anchors[k] = lexLarger(ours.anchors[k], t); + } + } + + const threads = unionById(ours.threads, theirs.threads, (o, t) => mergeThread(o, t, conflicts)); + + const attributions = unionById(ours.attributions, theirs.attributions, (o, t) => { + conflicts.push(o.id); + // newer updatedAt wins; equal updatedAt falls through to the tie-break. + if (o.updatedAt !== t.updatedAt) return o.updatedAt > t.updatedAt ? o : t; + return lexLarger(o, t); + }); + + const proposals = unionById(ours.proposals, theirs.proposals, (o, t) => { + conflicts.push(o.id); + return lexLarger(o, t); + }); + + // root: known sections merged above; document must agree; unknown root keys + // union with the same tie-break + report (contract §6). + const KNOWN_ROOT = new Set(["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"]); + let document = ours.document; + if (!same(ours.document, theirs.document)) { + conflicts.push("document"); + document = lexLarger(ours.document, theirs.document); + } + const merged = { schemaVersion: ours.schemaVersion, document, anchors, threads, attributions, proposals } as Artifact & + Rec; + const oRec = ours as unknown as Rec; + const tRec = theirs as unknown as Rec; + for (const k of [...new Set([...Object.keys(oRec), ...Object.keys(tRec)])].sort()) { + if (KNOWN_ROOT.has(k)) continue; + const inO = k in oRec; + const inT = k in tRec; + if (inO && inT && !same(oRec[k], tRec[k])) { + conflicts.push(k); + merged[k] = lexLarger(oRec[k], tRec[k]); + } else { + merged[k] = inO ? oRec[k] : tRec[k]; + } + } + return { merged, conflicts }; +} diff --git a/test/mergeArtifacts.test.ts b/test/mergeArtifacts.test.ts new file mode 100644 index 0000000..1b89d23 --- /dev/null +++ b/test/mergeArtifacts.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { emptyArtifact, type Artifact } from "../src/model"; +import { mergeArtifacts } from "../src/mergeArtifacts"; +import { expectValidSidecar } from "./helpers/validateSidecar"; + +const FP = { text: "alpha", before: "", after: "", lineHint: 0 }; + +function base(): Artifact { + const a = emptyArtifact("docs/x.md"); + a.anchors["a_1"] = { fingerprint: FP }; + a.threads.push({ + id: "t_1", + anchorId: "a_1", + status: "open", + messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }], + }); + return a; +} +const clone = (a: Artifact): Artifact => JSON.parse(JSON.stringify(a)); + +describe("mergeArtifacts (F5 SLICE-3, INV-17)", () => { + it("reply-append vs disjoint edit unions cleanly, no conflicts (the forge case)", () => { + const ours = base(); + ours.attributions.push({ + id: "at_1", + anchorId: "a_1", + author: { kind: "human", id: "ben" }, + createdAt: "2026-06-10T01:00:00.000Z", + updatedAt: "2026-06-10T01:00:00.000Z", + }); + const theirs = base(); + theirs.threads[0].messages.push({ + id: "m_2", + author: { kind: "human", id: "forge", email: "forge@example.org" }, + body: "reply", + createdAt: "2026-06-10T02:00:00.000Z", + }); + const { merged, conflicts } = mergeArtifacts(ours, theirs); + expect(conflicts).toEqual([]); + expect(merged.threads[0].messages.map((m) => m.id)).toEqual(["m_1", "m_2"]); + expect(merged.attributions.map((a) => a.id)).toEqual(["at_1"]); + expectValidSidecar(merged); + }); + + it("both sides append to the same thread: messages interleave by createdAt, no conflicts", () => { + const ours = base(); + ours.threads[0].messages.push({ id: "m_b", author: { kind: "human", id: "ben" }, body: "B", createdAt: "2026-06-10T03:00:00.000Z" }); + const theirs = base(); + theirs.threads[0].messages.push({ id: "m_a", author: { kind: "human", id: "forge" }, body: "A", createdAt: "2026-06-10T02:00:00.000Z" }); + const { merged, conflicts } = mergeArtifacts(ours, theirs); + expect(conflicts).toEqual([]); + expect(merged.threads[0].messages.map((m) => m.id)).toEqual(["m_1", "m_a", "m_b"]); + }); + + it("same attribution id divergent: newer updatedAt wins and the id is reported", () => { + const rec = { + id: "at_1", + anchorId: "a_1", + author: { kind: "human", id: "ben" } as const, + createdAt: "2026-06-10T00:00:00.000Z", + updatedAt: "2026-06-10T00:00:00.000Z", + }; + const ours = base(); + ours.attributions.push({ ...rec, updatedAt: "2026-06-10T05:00:00.000Z" }); + const theirs = base(); + theirs.attributions.push({ ...rec, updatedAt: "2026-06-10T04:00:00.000Z", turnId: "turn-x" }); + const { merged, conflicts } = mergeArtifacts(ours, theirs); + expect(conflicts).toEqual(["at_1"]); + expect(merged.attributions[0].updatedAt).toBe("2026-06-10T05:00:00.000Z"); + expect(merged.attributions[0].turnId).toBeUndefined(); + }); + + it("divergent same-id proposals: deterministic tie-break, id reported, symmetric result", () => { + const p = { + id: "p_1", + anchorId: "a_1", + replacement: "AAA", + author: { kind: "human", id: "ben" } as const, + createdAt: "2026-06-10T00:00:00.000Z", + }; + const ours = base(); + ours.proposals.push({ ...p }); + const theirs = base(); + theirs.proposals.push({ ...p, replacement: "ZZZ" }); + const r1 = mergeArtifacts(ours, theirs); + const r2 = mergeArtifacts(theirs, ours); + expect(r1.conflicts).toEqual(["p_1"]); + expect(r1.merged.proposals[0].replacement).toBe("ZZZ"); + expect(r2.merged.proposals[0].replacement).toBe("ZZZ"); + }); + + it("thread status divergence resolves deterministically and reports the thread id", () => { + const ours = base(); + const theirs = clone(ours); + theirs.threads[0].status = "resolved"; + const { merged, conflicts } = mergeArtifacts(ours, theirs); + expect(merged.threads[0].status).toBe("resolved"); + expect(conflicts).toEqual(["t_1"]); + }); + + it("divergent same-id messages tie-break symmetrically and report the message id", () => { + const ours = base(); + const theirs = clone(ours); + theirs.threads[0].messages[0].body = "hi (edited)"; + const r1 = mergeArtifacts(ours, theirs); + const r2 = mergeArtifacts(theirs, ours); + expect(r1.conflicts).toEqual(["m_1"]); + expect(r2.conflicts).toEqual(["m_1"]); + // deterministic + symmetric: both directions pick the same winner + expect(r1.merged.threads[0].messages[0].body).toBe(r2.merged.threads[0].messages[0].body); + }); + + it("anchors union by key; divergent same key reported", () => { + const ours = base(); + const theirs = base(); + theirs.anchors["a_2"] = { fingerprint: { ...FP, text: "beta" } }; + const clean = mergeArtifacts(ours, theirs); + expect(Object.keys(clean.merged.anchors).sort()).toEqual(["a_1", "a_2"]); + expect(clean.conflicts).toEqual([]); + const theirs2 = clone(ours); + theirs2.anchors["a_1"] = { fingerprint: { ...FP, lineHint: 9 } }; + const dirty = mergeArtifacts(ours, theirs2); + expect(dirty.conflicts).toEqual(["a_1"]); + }); + + it("unknown fields ride with the winning record and root unknowns union", () => { + const ours = base() as Artifact & Record; + ours.futureSection = [1]; + const theirs = base() as Artifact & Record; + theirs.otherFuture = "x"; + const { merged, conflicts } = mergeArtifacts(ours, theirs); + expect((merged as unknown as Record).futureSection).toEqual([1]); + expect((merged as unknown as Record).otherFuture).toBe("x"); + expect(conflicts).toEqual([]); + }); + + it("throws on differing majors (INV-16)", () => { + const ours = base(); + const theirs = { ...base(), schemaVersion: 2 }; + expect(() => mergeArtifacts(ours, theirs)).toThrow(/schemaVersion/); + }); +});