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.
This commit is contained in:
can1357
2026-02-11 07:12:28 +01:00
parent 4c0cba5984
commit 6c5fa149da
8 changed files with 36 additions and 200 deletions
+9 -4
View File
@@ -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
+4 -89
View File
@@ -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<number> {
const touched = new Set<number>();
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;
}
}
}
-15
View File
@@ -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<TInput> {
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) {
+3 -17
View File
@@ -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,
};
}
}
@@ -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`).
</instruction>
@@ -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"}}]}
</example>
<example name="insert before">
edit {"path":"src/app.py","edits":[{"insertBefore":{"loc":"{{hashline 3 'def hello'}}","content":" # new comment"}}]}
</example>
<example name="multiple edits (bottom-up safe)">
edit {"path":"src/app.py","edits":[{"replaceLine":{"loc":"{{hashline 10 'return True'}}","content":" return False"}},{"replaceLine":{"loc":"{{hashline 3 'def hello'}}","content":" x = 42"}}]}
</example>
@@ -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" } }];
+1 -1
View File
@@ -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);
+18 -12
View File
@@ -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<string, number> {
const counts: Record<string, number> = 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") },
});
}
}