#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
+119 -1
View File
@@ -223,11 +223,27 @@ export interface EditHunk {
* never part of another hunk.
*/
export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] {
return wordEditHunks(currentText, rewrittenText).map((h) =>
h.start === h.end ? anchorInsertion(h, currentText) : h,
);
}
/**
* The raw word-level diff underlying `diffToHunks`: disjoint, ordered EditHunks
* that exactly partition the change (a pure insertion stays ZERO-WIDTH at its
* offset — not anchored). `diffToHunks` adds anchoring on top so each hunk's F4
* fingerprint resolves; callers that apply hunks directly through the seam
* (INV-40's intra-block accept) want THESE raw, non-overlapping hunks instead —
* anchoring can grow an insertion to absorb a token an adjacent hunk also edits,
* which corrupts a batch apply. Pure, vscode-free, deterministic. Applying these
* right→left reconstructs `rewrittenText` exactly.
*/
export function wordEditHunks(currentText: string, rewrittenText: string): EditHunk[] {
const hunks: EditHunk[] = [];
let offset = 0;
let open: EditHunk | null = null;
const flush = () => {
if (open) hunks.push(open.start === open.end ? anchorInsertion(open, currentText) : open);
if (open) hunks.push(open);
open = null;
};
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
@@ -272,6 +288,108 @@ function anchorInsertion(hunk: EditHunk, text: string): EditHunk {
return { start: s, end: p, replacement: text.slice(s, p) + hunk.replacement };
}
type BlockAlignKind = "unchanged" | "changed" | "removed" | "added";
interface BlockAlignOp {
kind: BlockAlignKind;
ci?: number; // index into the current blocks
ni?: number; // index into the rewritten blocks
}
/**
* Align two block sequences by normalized key (jsdiff `diffArrays`), pairing an
* adjacent removed-then-added run element-wise into `changed` ops (the surplus
* staying `removed` / `added`) — the same pairing `diffBlocks` uses, but yielding
* index ops so the caller can read each side's source range. Pure.
*/
function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignOp[] {
const changes = diffArrays(
cur.map((b) => b.key),
next.map((b) => b.key),
);
const ops: BlockAlignOp[] = [];
let bi = 0; // current index
let ci = 0; // rewritten index
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", ci: bi++, ni: ci++ });
continue;
}
if (ch.removed) {
const nx = changes[n + 1];
const addCount = nx?.added ? (nx.count ?? nx.value.length) : 0;
const paired = Math.min(count, addCount);
for (let k = 0; k < paired; k++) ops.push({ kind: "changed", ci: bi++, ni: ci++ });
for (let k = paired; k < count; k++) ops.push({ kind: "removed", ci: bi++ });
for (let k = paired; k < addCount; k++) ops.push({ kind: "added", ni: ci++ });
if (nx?.added) n++;
continue;
}
for (let k = 0; k < count; k++) ops.push({ kind: "added", ni: ci++ });
}
return ops;
}
/**
* #47 (INV-39, §6.4): diff a whole-document rewrite into ONE EditHunk per CHANGED
* BLOCK — the unit a human reviews — rather than per word (which INV-37/`diffToHunks`
* did and this supersedes for document edits). Built by aligning both sides into
* the existing block units (`splitBlocksWithRanges`) and keying with the same diff
* as `diffBlocks`:
* - an ISOLATED changed block (1:1, unchanged blocks on both sides) → a
* block-aligned hunk `[block.start, block.end)` → the rewritten block's raw
* text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23).
* - any other changed RUN (block insertions/deletions, or adjacent changes) → one
* gap-span hunk covering the source between the bounding unchanged blocks → the
* matching rewritten span (separators included), so reconstruction stays exact.
* A zero-width gap-span (insert at a seamless boundary) is anchored to an
* adjacent token (INV-41) so its F4 fingerprint resolves and it accepts.
* - unchanged blocks (same key AND same raw) → no hunk.
* Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which
* is RETAINED as the intra-block sub-diff engine for word-precise accept
* attribution — INV-40). Applying all hunks right→left reconstructs `rewrittenText`
* exactly.
*/
export function diffToBlockHunks(currentText: string, rewrittenText: string): EditHunk[] {
const cur = splitBlocksWithRanges(currentText);
const next = splitBlocksWithRanges(rewrittenText);
const ops = alignBlocks(cur, next);
// Same key but different raw (whitespace/case) is still a real content change.
for (const op of ops) {
if (op.kind === "unchanged" && cur[op.ci!].raw !== next[op.ni!].raw) op.kind = "changed";
}
const hunks: EditHunk[] = [];
let i = 0;
while (i < ops.length) {
if (ops[i].kind === "unchanged") {
i++;
continue;
}
let j = i;
while (j < ops.length && ops[j].kind !== "unchanged") j++;
const run = ops.slice(i, j);
if (run.length === 1 && run[0].kind === "changed") {
const c = cur[run[0].ci!];
hunks.push({ start: c.start, end: c.end, replacement: next[run[0].ni!].raw });
} else {
// A run with insertions/deletions: replace the whole inter-anchor gap so the
// separators reconstruct exactly. Bound by the unchanged blocks on each side
// (or the document edges).
const prev = i > 0 ? ops[i - 1] : null;
const after = j < ops.length ? ops[j] : null;
const curStart = prev ? cur[prev.ci!].end : 0;
const curEnd = after ? cur[after.ci!].start : currentText.length;
const newStart = prev ? next[prev.ni!].end : 0;
const newEnd = after ? next[after.ni!].start : rewrittenText.length;
const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) };
hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk);
}
i = j;
}
return hunks;
}
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
// other fences fall through to markdown-it's default (escaped <pre><code>).