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,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