From 6c5fa149da6800a925bf608f0b316f19ea26b43d Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 11 Feb 2026 07:12:28 +0100 Subject: [PATCH] feat(coding-agent): removed insertBefore and substr hashline edit operations - Removed insertBefore and substr hashline edit operations, simplifying the edit API to support only replaceLine, replaceLines, and insertAfter operations. - Updated hashline edit schema and type definitions to remove insertBefore and substr operation types from the ParsedRefs union and edit validation logic. - Removed insertBefore and substr test cases from hashline test suite, including tests for insert-before functionality, anchor echo stripping, and substring matching. - Updated benchmark runner to refactor edit operations from src/dst format to discriminated union types (replaceLine, replaceLines, insertAfter) and adjusted insertAfter line references. --- packages/coding-agent/CHANGELOG.md | 13 ++- packages/coding-agent/src/patch/hashline.ts | 93 +------------------ packages/coding-agent/src/patch/index.ts | 15 --- packages/coding-agent/src/patch/shared.ts | 20 +--- .../src/prompts/tools/hashline.md | 8 +- .../coding-agent/test/core/hashline.test.ts | 55 ----------- packages/react-edit-benchmark/report.ts | 2 +- packages/react-edit-benchmark/runner.ts | 30 +++--- 8 files changed, 36 insertions(+), 200 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5b33c5291..7a4a19416 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added whitespace normalization in line reference parsing to tolerate spaces around colons (e.g., `5 : ab` now parses as `5:ab`) @@ -8,24 +9,28 @@ - Added warnings detection in `applyHashlineEdits` to alert users when edits affect significantly more lines than expected, indicating possible unintended reformatting - Added diagnostic output showing target line content when an edit produces no changes, helping users identify hash mismatches or incorrect replacement content - Added `{{hashline}}` Handlebars helper to compute accurate `LINE:HASH` references for prompt examples and documentation -- Added `substr` edit variant to match and replace by unique substring when line-hash references are unavailable ### Changed - Changed `applyHashlineEdits` return type to include optional `warnings` array for reporting suspicious edit patterns - Improved hash relocation logic to recompute touched lines after hash-based line number adjustments, preventing incorrect merge heuristics - Enhanced error messages for no-op edits to include preview of target lines with their current hashes and content -- Changed hashline edit format from `src`/`dst` object structure to direct operation schemas (`replaceLine`, `replaceLines`, `insertAfter`, `insertBefore`, `substr`) +- Changed hashline edit format from `src`/`dst` object structure to direct operation schemas (`replaceLine`, `replaceLines`, `insertAfter`) - Changed hash algorithm from 2-character hexadecimal to 3-character base-36 alphanumeric for improved readability and collision resistance - Improved hash mismatch handling to automatically relocate stale line references when the hash uniquely identifies a moved line -- Changed `HashlineEdit` from `src`/`dst` format to direct operation schemas: `replaceLine`, `replaceLines`, `insertAfter`, `insertBefore`, `substr` +- Changed `HashlineEdit` from `src`/`dst` format to direct operation schemas: `replaceLine`, `replaceLines`, `insertAfter` - Changed hash algorithm from hexadecimal (base-16) to base-36 alphanumeric for shorter, more readable line references - Increased maximum wrapped-line restoration from 6 to 10 lines to handle longer reflowed statements - Updated prompt examples to use `{{hashline}}` Handlebars helper for generating correct line references in tool instructions +### Removed + +- Removed `insertBefore` hashline edit operation for inserting content before a line +- Removed `substr` hashline edit operation for substring-based line replacement +- Removed `insertBefore` and `substr` hashline edit variants + ### Fixed -- Fixed substr operation to resolve line numbers during validation phase rather than mutation phase, preventing ambiguity errors from being raised too late - Fixed multi-edit application to use original file state for all anchor references, preventing incorrect line numbers when earlier edits change file length ## [11.10.4] - 2026-02-10 diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/patch/hashline.ts index 3d19d8fd3..2e6f0bbd4 100644 --- a/packages/coding-agent/src/patch/hashline.ts +++ b/packages/coding-agent/src/patch/hashline.ts @@ -18,9 +18,7 @@ import type { HashMismatch } from "./types"; type ParsedRefs = | { kind: "single"; ref: { line: number; hash: string } } | { kind: "range"; start: { line: number; hash: string }; end: { line: number; hash: string } } - | { kind: "insertAfter"; after: { line: number; hash: string } } - | { kind: "insertBefore"; before: { line: number; hash: string } } - | { kind: "substr"; needle: string; resolvedLine?: number }; + | { kind: "insertAfter"; after: { line: number; hash: string } }; function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dst: string } { if ("replaceLine" in edit) { @@ -37,21 +35,9 @@ function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dst: string dst: edit.replaceLines.content, }; } - if ("insertAfter" in edit) { - return { - spec: { kind: "insertAfter", after: parseLineRef(edit.insertAfter.loc) }, - dst: edit.insertAfter.content, - }; - } - if ("substr" in edit) { - return { - spec: { kind: "substr", needle: edit.substr.needle }, - dst: edit.substr.content, - }; - } return { - spec: { kind: "insertBefore", before: parseLineRef(edit.insertBefore.loc) }, - dst: edit.insertBefore.content, + spec: { kind: "insertAfter", after: parseLineRef(edit.insertAfter.loc) }, + dst: edit.insertAfter.content, }; } /** Split dst into lines; empty string means delete (no lines). */ @@ -237,14 +223,6 @@ function stripInsertAnchorEchoAfter(anchorLine: string, dstLines: string[]): str return dstLines; } -function stripInsertAnchorEchoBefore(anchorLine: string, dstLines: string[]): string[] { - if (dstLines.length <= 1) return dstLines; - if (equalsIgnoringWhitespace(dstLines[dstLines.length - 1], anchorLine)) { - return dstLines.slice(0, -1); - } - return dstLines; -} - function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] { // Only strip when the model replaced with multiple lines and grew the edit. // This avoids turning a single-line replacement into a deletion. @@ -699,7 +677,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines: * Apply an array of hashline edits to file content. * * Each edit operation identifies target lines directly (`replaceLine`, `replaceLines`, - * `insertAfter`, `insertBefore`). Line references are resolved via {@link parseLineRef} + * `insertAfter`). Line references are resolved via {@link parseLineRef} * and hashes validated before any mutation. * * Edits are sorted bottom-up (highest effective line first) so earlier @@ -728,29 +706,6 @@ export function applyHashlineEdits( }; }); - // Resolve substr specs to line numbers - for (const p of parsed) { - if (p.spec.kind !== "substr") continue; - const indices: number[] = []; - for (let i = 0; i < fileLines.length; i++) { - if (fileLines[i].includes(p.spec.needle)) indices.push(i); - } - if (indices.length === 0) { - throw new Error(`Substr needle not found in file: "${p.spec.needle}"`); - } - if (indices.length > 1) { - const previews = indices - .slice(0, 5) - .map(i => `${i + 1}: ${fileLines[i]}`) - .join("\n"); - const more = indices.length > 5 ? `\n... (${indices.length - 5} more)` : ""; - throw new Error( - `Substr needle is ambiguous (found ${indices.length} matches): "${p.spec.needle}"\n${previews}${more}`, - ); - } - (p.spec as { resolvedLine?: number }).resolvedLine = indices[0] + 1; - } - function collectExplicitlyTouchedLines(): Set { const touched = new Set(); for (const { spec } of parsed) { @@ -764,12 +719,6 @@ export function applyHashlineEdits( case "insertAfter": touched.add(spec.after.line); break; - case "insertBefore": - touched.add(spec.before.line); - break; - case "substr": - touched.add(spec.resolvedLine!); - break; } } return touched; @@ -810,17 +759,6 @@ export function applyHashlineEdits( } refsToValidate.push(spec.after); break; - case "insertBefore": - if (dstLines.length === 0) { - throw new Error('Insert-before edit (src "..N:HH") requires non-empty dst'); - } - refsToValidate.push(spec.before); - break; - case "substr": - if (dstLines.length !== 1) { - throw new Error(`Substr edit requires single-line replacement (got ${dstLines.length} lines)`); - } - break; } for (const ref of refsToValidate) { @@ -867,14 +805,6 @@ export function applyHashlineEdits( sortLine = p.spec.after.line; precedence = 1; break; - case "insertBefore": - sortLine = p.spec.before.line; - precedence = 2; - break; - case "substr": - sortLine = p.spec.resolvedLine ?? 0; - precedence = 3; - break; } return { ...p, idx, sortLine, precedence }; }); @@ -945,21 +875,6 @@ export function applyHashlineEdits( trackFirstChanged(spec.after.line + 1); break; } - case "insertBefore": { - const anchorLine = originalFileLines[spec.before.line - 1]; - const inserted = stripInsertAnchorEchoBefore(anchorLine, dstLines); - fileLines.splice(spec.before.line - 1, 0, ...inserted); - trackFirstChanged(spec.before.line); - break; - } - case "substr": { - const lineIdx = spec.resolvedLine! - 1; - const original = fileLines[lineIdx]; - const replaced = original.replace(spec.needle, dstLines[0]); - fileLines.splice(lineIdx, 1, replaced); - trackFirstChanged(spec.resolvedLine!); - break; - } } } diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 9cba50a6d..d19222b75 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -144,24 +144,10 @@ const hashlineInsertAfterSchema = Type.Object({ content: Type.String({ description: "Content to insert (\\n-separated); must be non-empty" }), }), }); -const hashlineInsertBeforeSchema = Type.Object({ - insertBefore: Type.Object({ - loc: Type.String({ description: 'Insert before this line "LINE:HASH"' }), - content: Type.String({ description: "Content to insert (\\n-separated); must be non-empty" }), - }), -}); -const hashlineSubstrSchema = Type.Object({ - substr: Type.Object({ - needle: Type.String({ description: "Unique substring to find in the target line" }), - content: Type.String({ description: "Replacement for the needle (single-line only)" }), - }), -}); const hashlineEditItemSchema = Type.Union([ hashlineReplaceLineSchema, hashlineReplaceLinesSchema, hashlineInsertAfterSchema, - hashlineInsertBeforeSchema, - hashlineSubstrSchema, ]); const hashlineEditSchema = Type.Object({ path: Type.String({ description: "File path (relative or absolute)" }), @@ -428,7 +414,6 @@ export class EditTool implements AgentTool { if ("replaceLine" in edit) refs.push(edit.replaceLine.loc); else if ("replaceLines" in edit) refs.push(edit.replaceLines.start, edit.replaceLines.end); else if ("insertAfter" in edit) refs.push(edit.insertAfter.loc); - else if ("insertBefore" in edit) refs.push(edit.insertBefore.loc); for (const ref of refs) { const parsed = parseLineRef(ref); if (parsed.line >= 1 && parsed.line <= lines.length) { diff --git a/packages/coding-agent/src/patch/shared.ts b/packages/coding-agent/src/patch/shared.ts index d3ac7568c..9e3c79a38 100644 --- a/packages/coding-agent/src/patch/shared.ts +++ b/packages/coding-agent/src/patch/shared.ts @@ -88,9 +88,7 @@ interface EditRenderArgs { type HashlineEditPreview = | { replaceLine: { loc: string; content: string } } | { replaceLines: { start: string; end: string; content: string } } - | { insertAfter: { loc: string; content: string } } - | { insertBefore: { loc: string; content: string } } - | { substr: { needle: string; content: string } }; + | { insertAfter: { loc: string; content: string } }; /** Extended context for edit tool rendering */ export interface EditRenderContext { @@ -170,21 +168,9 @@ function formatStreamingHashlineEdits(edits: HashlineEditPreview[], uiTheme: The dst: edit.replaceLines.content, }; } - if ("insertAfter" in edit) { - return { - srcLabel: `• insertAfter ${edit.insertAfter.loc}..`, - dst: edit.insertAfter.content, - }; - } - if ("substr" in edit) { - return { - srcLabel: `• substr "${edit.substr.needle}"`, - dst: edit.substr.content, - }; - } return { - srcLabel: `• insertBefore ..${edit.insertBefore.loc}`, - dst: edit.insertBefore.content, + srcLabel: `• insertAfter ${edit.insertAfter.loc}..`, + dst: edit.insertAfter.content, }; } } diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index 58c1a95bc..88dabc168 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -1,4 +1,4 @@ -# Edit (Replace lines) +# Edit (Hash anchored) Line-addressed edits using hash-verified line references. Read file with hashes first, then edit by referencing `LINE:HASH` pairs. @@ -23,8 +23,6 @@ Line-addressed edits using hash-verified line references. Read file with hashes - `{ replaceLine: { loc: "LINE:HASH", content: "..." } }` - `{ replaceLines: { start: "LINE:HASH", end: "LINE:HASH", content: "..." } }` - `{ insertAfter: { loc: "LINE:HASH", content: "..." } }` -- `{ insertBefore: { loc: "LINE:HASH", content: "..." } }` -- `{ substr: { needle: "unique substring", content: "..." } }` — use when line hashes unavailable; needle must match exactly one line `content: ""` means delete (for `replaceLine`/`replaceLines`). @@ -75,10 +73,6 @@ edit {"path":"src/app.py","edits":[{"replaceLines":{"start":"{{hashline 5 'old_v edit {"path":"src/app.py","edits":[{"insertAfter":{"loc":"{{hashline 3 'def hello'}}","content":" # new comment"}}]} - -edit {"path":"src/app.py","edits":[{"insertBefore":{"loc":"{{hashline 3 'def hello'}}","content":" # new comment"}}]} - - edit {"path":"src/app.py","edits":[{"replaceLine":{"loc":"{{hashline 10 'return True'}}","content":" return False"}},{"replaceLine":{"loc":"{{hashline 3 'def hello'}}","content":" x = 42"}}]} \ No newline at end of file diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index d8ba5a86d..e417886b4 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -364,24 +364,6 @@ describe("applyHashlineEdits — insert", () => { expect(() => applyHashlineEdits(content, edits)).toThrow(); }); - - test("inserts before a line", () => { - const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ insertBefore: { loc: makeRef(2, "bbb"), content: "NEW" } }]; - - const result = applyHashlineEdits(content, edits); - expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); - expect(result.firstChangedLine).toBe(2); - }); - - test("inserts before first line", () => { - const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insertBefore: { loc: makeRef(1, "aaa"), content: "TOP" } }]; - - const result = applyHashlineEdits(content, edits); - expect(result.content).toBe("TOP\naaa\nbbb"); - expect(result.firstChangedLine).toBe(1); - }); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -401,14 +383,6 @@ describe("applyHashlineEdits — heuristics", () => { expect(result.content).toBe("aaa\nbbb\nNEW\nccc"); }); - test("strips insert-before anchor echo", () => { - const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ insertBefore: { loc: makeRef(2, "bbb"), content: "NEW\nbbb" } }]; - - const result = applyHashlineEdits(content, edits); - expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); - }); - test("strips range boundary echo and preserves whitespace on unchanged lines", () => { const content = [ "import { foo } from 'x';", @@ -551,21 +525,6 @@ describe("applyHashlineEdits — heuristics", () => { expect(result.content).toBe("aaa\nBBB\nccc"); }); - test("substr replaces needle with content when it matches exactly one line", () => { - const content = "aaa\ndevtools–unsupported-bridge-protocol\nccc"; - const edits: HashlineEdit[] = [ - { - substr: { - needle: "devtools–unsupported-bridge-protocol", - content: "devtools-unsupported-bridge-protocol", - }, - }, - ]; - - const result = applyHashlineEdits(content, edits); - expect(result.content).toBe("aaa\ndevtools-unsupported-bridge-protocol\nccc"); - }); - test("treats same-line ranges as single-line replacements", () => { const content = "aaa\nbbb\nccc"; const good = makeRef(2, "bbb"); @@ -730,20 +689,6 @@ describe("applyHashlineEdits — errors", () => { expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); }); - test("rejects substr when needle not found", () => { - const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ substr: { needle: "garbage", content: "X" } }]; - - expect(() => applyHashlineEdits(content, edits)).toThrow(/Substr needle not found/); - }); - - test("rejects substr when needle matches multiple lines", () => { - const content = "aaa\ndup\nmid\ndup"; - const edits: HashlineEdit[] = [{ substr: { needle: "dup", content: "X" } }]; - - expect(() => applyHashlineEdits(content, edits)).toThrow(/Substr needle is ambiguous/); - }); - test("rejects out-of-range line", () => { const content = "aaa\nbbb"; const edits: HashlineEdit[] = [{ replaceLine: { loc: "10:aa", content: "X" } }]; diff --git a/packages/react-edit-benchmark/report.ts b/packages/react-edit-benchmark/report.ts index acae73822..e29fc0cd5 100644 --- a/packages/react-edit-benchmark/report.ts +++ b/packages/react-edit-benchmark/report.ts @@ -175,7 +175,7 @@ export function generateReport(result: BenchmarkResult): string { lines.push(""); lines.push("| Operation | Count | % |"); lines.push("|-----------|-------|---|"); - const order = ["replaceLine", "replaceLines", "insertAfter", "insertBefore", "substr"]; + const order = ["replaceLine", "replaceLines", "insertAfter"]; for (const key of order) { const count = summary.hashlineEditSubtypes[key] ?? 0; const pct = formatPercent(count / total); diff --git a/packages/react-edit-benchmark/runner.ts b/packages/react-edit-benchmark/runner.ts index 71e262217..d6f126e4e 100644 --- a/packages/react-edit-benchmark/runner.ts +++ b/packages/react-edit-benchmark/runner.ts @@ -51,7 +51,7 @@ function getEditPathFromArgs(args: unknown): string | null { return typeof pathValue === "string" && pathValue.length > 0 ? pathValue : null; } -const HASHLINE_SUBTYPES = ["replaceLine", "replaceLines", "insertAfter", "insertBefore", "substr"] as const; +const HASHLINE_SUBTYPES = ["replaceLine", "replaceLines", "insertAfter"] as const; function countHashlineEditSubtypes(args: unknown): Record { const counts: Record = Object.fromEntries(HASHLINE_SUBTYPES.map((k) => [k, 0])); @@ -253,7 +253,10 @@ async function evaluateMutationIntent( }; } -type GuidedHashlineEdit = { src: unknown; dst: string }; +type GuidedHashlineEdit = + | { replaceLine: { loc: string; content: string } } + | { replaceLines: { start: string; end: string; content: string } } + | { insertAfter: { loc: string; content: string } }; function buildGuidedHashlineEdits(actual: string, expected: string): GuidedHashlineEdit[] { const changes = diffLines(actual, expected); @@ -273,19 +276,23 @@ function buildGuidedHashlineEdits(actual: string, expected: string): GuidedHashl if (pendingRemoved.length === 0) { const insertLine = pendingStart; if (pendingAdded.length === 0) return; - if (insertLine <= actualLines.length) { - const beforeLine = actualLines[insertLine - 1] ?? ""; - const beforeRef = `${insertLine}:${computeLineHash(insertLine, beforeLine)}`; + if (insertLine === 1) { + const firstLine = actualLines[0] ?? ""; + const firstRef = `1:${computeLineHash(1, firstLine)}`; edits.push({ - src: { kind: "insertBefore", before: beforeRef }, - dst: pendingAdded.join("\n"), + replaceLine: { loc: firstRef, content: pendingAdded.join("\n") + "\n" + firstLine }, + }); + } else if (insertLine <= actualLines.length) { + const afterLine = actualLines[insertLine - 2] ?? ""; + const afterRef = `${insertLine - 1}:${computeLineHash(insertLine - 1, afterLine)}`; + edits.push({ + insertAfter: { loc: afterRef, content: pendingAdded.join("\n") }, }); } else if (insertLine === actualLines.length + 1 && actualLines.length > 0) { const afterLine = actualLines[actualLines.length - 1] ?? ""; const afterRef = `${actualLines.length}:${computeLineHash(actualLines.length, afterLine)}`; edits.push({ - src: { kind: "insertAfter", after: afterRef }, - dst: pendingAdded.join("\n"), + insertAfter: { loc: afterRef, content: pendingAdded.join("\n") }, }); } } else { @@ -294,13 +301,12 @@ function buildGuidedHashlineEdits(actual: string, expected: string): GuidedHashl const startContent = actualLines[startLine - 1] ?? ""; const startRef = `${startLine}:${computeLineHash(startLine, startContent)}`; if (startLine === endLine) { - edits.push({ src: { kind: "single", ref: startRef }, dst: pendingAdded.join("\n") }); + edits.push({ replaceLine: { loc: startRef, content: pendingAdded.join("\n") } }); } else { const endContent = actualLines[endLine - 1] ?? ""; const endRef = `${endLine}:${computeLineHash(endLine, endContent)}`; edits.push({ - src: { kind: "range", start: startRef, end: endRef }, - dst: pendingAdded.join("\n"), + replaceLines: { start: startRef, end: endRef, content: pendingAdded.join("\n") }, }); } }