#47 (SLICE-2, review): per-block document proposals + word-precise accept

Document-edit flow SLICE-2 (specs/coauthoring-document-edit-flow.md §7.2,
INV-39/40/41 — P1 "too much to review"). A whole-document rewrite now proposes
ONE F4 proposal per CHANGED BLOCK (the unit a human reviews), but accepting a
block reconciles attribution at WORD granularity (the unit F3 records). Block =
decision unit; word = attribution unit. Supersedes INV-37's per-word cut for
document edits; selection edits unchanged.

- trackChangesModel.ts: new pure diffToBlockHunks(current, rewritten) — block-key
  alignment (reusing diffArrays/diffBlocks keying): an isolated changed block →
  one block-aligned hunk → the rewritten block raw (a code/mermaid fence is one
  atomic whole-fence hunk, INV-23); insert/delete runs → one gap-span hunk over
  the inter-anchor region (separators included) so reconstruction stays exact; a
  zero-width gap-span is anchored (INV-41). Also split diffToHunks into the raw,
  un-anchored wordEditHunks + the anchoring wrapper (the anchoring could grow an
  insertion to overlap an adjacent hunk, corrupting a batch apply — a latent bug
  that only surfaced once hunks are applied as a batch).
- trackChangesPreview.ts: runEditAndPropose document branch uses diffToBlockHunks
  and tags each proposal granularity:"block".
- model.ts / proposalModel.ts: additive optional Proposal.granularity
  ("block"|"single"; absent ⇒ single, back-compat — no migration).
- proposalController.ts: accept of a block proposal runs an intra-block word
  sub-diff (wordEditHunks, disjoint) and applies one applyAgentEdit per changed
  run, descending offset — only the words Claude changed land Claude-attributed;
  unchanged spans keep prior authorship (INV-40).
- Tests: diffToBlockHunks unit (reconstruction + fence atomic + add/remove);
  f12Review host E2E (M blocks→M proposals, unchanged→none, fence atomic, INV-40
  attribution, INV-41 insertion accept); updated the f11 document-path E2E to
  per-block (INV-39 supersedes INV-37); MANUAL-SMOKE-F12 §2.

Seam note: pendingEdits.matchEvent resolves one registration per change event, so
INV-40's per-run attribution is sequential applyAgentEdit calls (N undo steps),
not one multi-replace WorkspaceEdit — see transcript Deferred decisions.

214 unit + 69/5 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-13 08:01:16 -07:00
parent 6e944ab4cc
commit 2f6008ba2b
9 changed files with 456 additions and 26 deletions
+85 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, test, expect } from "vitest";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
describe("splitBlocks", () => {
it("splits prose paragraphs on blank lines, dropping empties", () => {
@@ -402,6 +402,90 @@ describe("F11 diffToHunks (INV-37)", () => {
});
});
// #47 SLICE-2 (INV-39): a whole-document rewrite is diffed into ONE EditHunk per
// CHANGED BLOCK (the unit a human reviews), superseding INV-37's per-word hunks.
// Built by coarsening diffToHunks to block boundaries; fences stay atomic
// (INV-23); inserted/deleted blocks keep their anchored gap hunks (INV-41).
// Applying all hunks right→left must still reconstruct the rewrite exactly.
describe("#47 diffToBlockHunks (INV-39/41)", () => {
const applyHunks = (current: string, hunks: ReturnType<typeof diffToBlockHunks>): string => {
let out = current;
for (const h of [...hunks].sort((a, b) => b.start - a.start)) {
out = out.slice(0, h.start) + h.replacement + out.slice(h.end);
}
return out;
};
test("an identical rewrite → zero hunks", () => {
expect(diffToBlockHunks("# H\n\nSame body.\n", "# H\n\nSame body.\n")).toEqual([]);
});
test("two word edits in ONE paragraph → ONE block hunk (supersedes INV-37's two)", () => {
const current = "# Doc\n\nThe quick brown fox jumps over the lazy dog.\n";
const rewrite = "# Doc\n\nThe quick RED fox jumps over the lazy CAT.\n";
const hunks = diffToBlockHunks(current, rewrite);
expect(hunks).toHaveLength(1);
// the hunk spans the whole changed paragraph block
const para = "The quick brown fox jumps over the lazy dog.";
const start = current.indexOf(para);
expect(hunks[0].start).toBe(start);
expect(hunks[0].end).toBe(start + para.length);
expect(hunks[0].replacement).toBe("The quick RED fox jumps over the lazy CAT.");
expect(applyHunks(current, hunks)).toBe(rewrite);
});
test("edits across two paragraphs → two block hunks, one per changed block; unchanged → none", () => {
const current = "First para alpha.\n\nSecond para beta.\n\nThird para gamma.\n";
const rewrite = "First para ALPHA.\n\nSecond para beta.\n\nThird para GAMMA.\n";
const hunks = diffToBlockHunks(current, rewrite);
expect(hunks).toHaveLength(2);
// each hunk lands on a real block boundary in current
const blocks = splitBlocksWithRanges(current);
for (const h of hunks) {
expect(blocks.some((b) => b.start === h.start && b.end === h.end)).toBe(true);
}
expect(applyHunks(current, hunks)).toBe(rewrite);
});
test("a changed code fence → ONE atomic whole-fence hunk (INV-23)", () => {
const current = "# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n";
const rewrite = "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n";
const hunks = diffToBlockHunks(current, rewrite);
expect(hunks).toHaveLength(1);
const fence = "```js\nconst a = 1;\nconst b = 2;\n```";
const start = current.indexOf(fence);
expect(hunks[0].start).toBe(start);
expect(hunks[0].end).toBe(start + fence.length);
expect(hunks[0].replacement).toBe("```js\nconst a = 10;\nconst b = 2;\n```");
expect(applyHunks(current, hunks)).toBe(rewrite);
});
test("every hunk is resolvable (non-zero-width, real source text) and reconstructs", () => {
const cases: Array<[string, string]> = [
["# H\n\nThe brown fox sleeps.\n", "# H\n\nThe brown fox QUIETLY sleeps today.\n"], // insert words mid-block
["A para.\n\nB para.\n\nC para.\n", "A para.\n\nC para.\n"], // delete a whole block
["A para.\n\nB para.\n", "A para.\n\nNEW para.\n\nB para.\n"], // insert a whole block
["Only one block here.\n", "A totally different single block.\n"], // wholesale
["Keep me.\n\nDrop this one.\n", "Keep me.\n"], // delete trailing block
["unchanged body\n", "unchanged body\n"], // no-op
];
for (const [current, rewrite] of cases) {
const hunks = diffToBlockHunks(current, rewrite);
for (const h of hunks) {
expect(h.end).toBeGreaterThan(h.start); // non-zero-width → F4 fp resolves
expect(current.slice(h.start, h.end).length).toBeGreaterThan(0);
}
expect(applyHunks(current, hunks)).toBe(rewrite);
}
});
test("is deterministic — same inputs → identical hunks", () => {
const c = "P one.\n\nP two.\n";
const r = "P ONE.\n\nP two.\n";
expect(diffToBlockHunks(c, r)).toEqual(diffToBlockHunks(c, r));
});
});
// F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end
// (source char offsets from BlockWithRange) on every LIVE-source rendered block,
// in BOTH modes. The webview's selection→source mapping walks the DOM to the