From 6ffbf678710fe8970c37236efd39f2222c2725d5 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:42:51 -0700 Subject: [PATCH] feat(f7): block-level diff with atomic fences (#21) Co-Authored-By: Claude Opus 4.8 --- src/trackChangesModel.ts | 52 ++++++++++++++++++++++++++++++++++ test/trackChangesModel.test.ts | 51 ++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 93a6d59..eb31cf2 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -69,3 +69,55 @@ export function splitBlocks(text: string): Block[] { flushProse(); return blocks; } + +export type BlockOp = + | { kind: "unchanged"; block: Block } + | { kind: "added"; block: Block } + | { kind: "removed"; block: Block } + | { kind: "changed"; block: Block; before: Block; atomic: boolean }; + +/** + * Diff two block sequences. Matching is by normalized key (jsdiff `diffArrays`); + * adjacent removed-then-added runs are paired element-wise into `changed` ops, the + * surplus staying `removed` / `added`. A `changed` op is ATOMIC (INV-23) when + * either side is a code/mermaid fence — rendered whole, never word-refined. + */ +export function diffBlocks(baselineText: string, currentText: string): BlockOp[] { + const before = splitBlocks(baselineText); + const after = splitBlocks(currentText); + const changes = diffArrays( + before.map((b) => b.key), + after.map((b) => b.key), + ); + + const ops: BlockOp[] = []; + let bi = 0; // index into `before` + let ci = 0; // index into `after` + for (let n = 0; n < changes.length; n++) { + const ch = changes[n]; + const count = ch.count ?? ch.value.length; + if (!ch.added && !ch.removed) { + for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", block: after[ci++] }); + bi += count; + continue; + } + if (ch.removed) { + const next = changes[n + 1]; + const addCount = next?.added ? (next.count ?? next.value.length) : 0; + const paired = Math.min(count, addCount); + for (let k = 0; k < paired; k++) { + const beforeBlk = before[bi++]; + const afterBlk = after[ci++]; + const atomic = beforeBlk.type !== "prose" || afterBlk.type !== "prose"; + ops.push({ kind: "changed", block: afterBlk, before: beforeBlk, atomic }); + } + for (let k = paired; k < count; k++) ops.push({ kind: "removed", block: before[bi++] }); + for (let k = paired; k < addCount; k++) ops.push({ kind: "added", block: after[ci++] }); + if (next?.added) n++; // consumed the paired added run + continue; + } + // a lone added run (no preceding removed) + for (let k = 0; k < count; k++) ops.push({ kind: "added", block: after[ci++] }); + } + return ops; +} diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 4f0b188..c969fea 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { splitBlocks } from "../src/trackChangesModel"; +import { splitBlocks, diffBlocks } from "../src/trackChangesModel"; describe("splitBlocks", () => { it("splits prose paragraphs on blank lines, dropping empties", () => { @@ -27,3 +27,52 @@ describe("splitBlocks", () => { expect(blocks[0].key).toBe("hello world"); }); }); + +describe("diffBlocks", () => { + const kinds = (base: string, cur: string) => diffBlocks(base, cur).map((o) => o.kind); + + it("unchanged doc → all unchanged", () => { + const doc = "Alpha.\n\nBravo.\n"; + expect(kinds(doc, doc)).toEqual(["unchanged", "unchanged"]); + }); + + it("a pure addition → an added op", () => { + const ops = diffBlocks("Alpha.\n", "Alpha.\n\nBravo.\n"); + expect(ops.map((o) => o.kind)).toEqual(["unchanged", "added"]); + }); + + it("a pure deletion → a removed op", () => { + const ops = diffBlocks("Alpha.\n\nBravo.\n", "Alpha.\n"); + expect(ops.map((o) => o.kind)).toEqual(["unchanged", "removed"]); + }); + + it("a prose modification → a non-atomic changed op", () => { + const ops = diffBlocks("The quick fox.\n", "The slow fox.\n"); + expect(ops).toHaveLength(1); + expect(ops[0].kind).toBe("changed"); + expect(ops[0].kind === "changed" && ops[0].atomic).toBe(false); + }); + + it("a code-fence change → an ATOMIC changed op (INV-23)", () => { + const base = "```ts\nconst a = 1;\n```\n"; + const cur = "```ts\nconst a = 2;\n```\n"; + const ops = diffBlocks(base, cur); + expect(ops).toHaveLength(1); + expect(ops[0].kind).toBe("changed"); + expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true); + }); + + it("a mermaid-fence change → an ATOMIC changed op", () => { + const base = "```mermaid\nflowchart LR\n a-->b\n```\n"; + const cur = "```mermaid\nflowchart LR\n a-->c\n```\n"; + const ops = diffBlocks(base, cur); + expect(ops[0].kind).toBe("changed"); + expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true); + }); + + it("a reorder → keeps a matched block unchanged", () => { + const ops = diffBlocks("One.\n\nTwo.\n", "Two.\n\nOne.\n"); + const k = ops.map((o) => o.kind); + expect(k).toContain("unchanged"); + }); +});