feat(f7): block-level diff with atomic fences (#21)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-11 08:42:51 -07:00
parent 247a450e10
commit 6ffbf67871
2 changed files with 102 additions and 1 deletions
+52
View File
@@ -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;
}