feat: mergeArtifacts — deterministic union-by-id with surfaced conflicts (F5 SLICE-3, INV-17, #14)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
/** 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<T>(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<T extends { id: string }>(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 };
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
ours.futureSection = [1];
|
||||||
|
const theirs = base() as Artifact & Record<string, unknown>;
|
||||||
|
theirs.otherFuture = "x";
|
||||||
|
const { merged, conflicts } = mergeArtifacts(ours, theirs);
|
||||||
|
expect((merged as unknown as Record<string, unknown>).futureSection).toEqual([1]);
|
||||||
|
expect((merged as unknown as Record<string, unknown>).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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user