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
|
* unchanged parts reconstruct it exactly, so advancing on those yields true
|
||||||
* source offsets. Adjacent added/removed runs coalesce into one hunk; an
|
* source offsets. Adjacent added/removed runs coalesce into one hunk; an
|
||||||
* unchanged part flushes the open hunk.
|
* 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[] {
|
export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] {
|
||||||
const hunks: EditHunk[] = [];
|
const hunks: EditHunk[] = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let open: EditHunk | null = null;
|
let open: EditHunk | null = null;
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
if (open) hunks.push(open);
|
if (open) hunks.push(open.start === open.end ? anchorInsertion(open, currentText) : open);
|
||||||
open = null;
|
open = null;
|
||||||
};
|
};
|
||||||
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
|
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
|
||||||
@@ -237,6 +247,31 @@ export function diffToHunks(currentText: string, rewrittenText: string): EditHun
|
|||||||
return hunks;
|
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 });
|
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||||
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
|
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
|
||||||
// other fences fall through to markdown-it's default (escaped <pre><code>).
|
// 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
|
* preview is still a selection→source mapping surface (INV-36) while staying
|
||||||
* visually clean — no `cw-` annotation classes. A doc with no blocks (empty /
|
* visually clean — no `cw-` annotation classes. A doc with no blocks (empty /
|
||||||
* whitespace-only) renders whole. Pure + deterministic.
|
* 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 {
|
export function renderPlain(currentText: string, opts: RenderOptions = {}): string {
|
||||||
const render = opts.render ?? defaultRender;
|
const render = opts.render ?? defaultRender;
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
const { runEditTurn } = await import("./liveTurn");
|
const { runEditTurn } = await import("./liveTurn");
|
||||||
return runEditTurn(instruction, text);
|
return runEditTurn(instruction, text);
|
||||||
};
|
};
|
||||||
|
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
||||||
|
private turnSeq = 0;
|
||||||
|
private nextTurnSeq(): number {
|
||||||
|
return ++this.turnSeq;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly diffView: DiffViewController,
|
private readonly diffView: DiffViewController,
|
||||||
@@ -219,6 +224,9 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
instruction: string,
|
instruction: string,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const full = document.getText();
|
const full = document.getText();
|
||||||
|
// One turnId per gesture — the document case's N hunk-proposals all share it,
|
||||||
|
// so a single rewrite groups as one agent turn (parity with editSelection).
|
||||||
|
const turnId = `turn-${this.nextTurnSeq()}`;
|
||||||
const provenance = (turn: EditTurnResult) =>
|
const provenance = (turn: EditTurnResult) =>
|
||||||
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
|
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
|
||||||
if (target.kind === "range") {
|
if (target.kind === "range") {
|
||||||
@@ -226,14 +234,14 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
const turn = await this.editTurn(instruction, selected);
|
const turn = await this.editTurn(instruction, selected);
|
||||||
if (turn.replacement === "" || turn.replacement === selected) return [];
|
if (turn.replacement === "" || turn.replacement === selected) return [];
|
||||||
const fp = buildFingerprint(full, { start: target.start, end: target.end });
|
const fp = buildFingerprint(full, { start: target.start, end: target.end });
|
||||||
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { instruction });
|
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
|
||||||
return id ? [id] : [];
|
return id ? [id] : [];
|
||||||
}
|
}
|
||||||
const turn = await this.editTurn(instruction, full);
|
const turn = await this.editTurn(instruction, full);
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const h of diffToHunks(full, turn.replacement)) {
|
for (const h of diffToHunks(full, turn.replacement)) {
|
||||||
const fp = buildFingerprint(full, { start: h.start, end: h.end });
|
const fp = buildFingerprint(full, { start: h.start, end: h.end });
|
||||||
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), { instruction });
|
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), { turnId, instruction });
|
||||||
if (id) ids.push(id);
|
if (id) ids.push(id);
|
||||||
}
|
}
|
||||||
return ids;
|
return ids;
|
||||||
|
|||||||
@@ -144,6 +144,33 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
|||||||
assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing");
|
assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SLICE-3 (review follow-up): a document rewrite that INSERTS text → an
|
||||||
|
// acceptable proposal (not a born-orphaned zero-width hunk), and accepting all
|
||||||
|
// hunks of a multi-hunk rewrite lands the intended document.
|
||||||
|
test("document rewrite with an insertion → acceptable proposals; accept-all reaches the rewrite", async () => {
|
||||||
|
const original = "# F11 accept\n\nThe brown fox sleeps.\n";
|
||||||
|
const rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n";
|
||||||
|
const { doc } = await freshDoc("docs/f11accept.md", original);
|
||||||
|
const api = await getApi();
|
||||||
|
const ctl = api.trackChangesPreviewController;
|
||||||
|
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
|
||||||
|
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
|
||||||
|
await settle();
|
||||||
|
assert.ok(ids.length >= 1, "the rewrite produced at least one proposal");
|
||||||
|
|
||||||
|
// Every proposal must be acceptable (the insertion-anchoring fix): accept each.
|
||||||
|
for (const id of ids) {
|
||||||
|
const ok = await api.proposalController.acceptById("docs/f11accept.md", id);
|
||||||
|
assert.ok(ok, `proposal ${id} is acceptable (not born-orphaned)`);
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
assert.strictEqual(doc.getText(), rewrite, "accepting all hunks reconstructs the intended rewrite");
|
||||||
|
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
|
||||||
|
});
|
||||||
|
|
||||||
// SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
|
// SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
|
||||||
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
|
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
|
||||||
const all = await vscode.commands.getCommands(true);
|
const all = await vscode.commands.getCommands(true);
|
||||||
|
|||||||
@@ -360,6 +360,46 @@ describe("F11 diffToHunks (INV-37)", () => {
|
|||||||
const b = diffToHunks("a b c d", "a B c D");
|
const b = diffToHunks("a b c d", "a B c D");
|
||||||
expect(a).toEqual(b);
|
expect(a).toEqual(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Apply hunks (right→left so earlier offsets stay valid) to reconstruct the rewrite. */
|
||||||
|
const applyHunks = (current: string, hunks: ReturnType<typeof diffToHunks>): 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("applying the hunks always reconstructs the rewrite (substitute / delete / insert / multi)", () => {
|
||||||
|
const cases: Array<[string, string]> = [
|
||||||
|
["The quick brown fox.", "The quick red fox."],
|
||||||
|
["one two three four", "one TWO three FOUR"],
|
||||||
|
["alpha", "omega"],
|
||||||
|
["keep this and drop that", "keep this"],
|
||||||
|
["one two three", "one INSERTED two three"],
|
||||||
|
["start middle end", "PREFIX start middle end SUFFIX"],
|
||||||
|
["unchanged body", "unchanged body"],
|
||||||
|
];
|
||||||
|
for (const [current, rewrite] of cases) {
|
||||||
|
expect(applyHunks(current, diffToHunks(current, rewrite))).toBe(rewrite);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an inserted run anchors to adjacent text — never a zero-width, unacceptable hunk (INV-37)", () => {
|
||||||
|
// A pure insertion would otherwise produce start==end → an empty fingerprint
|
||||||
|
// → resolve() orphans it → the proposal can never be accepted. Each hunk must
|
||||||
|
// span real source text so its F4 fingerprint resolves.
|
||||||
|
for (const [current, rewrite] of [
|
||||||
|
["one two three", "one INSERTED two three"],
|
||||||
|
["tail anchor", "tail anchor APPENDED"],
|
||||||
|
["lead", "PREPENDED lead"],
|
||||||
|
] as Array<[string, string]>) {
|
||||||
|
for (const h of diffToHunks(current, rewrite)) {
|
||||||
|
expect(h.end).toBeGreaterThan(h.start); // non-zero-width
|
||||||
|
expect(current.slice(h.start, h.end).length).toBeGreaterThan(0); // real fp.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end
|
// F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end
|
||||||
@@ -408,4 +448,23 @@ describe("F11 data-src emission (INV-36)", () => {
|
|||||||
const r2 = renderReview("x", "x\n\ny", [], []);
|
const r2 = renderReview("x", "x\n\ny", [], []);
|
||||||
expect(r1).toBe(r2);
|
expect(r1).toBe(r2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CHARACTERIZATION (conscious tradeoff of the locked block-level mapping, §6.7
|
||||||
|
// fork 1): both modes render markdown PER BLOCK so each block can carry its
|
||||||
|
// data-src offsets. A consequence is that markdown constructs whose parts span
|
||||||
|
// blank-line-separated blocks — a reference-link USE and its DEFINITION — do not
|
||||||
|
// resolve across blocks (markdown-it sees each block in isolation). renderReview
|
||||||
|
// already had this; F11 brings the off/clean preview into line with it (both
|
||||||
|
// per-block) rather than leaving the two modes rendering differently. If
|
||||||
|
// cross-block fidelity is wanted later, it is a follow-up (source-map driven
|
||||||
|
// wrapping), not a change to the locked block-level decision.
|
||||||
|
test("a reference-link definition in a separate block does not resolve (block-level rendering)", () => {
|
||||||
|
const doc = "See [the spec][ref] for details.\n\n[ref]: https://example.com/spec\n";
|
||||||
|
const html = renderPlain(doc);
|
||||||
|
// the link is NOT resolved to an <a href> — the [text][ref] is rendered literally.
|
||||||
|
expect(html).not.toContain('href="https://example.com/spec"');
|
||||||
|
expect(html).toContain("[the spec][ref]");
|
||||||
|
// both modes agree (renderReview is likewise per-block) — the consistency point.
|
||||||
|
expect(renderReview(doc, doc, [], [])).not.toContain('href="https://example.com/spec"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user