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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user