fix(f11): address code review — insertion anchoring, turnId, accept-all coverage (#43)

Code-review follow-up on the F11 branch (5966907..1564ef5). Three real findings:

- CRITICAL — pure-insertion hunks were born-orphaned. A document rewrite that
  INSERTS text produced a zero-width hunk (start==end) → buildFingerprint yields
  empty fp.text → anchorer.resolve orphans an empty needle → the proposal could
  never be accepted (silently). diffToHunks now anchors every zero-width
  insertion to an adjacent source token (anchorInsertion): the range absorbs the
  token and the replacement keeps it, so net text is identical but fp.text is
  non-empty and resolvable. New unit tests: applying hunks always reconstructs
  the rewrite (substitute/delete/insert/multi); insertions are never zero-width.
  New host E2E ACCEPTS a rewrite-with-insertion end to end and asserts accept-all
  reconstructs the intended document (also covers sequential multi-hunk accept).

- IMPORTANT — renderPlain cross-block fidelity. The per-block off-mode rendering
  (SLICE-2) can't resolve a reference-link definition in a separate block.
  Documented as a conscious tradeoff of the operator-locked block-level mapping
  (§6.7) — renderReview already rendered per-block, so this keeps both modes
  consistent — with a characterization test + a docstring note.

- MINOR — F11 proposals now carry a turnId (one per Ask-Claude gesture, shared
  across a document rewrite's N hunks), matching the editor-menu editSelection
  path so a rewrite groups as one agent turn.

208 unit + 9/9 F11 host E2E green. (The lone red E2E is a pre-existing,
F11-independent undoMarks timing flake — proven by isolation: it fails
identically with all F11 tests removed.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-12 14:16:49 -07:00
parent 1564ef562b
commit 47cc733026
4 changed files with 138 additions and 3 deletions
+42 -1
View File
@@ -211,13 +211,23 @@ export interface EditHunk {
* unchanged parts reconstruct it exactly, so advancing on those yields true
* source offsets. Adjacent added/removed runs coalesce into one hunk; an
* unchanged part flushes the open hunk.
*
* A PURE insertion (added text between two unchanged regions) would otherwise be
* a zero-width hunk (start==end) — and F4's fingerprint of empty text can never
* resolve (anchorer.resolve orphans an empty needle), so the proposal could
* never be accepted. Every zero-width hunk is therefore anchored to an adjacent
* source token (`anchorInsertion`): its range absorbs that token and its
* replacement keeps it, so the net text is identical but `fp.text` is non-empty
* and resolvable. A zero-width hunk is always bordered by unchanged text (or a
* doc edge), so the absorbed token is genuinely present in `currentText` and is
* never part of another hunk.
*/
export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] {
const hunks: EditHunk[] = [];
let offset = 0;
let open: EditHunk | null = null;
const flush = () => {
if (open) hunks.push(open);
if (open) hunks.push(open.start === open.end ? anchorInsertion(open, currentText) : open);
open = null;
};
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
@@ -237,6 +247,31 @@ export function diffToHunks(currentText: string, rewrittenText: string): EditHun
return hunks;
}
const isWs = (c: string): boolean => /\s/.test(c);
/**
* Grow a zero-width insertion hunk to absorb an adjacent source token so its F4
* fingerprint has real text to resolve. Prefer the FOLLOWING token (optional
* leading whitespace + a run of non-whitespace); at end-of-document, fall back to
* the PRECEDING token. The absorbed text is kept in the replacement, so the
* applied result is unchanged.
*/
function anchorInsertion(hunk: EditHunk, text: string): EditHunk {
const p = hunk.start;
if (p < text.length) {
let e = p;
while (e < text.length && isWs(text[e])) e++;
while (e < text.length && !isWs(text[e])) e++;
if (e === p) e = Math.min(text.length, p + 1);
return { start: p, end: e, replacement: hunk.replacement + text.slice(p, e) };
}
let s = p;
while (s > 0 && isWs(text[s - 1])) s--;
while (s > 0 && !isWs(text[s - 1])) s--;
if (s === p) s = Math.max(0, p - 1);
return { start: s, end: p, replacement: text.slice(s, p) + hunk.replacement };
}
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>).
@@ -427,6 +462,12 @@ export function colorByAuthor(
* preview is still a selection→source mapping surface (INV-36) while staying
* visually clean — no `cw-` annotation classes. A doc with no blocks (empty /
* whitespace-only) renders whole. Pure + deterministic.
*
* Tradeoff of the locked block-level mapping (§6.7): rendering PER BLOCK (so each
* carries its offsets) means a markdown construct split across blank-line-
* separated blocks — a reference-link use and its definition — doesn't resolve
* across blocks. `renderReview` already rendered per-block; this keeps the two
* modes consistent rather than faithful-but-different. Characterized in tests.
*/
export function renderPlain(currentText: string, opts: RenderOptions = {}): string {
const render = opts.render ?? defaultRender;