/** * 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 }; }