feat: introduced hashline API redesign with structured operations and LINE#ID format

- Redesigned hashline edit API with new operation names (set, set_range, insert) and structured body parameter accepting string arrays for multiline edits.
- Changed hashline reference format from LINE:HASH to LINE#ID throughout tools and documentation for improved clarity.
- Enhanced insert operation to support optional before/after anchors enabling flexible insertion positioning and boundary echo stripping.
- Made hashline autocorrect heuristics conditional on PI_HL_AUTOCORRECT environment variable for controlled behavior.
- Added benchmark reports for claude-haiku-4-5 and GPT-5.2-Codex models demonstrating hashline edit variant performance.
This commit is contained in:
can1357
2026-02-19 14:53:18 +01:00
parent 8c73c45ccc
commit 2bdcd618ad
19 changed files with 2551 additions and 328 deletions
-2
View File
@@ -379,7 +379,6 @@ Output final answer after ####.
When using: 3-5 diverse examples covering edge cases.
```xml
<examples>
<example name="simple">
Input: X
Output: Y
@@ -389,7 +388,6 @@ Output: Y
Input: X'
Output: Y'
</example>
</examples>
```
### Long Context Handling
+7 -1
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed automatic line relocation when hash references become stale; edits with mismatched line hashes now fail with an error instead of silently relocating to matching lines elsewhere in the file
@@ -17,11 +16,18 @@
### Changed
- Updated hashline reference format from `LINE:HASH` to `LINE#ID` throughout the codebase for improved clarity
- Renamed hashline edit operations: `set_line` → `set`, `replace_lines` → `set_range`, `insert_after` → `insert` with support for `before` and `between` anchors
- Changed hashline edit `body` field from string to array of strings for clearer multiline handling
- Updated handlebars helpers: renamed `hashline` to `hlineref` and added `hlinefull` for formatted line output
- Improved insert operation to support `before`, `after`, and `between` (both anchors) positioning modes
- Made autocorrect heuristics (boundary echo stripping, indent restoration) conditional on `PI_HL_AUTOCORRECT` environment variable
- Updated SSH host discovery to load from managed omp config paths (.omp/ssh.json and ~/.omp/agent/ssh.json) in addition to legacy root-level ssh.json and .ssh.json files
- Improved terminal output handling in interactive bash sessions to ensure all queued writes complete before returning results
### Fixed
- Fixed insert-between operation to properly validate adjacent anchor lines and strip boundary echoes from both sides
- Fixed terminal output handling to properly queue and serialize writes, preventing dropped or corrupted output in interactive bash sessions
## [12.12.1] - 2026-02-19
@@ -76,7 +76,6 @@ Call create_conventional_analysis with:
}
</output_format>
<examples>
<example name="feature-with-api">
{
"type": "feat",
@@ -146,5 +145,4 @@ Call create_conventional_analysis with:
"details": [],
"issue_refs": []
}
</example>
</examples>
</example>
@@ -230,13 +230,28 @@ handlebars.registerHelper("jtdToTypeScript", (schema: unknown): string => jtdToT
handlebars.registerHelper("jsonStringify", (value: unknown): string => JSON.stringify(value));
/**
* {{hashline lineNum "content"}} — compute a real hashline ref for prompt examples.
* Returns `"lineNum:hash"` using the actual hash algorithm.
* {{hlineref lineNum "content"}} — compute a real hashline ref for prompt examples.
* Returns `"lineNum#hash"` using the actual hash algorithm.
*/
handlebars.registerHelper("hashline", (lineNum: unknown, content: unknown): string => {
function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } {
const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10);
const str = typeof content === "string" ? content : String(content ?? "");
return `${num}:${computeLineHash(num, str)}`;
const text = typeof content === "string" ? content : String(content ?? "");
const ref = `${num}#${computeLineHash(num, text)}`;
return { num, text, ref };
}
handlebars.registerHelper("hlineref", (lineNum: unknown, content: unknown): string => {
const { ref } = formatHashlineRef(lineNum, content);
return ref;
});
/**
* {{hlinefull lineNum "content"}} — format a full read-style line with prefix.
* Returns `"lineNum#hash|content"`.
*/
handlebars.registerHelper("hlinefull", (lineNum: unknown, content: unknown): string => {
const { ref, text } = formatHashlineRef(lineNum, content);
return `${ref}|${text}`;
});
export function renderPromptTemplate(template: string, context: TemplateContext = {}): string {
@@ -287,7 +287,7 @@ export const SETTINGS_SCHEMA = {
ui: {
tab: "config",
label: "Read hash lines",
description: "Include line hashes in read output for hashline edit mode (LINE:HASH|content)",
description: "Include line hashes in read output for hashline edit mode (LINE#ID|content)",
},
},
showHardwareCursor: {
+226 -67
View File
@@ -4,12 +4,12 @@
* Each line in a file is identified by its 1-indexed line number and a short
* base36 hash derived from the normalized line content (xxHash32, truncated to 4
* base36 chars).
* The combined `LINE:HASH` reference acts as both an address and a staleness check:
* The combined `LINE#ID` reference acts as both an address and a staleness check:
* if the file has changed since the caller last read it, hash mismatches are caught
* before any mutation occurs.
*
* Displayed format: `LINENUM:HASH|CONTENT`
* Reference format: `"LINENUM:HASH"` (e.g. `"5:a3f2"`)
* Displayed format: `LINENUM#HASH|CONTENT`
* Reference format: `"LINENUM#HASH"` (e.g. `"5#a3f2"`)
*/
import type { HashlineEdit } from "./index";
@@ -18,45 +18,84 @@ 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: "insertAfter"; after: { line: number; hash: string } }
| { kind: "insertBefore"; before: { line: number; hash: string } }
| { kind: "insertBetween"; after: { line: number; hash: string }; before: { line: number; hash: string } }
| { kind: "insertAtEof" };
function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dst: string } {
if ("set_line" in edit) {
function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dstLines: string[] } {
if ("set" in edit) {
return {
spec: { kind: "single", ref: parseLineRef(edit.set_line.anchor) },
dst: edit.set_line.new_text,
spec: { kind: "single", ref: parseLineRef(edit.set.ref) },
dstLines: edit.set.body,
};
}
if ("replace_lines" in edit) {
const r = edit.replace_lines as Record<string, string>;
const start = parseLineRef(r.start_anchor);
if (!r.end_anchor) {
if ("set_range" in edit) {
const r = edit.set_range as Record<string, unknown>;
const start = parseLineRef(r.beg as string);
if (!r.end) {
return {
spec: { kind: "single", ref: start },
dst: r.new_text ?? "",
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
};
}
const end = parseLineRef(r.end_anchor);
const end = parseLineRef(r.end as string);
return {
spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end },
dst: r.new_text ?? "",
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
};
}
if ("insert" in edit) {
const r = edit.insert as Record<string, unknown>;
const dstLines = Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.text ?? r.content ?? ""));
const hasAfterField = "after" in r;
const hasBeforeField = "before" in r;
const afterRef = r.after;
const beforeRef = r.before;
if (hasAfterField && (typeof afterRef !== "string" || afterRef.trim().length === 0)) {
throw new Error('insert.after must be a non-empty "LINE#ID" string when provided');
}
if (hasBeforeField && (typeof beforeRef !== "string" || beforeRef.trim().length === 0)) {
throw new Error('insert.before must be a non-empty "LINE#ID" string when provided');
}
const hasAfter = hasAfterField && typeof afterRef === "string";
const hasBefore = hasBeforeField && typeof beforeRef === "string";
if (hasAfter && hasBefore) {
return {
spec: {
kind: "insertBetween",
after: parseLineRef(afterRef),
before: parseLineRef(beforeRef),
},
dstLines,
};
}
if (hasAfter) {
return {
spec: { kind: "insertAfter", after: parseLineRef(afterRef) },
dstLines,
};
}
if (hasBefore) {
return {
spec: { kind: "insertBefore", before: parseLineRef(beforeRef) },
dstLines,
};
}
return { spec: { kind: "insertAtEof" }, dstLines };
}
if ("replace" in edit) {
throw new Error("replace edits are applied separately; do not pass them to applyHashlineEdits");
}
return {
spec: { kind: "insertAfter", after: parseLineRef(edit.insert_after.anchor) },
dst: edit.insert_after.text ?? (edit.insert_after as Record<string, string>).content ?? "",
};
throw new Error("Unknown hashline edit operation");
}
/** Split dst into lines; empty string means delete (no lines). */
function splitDstLines(dst: string): string[] {
return dst === "" ? [] : dst.split("\n");
}
/** Pattern matching hashline display format: `LINE:HASH|CONTENT` */
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+:[0-9a-zA-Z]{1,16}\|/;
/** Pattern matching hashline display format: `LINE#ID|CONTENT` */
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}\|/;
/** Pattern matching a unified-diff `+` prefix (but not `++`) */
const DIFF_PLUS_RE = /^\+(?!\+)/;
@@ -179,6 +218,25 @@ 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 stripInsertBoundaryEcho(afterLine: string, beforeLine: string, dstLines: string[]): string[] {
let out = dstLines;
if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) {
out = out.slice(1);
}
if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) {
out = out.slice(0, -1);
}
return out;
}
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.
@@ -206,7 +264,7 @@ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine:
/**
* Strip hashline display prefixes and diff `+` markers from replacement lines.
*
* Models frequently copy the `LINE:HASH ` prefix from read output into their
* Models frequently copy the `LINE#ID ` prefix from read output into their
* replacement content, or include unified-diff `+` prefixes. Both corrupt the
* output file. This strips them heuristically before application.
*/
@@ -262,7 +320,7 @@ export function computeLineHash(idx: number, line: string): string {
/**
* Format file content with hashline prefixes for display.
*
* Each line becomes `LINENUM:HASH|CONTENT` where LINENUM is 1-indexed.
* Each line becomes `LINENUM#HASH|CONTENT` where LINENUM is 1-indexed.
*
* @param content - Raw file content string
* @param startLine - First line number (1-indexed, defaults to 1)
@@ -271,7 +329,7 @@ export function computeLineHash(idx: number, line: string): string {
* @example
* ```
* formatHashLines("function hi() {\n return;\n}")
* // "1:HH|function hi() {\n2:HH| return;\n3:HH|}"
* // "1#HH|function hi() {\n2#HH| return;\n3#HH|}"
* ```
*/
export function formatHashLines(content: string, startLine = 1): string {
@@ -280,7 +338,7 @@ export function formatHashLines(content: string, startLine = 1): string {
.map((line, i) => {
const num = startLine + i;
const hash = computeLineHash(num, line);
return `${num}:${hash}|${line}`;
return `${num}#${hash}|${line}`;
})
.join("\n");
}
@@ -352,7 +410,7 @@ export async function* streamHashLinesFromUtf8(
};
const pushLine = (line: string): string[] => {
const formatted = `${lineNum}:${computeLineHash(lineNum, line)}|${line}`;
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
lineNum++;
const chunksToYield: string[] = [];
@@ -446,7 +504,7 @@ export async function* streamHashLinesFromLines(
const pushLine = (line: string): string[] => {
sawAnyLine = true;
const formatted = `${lineNum}:${computeLineHash(lineNum, line)}|${line}`;
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
lineNum++;
const chunksToYield: string[] = [];
@@ -498,24 +556,24 @@ export async function* streamHashLinesFromLines(
}
/**
* Parse a line reference string like `"5:abcd"` into structured form.
* Parse a line reference string like `"5#abcd"` into structured form.
*
* @throws Error if the format is invalid (not `NUMBER:HEXHASH`)
* @throws Error if the format is invalid (not `NUMBER#HEXHASH`)
*/
export function parseLineRef(ref: string): { line: number; hash: string } {
// Strip display-format suffix: "5:ab|some content" → "5:ab", or legacy "5:ab some content" → "5:ab"
// Strip display-format suffix: "5#ab|some content" → "5#ab", or legacy "5#ab some content" → "5#ab"
// Models often copy the full display format from read output.
const cleaned = ref
.replace(/\|.*$/, "")
.replace(/ {2}.*$/, "")
.replace(/^>+\s*/, "")
.trim();
const normalized = cleaned.replace(/\s*:\s*/, ":");
const strictMatch = normalized.match(/^(\d+):([0-9a-zA-Z]{1,16})$/);
const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+):([0-9a-zA-Z]{${HASH_LEN}})`));
const normalized = cleaned.replace(/\s*#\s*/, "#");
const strictMatch = normalized.match(/^(\d+)#([0-9a-zA-Z]{1,16})$/);
const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+)#([0-9a-zA-Z]{${HASH_LEN}})`));
const match = strictMatch ?? prefixMatch;
if (!match) {
throw new Error(`Invalid line reference "${ref}". Expected format "LINE:HASH" (e.g. "5:aa").`);
throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`);
}
const line = Number.parseInt(match[1], 10);
if (line < 1) {
@@ -535,7 +593,7 @@ const MISMATCH_CONTEXT = 2;
* Error thrown when one or more hashline references have stale hashes.
*
* Displays grep-style output with `>>>` markers on mismatched lines,
* showing the correct `LINE:HASH` so the caller can fix all refs at once.
* showing the correct `LINE#ID` so the caller can fix all refs at once.
*/
export class HashlineMismatchError extends Error {
readonly remaps: ReadonlyMap<string, string>;
@@ -548,7 +606,7 @@ export class HashlineMismatchError extends Error {
const remaps = new Map<string, string>();
for (const m of mismatches) {
const actual = computeLineHash(m.line, fileLines[m.line - 1]);
remaps.set(`${m.line}:${m.expected}`, `${m.line}:${actual}`);
remaps.set(`${m.line}#${m.expected}`, `${m.line}#${actual}`);
}
this.remaps = remaps;
}
@@ -573,7 +631,7 @@ export class HashlineMismatchError extends Error {
const lines: string[] = [];
lines.push(
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE:HASH references shown below (>>> marks changed lines).`,
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE#ID references shown below (>>> marks changed lines).`,
);
lines.push("");
@@ -587,7 +645,7 @@ export class HashlineMismatchError extends Error {
const content = fileLines[lineNum - 1];
const hash = computeLineHash(lineNum, content);
const prefix = `${lineNum}:${hash}`;
const prefix = `${lineNum}#${hash}`;
if (mismatchSet.has(lineNum)) {
lines.push(`>>> ${prefix}|${content}`);
@@ -624,8 +682,8 @@ 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 (`set_line`, `replace_lines`,
* `insert_after`). Line references are resolved via {@link parseLineRef}
* Each edit operation identifies target lines directly (`set`, `set_range`,
* `insert`). Line references are resolved via {@link parseLineRef}
* and hashes validated before any mutation.
*
* Edits are sorted bottom-up (highest effective line first) so earlier
@@ -651,12 +709,14 @@ export function applyHashlineEdits(
let firstChangedLine: number | undefined;
const noopEdits: Array<{ editIndex: number; loc: string; currentContent: string }> = [];
const autocorrect = Bun.env.PI_HL_AUTOCORRECT === "1";
// Parse src specs and dst lines up front
const parsed = edits.map(edit => {
const parsedEdit = parseHashlineEdit(edit);
return {
spec: parsedEdit.spec,
dstLines: stripNewLinePrefixes(splitDstLines(parsedEdit.dst)),
dstLines: stripNewLinePrefixes(parsedEdit.dstLines),
};
});
@@ -673,6 +733,15 @@ export function applyHashlineEdits(
case "insertAfter":
touched.add(spec.after.line);
break;
case "insertBefore":
touched.add(spec.before.line);
break;
case "insertBetween":
touched.add(spec.after.line);
touched.add(spec.before.line);
break;
case "insertAtEof":
break;
}
}
return touched;
@@ -700,11 +769,38 @@ export function applyHashlineEdits(
}
case "insertAfter": {
if (dstLines.length === 0) {
throw new Error('Insert-after edit (src "N:HH..") requires non-empty dst');
throw new Error('Insert-after edit (src "N#HH..") requires non-empty dst');
}
if (!validateRef(spec.after)) continue;
break;
}
case "insertBefore": {
if (dstLines.length === 0) {
throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst');
}
if (!validateRef(spec.before)) continue;
break;
}
case "insertBetween": {
if (dstLines.length === 0) {
throw new Error('Insert-between edit (src "A#HH.. B#HH..") requires non-empty dst');
}
if (spec.before.line !== spec.after.line + 1) {
throw new Error(
`insert requires adjacent anchors (after ${spec.after.line}, before ${spec.before.line})`,
);
}
const afterValid = validateRef(spec.after);
const beforeValid = validateRef(spec.before);
if (!afterValid || !beforeValid) continue;
break;
}
case "insertAtEof": {
if (dstLines.length === 0) {
throw new Error("Insert-at-EOF edit requires non-empty dst");
}
break;
}
case "range": {
if (spec.start.line > spec.end.line) {
throw new Error(`Range start line ${spec.start.line} must be <= end line ${spec.end.line}`);
@@ -736,6 +832,15 @@ export function applyHashlineEdits(
case "insertAfter":
lineKey = `i:${p.spec.after.line}`;
break;
case "insertBefore":
lineKey = `ib:${p.spec.before.line}`;
break;
case "insertBetween":
lineKey = `ix:${p.spec.after.line}:${p.spec.before.line}`;
break;
case "insertAtEof":
lineKey = "ieof";
break;
}
const dstKey = `${lineKey}|${p.dstLines.join("\n")}`;
if (seenEditKeys.has(dstKey)) {
@@ -767,6 +872,18 @@ export function applyHashlineEdits(
sortLine = p.spec.after.line;
precedence = 1;
break;
case "insertBefore":
sortLine = p.spec.before.line;
precedence = 2;
break;
case "insertBetween":
sortLine = p.spec.before.line;
precedence = 3;
break;
case "insertAtEof":
sortLine = fileLines.length + 1;
precedence = 4;
break;
}
return { ...p, idx, sortLine, precedence };
});
@@ -777,7 +894,7 @@ export function applyHashlineEdits(
for (const { spec, dstLines, idx } of annotated) {
switch (spec.kind) {
case "single": {
const merged = maybeExpandSingleLineMerge(spec.ref.line, dstLines);
const merged = autocorrect ? maybeExpandSingleLineMerge(spec.ref.line, dstLines) : null;
if (merged) {
const origLines = originalFileLines.slice(
merged.startLine - 1,
@@ -794,7 +911,7 @@ export function applyHashlineEdits(
if (origLines.join("\n") === nextLines.join("\n")) {
noopEdits.push({
editIndex: idx,
loc: `${spec.ref.line}:${spec.ref.hash}`,
loc: `${spec.ref.line}#${spec.ref.hash}`,
currentContent: origLines.join("\n"),
});
break;
@@ -806,16 +923,22 @@ export function applyHashlineEdits(
const count = 1;
const origLines = originalFileLines.slice(spec.ref.line - 1, spec.ref.line);
let stripped = stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines);
stripped = restoreOldWrappedLines(origLines, stripped);
let newLines = restoreIndentForPairedReplacement(origLines, stripped);
if (origLines.join("\n") === newLines.join("\n") && origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))) {
let stripped = autocorrect
? stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines)
: dstLines;
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
if (
autocorrect &&
origLines.join("\n") === newLines.join("\n") &&
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
) {
newLines = normalizeConfusableHyphensInLines(newLines);
}
if (origLines.join("\n") === newLines.join("\n")) {
noopEdits.push({
editIndex: idx,
loc: `${spec.ref.line}:${spec.ref.hash}`,
loc: `${spec.ref.line}#${spec.ref.hash}`,
currentContent: origLines.join("\n"),
});
break;
@@ -827,16 +950,22 @@ export function applyHashlineEdits(
case "range": {
const count = spec.end.line - spec.start.line + 1;
const origLines = originalFileLines.slice(spec.start.line - 1, spec.start.line - 1 + count);
let stripped = stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines);
stripped = restoreOldWrappedLines(origLines, stripped);
let newLines = restoreIndentForPairedReplacement(origLines, stripped);
if (origLines.join("\n") === newLines.join("\n") && origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))) {
let stripped = autocorrect
? stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines)
: dstLines;
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
if (
autocorrect &&
origLines.join("\n") === newLines.join("\n") &&
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
) {
newLines = normalizeConfusableHyphensInLines(newLines);
}
if (origLines.join("\n") === newLines.join("\n")) {
noopEdits.push({
editIndex: idx,
loc: `${spec.start.line}:${spec.start.hash}`,
loc: `${spec.start.line}#${spec.start.hash}`,
currentContent: origLines.join("\n"),
});
break;
@@ -847,11 +976,11 @@ export function applyHashlineEdits(
}
case "insertAfter": {
const anchorLine = originalFileLines[spec.after.line - 1];
const inserted = stripInsertAnchorEchoAfter(anchorLine, dstLines);
const inserted = autocorrect ? stripInsertAnchorEchoAfter(anchorLine, dstLines) : dstLines;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
loc: `${spec.after.line}:${spec.after.hash}`,
loc: `${spec.after.line}#${spec.after.hash}`,
currentContent: originalFileLines[spec.after.line - 1],
});
break;
@@ -860,23 +989,53 @@ export function applyHashlineEdits(
trackFirstChanged(spec.after.line + 1);
break;
}
case "insertBefore": {
const anchorLine = originalFileLines[spec.before.line - 1];
const inserted = autocorrect ? stripInsertAnchorEchoBefore(anchorLine, dstLines) : dstLines;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
loc: `${spec.before.line}#${spec.before.hash}`,
currentContent: originalFileLines[spec.before.line - 1],
});
break;
}
fileLines.splice(spec.before.line - 1, 0, ...inserted);
trackFirstChanged(spec.before.line);
break;
}
case "insertBetween": {
const afterLine = originalFileLines[spec.after.line - 1];
const beforeLine = originalFileLines[spec.before.line - 1];
const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, dstLines) : dstLines;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
loc: `${spec.after.line}#${spec.after.hash}..${spec.before.line}#${spec.before.hash}`,
currentContent: `${afterLine}\n${beforeLine}`,
});
break;
}
fileLines.splice(spec.before.line - 1, 0, ...inserted);
trackFirstChanged(spec.before.line);
break;
}
case "insertAtEof": {
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...dstLines);
trackFirstChanged(1);
break;
}
fileLines.splice(fileLines.length, 0, ...dstLines);
trackFirstChanged(fileLines.length - dstLines.length + 1);
break;
}
}
}
const warnings: string[] = [];
let diffLineCount = Math.abs(fileLines.length - originalFileLines.length);
for (let i = 0; i < Math.min(fileLines.length, originalFileLines.length); i++) {
if (fileLines[i] !== originalFileLines[i]) diffLineCount++;
}
if (diffLineCount > edits.length * 4) {
warnings.push(
`Edit changed ${diffLineCount} lines across ${edits.length} operations — verify no unintended reformatting.`,
);
}
return {
content: fileLines.join("\n"),
firstChangedLine,
...(warnings.length > 0 ? { warnings } : {}),
...(noopEdits.length > 0 ? { noopEdits } : {}),
};
+37 -39
View File
@@ -129,35 +129,38 @@ const patchEditSchema = Type.Object({
export type ReplaceParams = Static<typeof replaceEditSchema>;
export type PatchParams = Static<typeof patchEditSchema>;
const hashlineSingleSchema = Type.Object(
const hashlineSetSchema = Type.Object(
{
set_line: Type.Object({
anchor: Type.String({ description: 'Line reference "LINE:HASH"' }),
new_text: Type.String({ description: 'Replacement content (\\n-separated) — "" for delete' }),
set: Type.Object({
ref: Type.String({ description: 'Line reference "LINE#ID"' }),
body: Type.Array(Type.String(), { description: "Replacement lines (empty array to delete)" }),
}),
},
{ additionalProperties: true },
);
const hashlineRangeSchema = Type.Object(
const hashlineSetRangeSchema = Type.Object(
{
replace_lines: Type.Object({
start_anchor: Type.String({ description: 'Start line ref "LINE:HASH"' }),
end_anchor: Type.String({ description: 'End line ref "LINE:HASH"' }),
new_text: Type.String({ description: 'Replacement content (\\n-separated) — "" for delete' }),
}),
},
{ additionalProperties: true },
);
const hashlineInsertAfterSchema = Type.Object(
{
insert_after: Type.Object({
anchor: Type.String({ description: 'Insert after this line "LINE:HASH"' }),
text: Type.String({ description: "Content to insert (\\n-separated); must be non-empty" }),
set_range: Type.Object({
beg: Type.String({ description: 'Start line ref "LINE#ID"' }),
end: Type.String({ description: 'End line ref "LINE#ID"' }),
body: Type.Array(Type.String(), { description: "Replacement lines (empty array to delete)" }),
}),
},
{ additionalProperties: true },
);
const hashlineInsertSchema = Type.Union([
Type.Object(
{
insert: Type.Object({
before: Type.Optional(Type.String({ minLength: 1, description: 'Insert before this line "LINE#ID"' })),
after: Type.Optional(Type.String({ minLength: 1, description: 'Insert after this line "LINE#ID"' })),
body: Type.Array(Type.String(), { description: "Lines to insert; must be non-empty" }),
}),
},
{ additionalProperties: true },
),
]);
const hashlineReplaceSchema = Type.Object(
{
replace: Type.Object({
@@ -169,9 +172,9 @@ const hashlineReplaceSchema = Type.Object(
{ additionalProperties: true },
);
const hashlineEditItemSchema = Type.Union([
hashlineSingleSchema,
hashlineRangeSchema,
hashlineInsertAfterSchema,
hashlineSetSchema,
hashlineSetRangeSchema,
hashlineInsertSchema,
hashlineReplaceSchema,
]);
const hashlineEditSchema = Type.Object(
@@ -426,30 +429,23 @@ export class EditTool implements AgentTool<TInput> {
if (("old_text" in edit || "new_text" in edit) && !("replace" in edit)) {
throw new Error(
`edits[${i}] contains 'old_text'/'new_text' at top level (replace mode). ` +
`Use {replace: {old_text, new_text}} for hashline content replace, or {set_line}, {replace_lines}, {insert_after}.`,
`Use {replace: {old_text, new_text}} for hashline content replace, or {set}, {set_range}, {insert}.`,
);
}
if ("diff" in edit) {
throw new Error(
`edits[${i}] contains 'diff' field from patch mode. ` +
`Hashline edits use: {set_line}, {replace_lines}, {insert_after}, or {replace}.`,
`Hashline edits use: {set}, {set_range}, {insert}, or {replace}.`,
);
}
if (
!("set_line" in edit) &&
!("replace_lines" in edit) &&
!("insert_after" in edit) &&
!("replace" in edit)
) {
if (!("set" in edit) && !("set_range" in edit) && !("insert" in edit) && !("replace" in edit)) {
throw new Error(
`edits[${i}] must contain exactly one of: 'set_line', 'replace_lines', 'insert_after', or 'replace'. Got keys: [${Object.keys(edit).join(", ")}].`,
`edits[${i}] must contain exactly one of: 'set', 'set_range', 'insert', or 'replace'. Got keys: [${Object.keys(edit).join(", ")}].`,
);
}
}
const anchorEdits = edits.filter(
(e): e is HashlineEdit => "set_line" in e || "replace_lines" in e || "insert_after" in e,
);
const anchorEdits = edits.filter((e): e is HashlineEdit => "set" in e || "set_range" in e || "insert" in e);
const replaceEdits = edits.filter(
(e): e is { replace: { old_text: string; new_text: string; all?: boolean } } => "replace" in e,
);
@@ -467,7 +463,7 @@ export class EditTool implements AgentTool<TInput> {
const originalNormalized = normalizeToLF(content);
let normalizedContent = originalNormalized;
// Apply anchor-based edits first (set_line, replace_lines, insert_after)
// Apply anchor-based edits first (set, set_range, insert)
const anchorResult = applyHashlineEdits(normalizedContent, anchorEdits);
normalizedContent = anchorResult.content;
@@ -508,17 +504,19 @@ export class EditTool implements AgentTool<TInput> {
const targetLines: string[] = [];
for (const edit of edits) {
const refs: string[] = [];
if ("set_line" in edit) refs.push(edit.set_line.anchor);
else if ("replace_lines" in edit)
refs.push(edit.replace_lines.start_anchor, edit.replace_lines.end_anchor);
else if ("insert_after" in edit) refs.push(edit.insert_after.anchor);
if ("set" in edit) refs.push(edit.set.ref);
else if ("set_range" in edit) refs.push(edit.set_range.beg, edit.set_range.end);
else if ("insert" in edit) {
if (edit.insert.after) refs.push(edit.insert.after);
if (edit.insert.before) refs.push(edit.insert.before);
}
for (const ref of refs) {
try {
const parsed = parseLineRef(ref);
if (parsed.line >= 1 && parsed.line <= lines.length) {
const lineContent = lines[parsed.line - 1];
const hash = computeLineHash(parsed.line, lineContent);
targetLines.push(`${parsed.line}:${hash}|${lineContent}`);
targetLines.push(`${parsed.line}#${hash}|${lineContent}`);
}
} catch {
/* skip malformed refs */
+37 -23
View File
@@ -86,9 +86,9 @@ interface EditRenderArgs {
}
type HashlineEditPreview =
| { set_line: { anchor: string; new_text: string } }
| { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
| { insert_after: { anchor: string; text: string } }
| { set: { ref: string; body: string[] } }
| { set_range: { beg: string; end: string; body: string[] } }
| { insert: { before?: string; after?: string; body: string[] } }
| { replace: { old_text: string; new_text: string; all?: boolean } };
/** Extended context for edit tool rendering */
@@ -168,20 +168,28 @@ function formatStreamingHashlineEdits(edits: unknown[], uiTheme: Theme, ui: Tool
dst: "",
};
}
if ("set_line" in editRecord) {
const setLine = asRecord(editRecord.set_line);
if ("set" in editRecord) {
const setLine = asRecord(editRecord.set);
return {
srcLabel: `• set_line ${typeof setLine?.anchor === "string" ? setLine.anchor : "…"}`,
dst: typeof setLine?.new_text === "string" ? setLine.new_text : "",
srcLabel: `• set ${typeof setLine?.ref === "string" ? setLine.ref : "…"}`,
dst: Array.isArray(setLine?.body)
? (setLine.body as string[]).join("\n")
: typeof setLine?.body === "string"
? setLine.body
: "",
};
}
if ("replace_lines" in editRecord) {
const replaceLines = asRecord(editRecord.replace_lines);
const start = typeof replaceLines?.start_anchor === "string" ? replaceLines.start_anchor : "…";
const end = typeof replaceLines?.end_anchor === "string" ? replaceLines.end_anchor : "…";
if ("set_range" in editRecord) {
const setRange = asRecord(editRecord.set_range);
const start = typeof setRange?.beg === "string" ? setRange.beg : "…";
const end = typeof setRange?.end === "string" ? setRange.end : "…";
return {
srcLabel: `• replace_lines ${start}..${end}`,
dst: typeof replaceLines?.new_text === "string" ? replaceLines.new_text : "",
srcLabel: `• set_range ${start}..${end}`,
dst: Array.isArray(setRange?.body)
? (setRange.body as string[]).join("\n")
: typeof setRange?.body === "string"
? setRange.body
: "",
};
}
if ("replace" in editRecord) {
@@ -192,17 +200,23 @@ function formatStreamingHashlineEdits(edits: unknown[], uiTheme: Theme, ui: Tool
dst: typeof replace?.new_text === "string" ? replace.new_text : "",
};
}
if ("insert_after" in editRecord) {
const insertAfter = asRecord(editRecord.insert_after);
const anchor = typeof insertAfter?.anchor === "string" ? insertAfter.anchor : "…";
const text =
typeof insertAfter?.text === "string"
? insertAfter.text
: typeof insertAfter?.content === "string"
? insertAfter.content
: "";
if ("insert" in editRecord) {
const insertOp = asRecord(editRecord.insert);
const after = typeof insertOp?.after === "string" ? insertOp.after : undefined;
const before = typeof insertOp?.before === "string" ? insertOp.before : undefined;
const body = insertOp?.body;
const text = Array.isArray(body)
? (body as string[]).join("\n")
: typeof body === "string"
? body
: typeof insertOp?.text === "string"
? insertOp.text
: typeof insertOp?.content === "string"
? (insertOp.content as string)
: "";
const refs = [after, before].filter(Boolean).join("..") || "…";
return {
srcLabel: `• insert_after ${anchor}..`,
srcLabel: `• insert ${refs}`,
dst: text,
};
}
@@ -11,14 +11,18 @@ Powerful search tool built on ripgrep.
</instruction>
<output>
Results are always content mode. {{#if IS_HASHLINE_MODE}}Lines are hashline-prefixed as `LINE:HASH|content`.{{else}}{{#if IS_LINE_NUMBER_MODE}}Lines are line-number-prefixed.{{else}}Lines are plain text (no prefixes).{{/if}}{{/if}}
Truncated at 100 matches by default (configurable via `limit`).
- Results are always content mode.
{{#if IS_HASHLINE_MODE}}
- Text output is CID prefixed: `LINE#ID|content`
{{else}}
{{#if IS_LINE_NUMBER_MODE}}
- Text output is line-number-prefixed
{{/if}}
{{/if}}
</output>
<critical>
- ALWAYS use Grep for search tasks—NEVER invoke `grep` or `rg` via Bash. Has correct permissions and access.
</critical>
<avoid>
- Open-ended searches requiring multiple rounds—use Task tool with explore subagent instead
</avoid>
- ALWAYS use Grep when searching for content.
- NEVER invoke `grep` or `rg` via Bash.
- If the search is open-ended, requiring multiple rounds, use Task tool with explore subagent instead
</critical>
@@ -1,85 +1,130 @@
# Edit (Hash Anchored)
Line-addressed edits using hash-verified line references. Read files in hashline mode, collect exact `LINE:HASH` references, and submit edits that change only the targeted token or expression.
**CRITICAL: Copy `LINE:HASH` refs verbatim from read output. Use only the anchor prefix (e.g., `{{hashline 42 "const x = 1"}}`), never the trailing source text after `|`.**
Apply precise file edits using `LINE#ID` anchors from `read` output.
**CRITICAL:** anchors are `LINE#ID` only. Copy verbatim from the prefix (example: `{{hlineref 42 "const x = 1"}}`). Never include `|content`.
<workflow>
1. Read the target file (`read`) to obtain `LINE:HASH` references
2. Collect the exact `LINE:HASH` refs for lines you will change
3. Direction-lock each mutation: identify the exact current token/expression → the intended replacement
4. Submit one `edit` call containing all operations for that file
5. If another edit is needed on the same file: re-read first, then edit (hashes change after every edit)
6. Respond with tool calls only — no prose
1. `read` the target range to capture current `LINE#ID` anchors.
2. Pick the smallest operation per change site (`set`/`set_range`/`insert`/`replace`).
3. Direction-lock every edit: exact current text -> intended text.
4. Submit one `edit` call per file containing all operations.
5. If another edit is needed in that file, re-read first (hashes changed).
6. Output tool calls only; no prose.
</workflow>
<operations>
Four edit variants are available:
- **`set_line`**: Replace a single line
`{ set_line: { anchor: "LINE:HASH", new_text: "..." } }`
`new_text: ""` keeps the line but makes it blank.
- **`replace_lines`**: Replace a contiguous range (use for deletions with `new_text: ""`)
`{ replace_lines: { start_anchor: "LINE:HASH", end_anchor: "LINE:HASH", new_text: "..." } }`
- **`insert_after`**: Add new content after an anchor line
`{ insert_after: { anchor: "LINE:HASH", text: "..." } }`
- **`replace`**: Substring-style fuzzy match (when line refs are unavailable)
`{ replace: { old_text: "...", new_text: "...", all?: boolean } }`
**Atomicity:** All edits in one call validate against the file as last read. Line numbers and hashes refer to the original state, not post-edit state. The applicator sorts and applies bottom-up automatically.
- **`set`** (single line replace/delete)
- `{ set: { ref: "LINE#ID", body: ["..."] } }`
- `body: []` deletes the line; `body: [""]` keeps a blank line.
- **`set_range`** (contiguous multi-line replace/delete)
- `{ set_range: { beg: "LINE#ID", end: "LINE#ID", body: ["..."] } }`
- Use for swaps, block rewrites, or deleting a full span (`body: []`).
- **`insert`** (new content)
- `{ insert: { before: "LINE#ID", body: ["..."] } }`
- `{ insert: { after: "LINE#ID", body: ["..."] } }`
- `{ insert: { after: "LINE#ID", before: "LINE#ID", body: ["..."] } }` (between adjacent anchors; safest for blocks)
- `{ insert: { body: ["..."] } }` (append EOF only when intentional)
- **`replace`** (fuzzy text fallback when anchors unavailable)
- `{ replace: { old_text: "...", new_text: "...", all?: boolean } }`
**Atomicity:** all ops validate against the same pre-edit file snapshot; refs are interpreted against last `read`; applicator applies bottom-up.
</operations>
<rules>
1. **Scope each operation minimally.** One logical change site per operation. Use separate `set_line` ops for non-adjacent lines instead of a wide `replace_lines` that spans unchanged code.
2. **Preserve original formatting exactly.** Copy each line's whitespace, braces, semicolons, trailing commas, and style — then change only the targeted token/expression. Keep `import { foo }` as-is; keep indentation and line breaks as-is.
3. **Use `insert_after` for additions.** When adding a field, argument, or import near existing lines, prefer `insert_after` over replacing a neighboring line.
4. **Ensure `new_text` differs from current content.** Identical content is rejected as a no-op.
5. **Edit only requested lines.** Leave unrelated code untouched.
6. **Lock mutation direction.** Replace the exact currently-present token with the intended target. For swaps between two locations, use two `set_line` ops in one call.
1. **Minimize scope:** one logical mutation site per operation.
2. **Preserve formatting:** keep indentation, punctuation, line breaks, trailing commas, brace style.
3. **Prefer insertion over neighbor rewrites:** anchor on structural boundaries (`}`, `]`, `},`) not interior property lines.
4. **No no-ops:** replacement body must differ from current content.
5. **Touch only requested code:** avoid incidental edits.
6. **Use exact current tokens:** never "rewrite approximately"; mutate the token that exists now.
7. **For swaps/moves:** prefer one `set_range` over multiple conflicting `set`s.
</rules>
<selection_heuristics>
- One wrong line -> `set`
- Adjacent block changed -> `set_range`
- Missing line/block -> `insert`
- Cannot trust line anchors (generated/unknown offsets) -> `replace` (last resort)
</selection_heuristics>
<anchor_hygiene>
- Copy anchor IDs exactly from `read` or error output.
- Never handcraft hashes.
- For inserts, prefer `after+before` dual anchors when both boundaries are known.
- Re-read after each successful edit call before issuing another on same file.
</anchor_hygiene>
<recovery>
**Hash mismatch (`>>>` error):**
→ Copy the updated `LINE:HASH` refs from the error output verbatim and retry with the same intended mutation.
→ Re-read only if you need lines not shown in the error.
→ If mismatch repeats after applying updated refs, stop and re-read the relevant region.
**No-op error ("identical content"):**
→ Stop. Re-read the file — you are targeting the wrong line or your replacement is not different.
→ After 2 consecutive no-op errors on the same line, re-read the entire function/block.
**Hash mismatch (`>>>`)**
- Retry with the updated anchors shown in error output.
- Re-read only if required anchors are missing from error snippet.
- If mismatch repeats, stop and re-read the exact block.
**No-op / identical content**
- Re-read immediately; target is stale or replacement equals current text.
- After two no-ops on same area, re-read the full function/block before retry.
</recovery>
<examples>
<example name="replace single line">
set_line: { anchor: "{{hashline 2 " x"}}", new_text: " x = 99" }
<example name="single-line token fix (set)">
Read:
{{hlinefull 41 " return record != null && record.status === 'fulfilled';"}}
Edit:
set: { ref: "{{hlineref 41 " return record != null && record.status === 'fulfilled';"}}", body: [" return record != null && record?.status === 'fulfilled';"] }
</example>
<example name="replace range">
replace_lines: { start_anchor: "{{hashline 5 "old start line"}}", end_anchor: "{{hashline 8 "old end line"}}", new_text: " combined = True" }
<example name="restore missing declaration (insert before)">
Read:
{{hlinefull 15 "export function useX(...): boolean {"}}
{{hlinefull 16 " useEffect(() => {"}}
Edit:
insert: { before: "{{hlineref 16 " useEffect(() => {"}}", body: [" const [isVisible, setIsVisible] = useState(true);"] }
</example>
<example name="delete lines">
replace_lines: { start_anchor: "{{hashline 5 "line to delete A"}}", end_anchor: "{{hashline 6 "line to delete B"}}", new_text: "" }
<example name="insert between siblings (after+before)">
Read:
{{hlinefull 120 " doFirst();"}}
{{hlinefull 121 " doThird();"}}
Edit:
insert: { after: "{{hlineref 120 " doFirst();"}}", before: "{{hlineref 121 " doThird();"}}", body: [" doSecond();"] }
</example>
<example name="insert after">
insert_after: { anchor: "{{hashline 3 "anchor line content"}}", text: " # new comment" }
<example name="swap adjacent lines atomically (set_range)">
Read:
{{hlinefull 190 " thenable.then(resolve, ignoreReject);"}}
{{hlinefull 191 " chunkCache.set(chunkId, thenable);"}}
Edit:
set_range: { beg: "{{hlineref 190 " thenable.then(resolve, ignoreReject);"}}", end: "{{hlineref 191 " chunkCache.set(chunkId, thenable);"}}", body: [" chunkCache.set(chunkId, thenable);", " thenable.then(resolve, ignoreReject);"] }
</example>
<example name="multiple edits (bottom-up safe)">
set_line: { anchor: "{{hashline 10 "old line 10"}}", new_text: " return False" }
set_line: { anchor: "{{hashline 3 "old line 3"}}", new_text: " x = 42" }
<example name="insert guard before comment">
Read:
{{hlinefull 188 ""}}
{{hlinefull 189 " // If we don't find a Fiber on the comment..."}}
Edit:
insert: { after: "{{hlineref 188 ""}}", body: [" if (targetFiber) {", " targetInst = targetFiber;", " }"] }
</example>
<example name="content replace (substr-style, no hashes)">
<example name="anti-pattern: interior anchor vs boundary anchor">
Bad:
insert: { after: "195#d3", body: [" { id: \"nanogpt\", available: true },"] }
Good:
insert: { after: "196#f6", before: "197#fc", body: [" { id: \"nanogpt\", available: true },"] }
</example>
<example name="explicit EOF append">
insert: { body: ["// end marker"] }
</example>
<example name="replace fallback only">
replace: { old_text: "x = 42", new_text: "x = 99" }
</example>
</examples>
<validation>
Before submitting, verify:
- [ ] Payload shape: `{"path": string, "edits": [operation, ...]}` with non-empty `edits` array
- [ ] Each operation has exactly one variant key: `set_line` | `replace_lines` | `insert_after` | `replace`
- [ ] Each anchor is copied exactly from the `LINE:HASH` prefix (no spaces, no trailing source text)
- [ ] `new_text`/`text` contains plain replacement lines only — no `LINE:HASH` prefixes, no diff `+` markers
- [ ] Each replacement differs from the current line content
- [ ] Each operation targets one logical change site with minimal scope
- [ ] Formatting of replaced lines matches the original exactly, except for the targeted change
- [ ] Payload shape is `{ "path": string, "edits": [operation, ...] }` and `edits` is non-empty
- [ ] Every operation has exactly one variant key: `set` | `set_range` | `insert` | `replace`
- [ ] Every anchor is copied exactly as `LINE#ID` (no spaces, no `|content`)
- [ ] `body` lines are raw content only (no diff markers, no anchor prefixes)
- [ ] Every replacement is meaningfully different from current content
- [ ] Scope is minimal and formatting is preserved except targeted token changes
</validation>
**REMINDER: Copy `LINE:HASH` refs verbatim. Anchors are `LINE:HASH` only — never `LINE:HASH|content`. Preserve exact formatting. Change only the targeted token.**
**Final reminder:** anchors are immutable references to the last read snapshot. Re-read when state changes, then edit.
@@ -6,12 +6,10 @@ Reads files from local filesystem or internal URLs.
- Reads up to {{DEFAULT_MAX_LINES}} lines default
- Use `offset` and `limit` for large files
{{#if IS_HASHLINE_MODE}}
- Text output is hashline-prefixed: `LINE:HASH|content`
- Text output is CID prefixed: `LINE#ID|content`
{{else}}
{{#if IS_LINE_NUMBER_MODE}}
- Text output is line-number-prefixed
{{else}}
- Text output is plain (no line prefixes)
{{/if}}
{{/if}}
- Supports images (PNG, JPG) and PDFs
+1 -1
View File
@@ -209,7 +209,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => {
if (useHashLines) {
const ref = `${lineNumber}:${computeLineHash(lineNumber, line)}`;
const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`;
return isMatch ? `>>${ref}|${line}` : ` ${ref}|${line}`;
}
const padded = lineNumber.toString().padStart(lineWidth, " ");
+2 -2
View File
@@ -772,7 +772,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const prependHashLines = (text: string, startNum: number): string => {
const textLines = text.split("\n");
return textLines
.map((line, i) => `${startNum + i}:${computeLineHash(startNum + i, line)}|${line}`)
.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}|${line}`)
.join("\n");
};
const formatText = (text: string, startNum: number): string => {
@@ -929,7 +929,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
};
const prependHashLines = (text: string, startNum: number): string => {
const textLines = text.split("\n");
return textLines.map((line, i) => `${startNum + i}:${computeLineHash(startNum + i, line)}|${line}`).join("\n");
return textLines.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}|${line}`).join("\n");
};
const formatText = (text: string, startNum: number): string => {
if (shouldAddHashLines) return prependHashLines(text, startNum);
+260 -97
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import {
applyHashlineEdits,
computeLineHash,
@@ -47,30 +47,30 @@ describe("formatHashLines", () => {
test("formats single line", () => {
const result = formatHashLines("hello");
const hash = computeLineHash(1, "hello");
expect(result).toBe(`1:${hash}|hello`);
expect(result).toBe(`1#${hash}|hello`);
});
test("formats multiple lines with 1-indexed numbers", () => {
const result = formatHashLines("foo\nbar\nbaz");
const lines = result.split("\n");
expect(lines).toHaveLength(3);
expect(lines[0]).toStartWith("1:");
expect(lines[1]).toStartWith("2:");
expect(lines[2]).toStartWith("3:");
expect(lines[0]).toStartWith("1#");
expect(lines[1]).toStartWith("2#");
expect(lines[2]).toStartWith("3#");
});
test("respects custom startLine", () => {
const result = formatHashLines("foo\nbar", 10);
const lines = result.split("\n");
expect(lines[0]).toStartWith("10:");
expect(lines[1]).toStartWith("11:");
expect(lines[0]).toStartWith("10#");
expect(lines[1]).toStartWith("11#");
});
test("handles empty lines in content", () => {
const result = formatHashLines("foo\n\nbar");
const lines = result.split("\n");
expect(lines).toHaveLength(3);
expect(lines[1]).toMatch(/^2:[0-9a-z]{2,4}\|$/);
expect(lines[1]).toMatch(/^2#[0-9a-z]{2,4}\|$/);
});
test("round-trips with computeLineHash", () => {
@@ -79,7 +79,7 @@ describe("formatHashLines", () => {
const lines = formatted.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(\d+):([0-9a-z]+)\|(.*)$/);
const match = lines[i].match(/^(\d+)#([0-9a-z]+)\|(.*)$/);
expect(match).not.toBeNull();
const lineNum = Number.parseInt(match![1], 10);
const hash = match![2];
@@ -148,34 +148,34 @@ describe("streamHashLinesFrom*", () => {
describe("parseLineRef", () => {
test("parses valid reference", () => {
const ref = parseLineRef("5:abcd");
const ref = parseLineRef("5#abcd");
expect(ref).toEqual({ line: 5, hash: "abcd" });
});
test("parses single-digit hash", () => {
const ref = parseLineRef("1:a");
const ref = parseLineRef("1#a");
expect(ref).toEqual({ line: 1, hash: "a" });
});
test("parses long hash", () => {
const ref = parseLineRef("100:abcdef0123456789");
const ref = parseLineRef("100#abcdef0123456789");
expect(ref).toEqual({ line: 100, hash: "abcdef0123456789" });
});
test("rejects missing colon", () => {
test("rejects missing separator", () => {
expect(() => parseLineRef("5abcd")).toThrow(/Invalid line reference/);
});
test("rejects non-numeric line", () => {
expect(() => parseLineRef("abc:1234")).toThrow(/Invalid line reference/);
expect(() => parseLineRef("abc#1234")).toThrow(/Invalid line reference/);
});
test("rejects non-alphanumeric hash", () => {
expect(() => parseLineRef("5:$$$$")).toThrow(/Invalid line reference/);
expect(() => parseLineRef("5#$$$$")).toThrow(/Invalid line reference/);
});
test("rejects line number 0", () => {
expect(() => parseLineRef("0:abcd")).toThrow(/Line number must be >= 1/);
expect(() => parseLineRef("0#abcd")).toThrow(/Line number must be >= 1/);
});
test("rejects empty string", () => {
@@ -183,7 +183,7 @@ describe("parseLineRef", () => {
});
test("rejects empty hash", () => {
expect(() => parseLineRef("5:")).toThrow(/Invalid line reference/);
expect(() => parseLineRef("5#")).toThrow(/Invalid line reference/);
});
});
@@ -227,12 +227,12 @@ describe("validateLineRef", () => {
describe("applyHashlineEdits — replace", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("replaces single line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(2, "bbb"), new_text: "BBB" } }];
const edits: HashlineEdit[] = [{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBBB\nccc");
@@ -241,9 +241,7 @@ describe("applyHashlineEdits — replace", () => {
test("range replace (shrink)", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ replace_lines: { start_anchor: makeRef(2, "bbb"), end_anchor: makeRef(3, "ccc"), new_text: "ONE" } },
];
const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: ["ONE"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nONE\nddd");
@@ -252,7 +250,7 @@ describe("applyHashlineEdits — replace", () => {
test("range replace (same count)", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ replace_lines: { start_anchor: makeRef(2, "bbb"), end_anchor: makeRef(3, "ccc"), new_text: "XXX\nYYY" } },
{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: ["XXX", "YYY"] } },
];
const result = applyHashlineEdits(content, edits);
@@ -262,7 +260,7 @@ describe("applyHashlineEdits — replace", () => {
test("replaces first line", () => {
const content = "first\nsecond\nthird";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(1, "first"), new_text: "FIRST" } }];
const edits: HashlineEdit[] = [{ set: { ref: makeRef(1, "first"), body: ["FIRST"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("FIRST\nsecond\nthird");
@@ -271,7 +269,7 @@ describe("applyHashlineEdits — replace", () => {
test("replaces last line", () => {
const content = "first\nsecond\nthird";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(3, "third"), new_text: "THIRD" } }];
const edits: HashlineEdit[] = [{ set: { ref: makeRef(3, "third"), body: ["THIRD"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("first\nsecond\nTHIRD");
@@ -285,12 +283,12 @@ describe("applyHashlineEdits — replace", () => {
describe("applyHashlineEdits — delete", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("deletes single line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(2, "bbb"), new_text: "" } }];
const edits: HashlineEdit[] = [{ set: { ref: makeRef(2, "bbb"), body: [] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nccc");
@@ -299,9 +297,7 @@ describe("applyHashlineEdits — delete", () => {
test("deletes range of lines", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ replace_lines: { start_anchor: makeRef(2, "bbb"), end_anchor: makeRef(3, "ccc"), new_text: "" } },
];
const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: [] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nddd");
@@ -309,7 +305,7 @@ describe("applyHashlineEdits — delete", () => {
test("deletes first line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(1, "aaa"), new_text: "" } }];
const edits: HashlineEdit[] = [{ set: { ref: makeRef(1, "aaa"), body: [] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("bbb\nccc");
@@ -317,7 +313,7 @@ describe("applyHashlineEdits — delete", () => {
test("deletes last line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set_line: { anchor: makeRef(3, "ccc"), new_text: "" } }];
const edits: HashlineEdit[] = [{ set: { ref: makeRef(3, "ccc"), body: [] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb");
@@ -330,12 +326,12 @@ describe("applyHashlineEdits — delete", () => {
describe("applyHashlineEdits — insert", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("inserts after a line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "NEW" } }];
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: ["NEW"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
@@ -344,7 +340,7 @@ describe("applyHashlineEdits — insert", () => {
test("inserts multiple lines", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "x\ny\nz" } }];
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: ["x", "y", "z"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nx\ny\nz\nbbb");
@@ -352,7 +348,7 @@ describe("applyHashlineEdits — insert", () => {
test("inserts after last line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(2, "bbb"), text: "NEW" } }];
const edits: HashlineEdit[] = [{ insert: { after: makeRef(2, "bbb"), body: ["NEW"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb\nNEW");
@@ -360,10 +356,154 @@ describe("applyHashlineEdits — insert", () => {
test("insert with empty dst throws", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "" } }];
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: [] } }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("inserts at EOF without anchors", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { body: ["NEW"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb\nNEW");
expect(result.firstChangedLine).toBe(3);
});
test("inserts at EOF into empty file without anchors", () => {
const content = "";
const edits: HashlineEdit[] = [{ insert: { body: ["NEW"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("NEW");
expect(result.firstChangedLine).toBe(1);
});
test("insert at EOF with empty dst throws", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { body: [] } }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// applyHashlineEdits — insert (before)
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — insert (before)", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("inserts before a line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["NEW"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
expect(result.firstChangedLine).toBe(2);
});
test("inserts multiple lines before", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["x", "y", "z"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nx\ny\nz\nbbb");
});
test("inserts before first line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: ["NEW"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("NEW\naaa\nbbb");
});
test("insert with before and empty text throws", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: [] } }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("strips anchor echo from end of inserted text (autocorrect)", () => {
Bun.env.PI_HL_AUTOCORRECT = "1";
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["NEW", "bbb"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
});
Bun.env.PI_HL_AUTOCORRECT = undefined;
test("insert before and insert after at same line produce correct order", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ insert: { before: makeRef(2, "bbb"), body: ["BEFORE"] } },
{ insert: { after: makeRef(2, "bbb"), body: ["AFTER"] } },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBEFORE\nbbb\nAFTER\nccc");
});
test("insert before with set at same line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ insert: { before: makeRef(2, "bbb"), body: ["BEFORE"] } },
{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBEFORE\nBBB\nccc");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// applyHashlineEdits — insert (between)
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — insert (between)", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("inserts between adjacent anchors", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ insert: { after: makeRef(1, "aaa"), before: makeRef(2, "bbb"), body: ["NEW"] } },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
expect(result.firstChangedLine).toBe(2);
});
test("inserts multiple lines between anchors", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{
insert: {
after: makeRef(1, "aaa"),
before: makeRef(2, "bbb"),
body: ["x", "y", "z"],
},
},
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nx\ny\nz\nbbb\nccc");
});
test("strips boundary echo from both sides (autocorrect)", () => {
Bun.env.PI_HL_AUTOCORRECT = "1";
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{
insert: {
after: makeRef(1, "aaa"),
before: makeRef(2, "bbb"),
body: ["aaa", "NEW", "bbb"],
},
},
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
});
Bun.env.PI_HL_AUTOCORRECT = undefined;
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -371,13 +511,22 @@ describe("applyHashlineEdits — insert", () => {
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — heuristics", () => {
let origEnv: string | undefined;
beforeAll(() => {
origEnv = Bun.env.PI_HL_AUTOCORRECT;
Bun.env.PI_HL_AUTOCORRECT = "1";
});
afterAll(() => {
Bun.env.PI_HL_AUTOCORRECT = origEnv;
});
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("strips insert-after anchor echo", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(2, "bbb"), text: "bbb\nNEW" } }];
const edits: HashlineEdit[] = [{ insert: { after: makeRef(2, "bbb"), body: ["bbb", "NEW"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb\nNEW\nccc");
@@ -398,11 +547,11 @@ describe("applyHashlineEdits — heuristics", () => {
const end = 6;
const edits: HashlineEdit[] = [
{
replace_lines: {
start_anchor: makeRef(start, "if (cond) {"),
end_anchor: makeRef(end, "}"),
set_range: {
beg: makeRef(start, "if (cond) {"),
end: makeRef(end, "}"),
// Echoes line after the range ("after();") and also reformats the import line.
new_text: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"].join("\n"),
body: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"],
},
},
];
@@ -417,10 +566,10 @@ describe("applyHashlineEdits — heuristics", () => {
const content = ["import { foo } from 'x';", "import { bar } from 'y';", "const x = 1;"].join("\n");
const edits: HashlineEdit[] = [
{
replace_lines: {
start_anchor: makeRef(1, "import { foo } from 'x';"),
end_anchor: makeRef(2, "import { bar } from 'y';"),
new_text: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"].join("\n"),
set_range: {
beg: makeRef(1, "import { foo } from 'x';"),
end: makeRef(2, "import { bar } from 'y';"),
body: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"],
},
},
];
@@ -439,9 +588,9 @@ describe("applyHashlineEdits — heuristics", () => {
const content = ["before();", longLine, "after();"].join("\n");
const edits: HashlineEdit[] = [
{
set_line: {
anchor: makeRef(2, longLine),
new_text: [
set: {
ref: makeRef(2, longLine),
body: [
"const",
"options",
"=",
@@ -452,7 +601,7 @@ describe("applyHashlineEdits — heuristics", () => {
"thirdLongIdentifier",
"+",
"fourthLongIdentifier;",
].join("\n"),
],
},
},
];
@@ -468,10 +617,10 @@ describe("applyHashlineEdits — heuristics", () => {
const edits: HashlineEdit[] = [
{
set_line: {
anchor: makeRef(1, " typeof HOOK === 'undefined' &&"),
set: {
ref: makeRef(1, " typeof HOOK === 'undefined' &&"),
// Model merged both lines into one and dropped indentation.
new_text: "typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'",
body: ["typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'"],
},
},
];
@@ -491,11 +640,10 @@ describe("applyHashlineEdits — heuristics", () => {
const edits: HashlineEdit[] = [
{
set_line: {
anchor: makeRef(2, " resolveRNStyle || hook.resolveRNStyle;"),
set: {
ref: makeRef(2, " resolveRNStyle || hook.resolveRNStyle;"),
// Model absorbed the declaration line and dropped indentation.
new_text:
"const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;",
body: ["const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;"],
},
},
];
@@ -509,14 +657,14 @@ describe("applyHashlineEdits — heuristics", () => {
);
});
test("accepts polluted src that starts with LINE:HASH but includes trailing content", () => {
test("accepts polluted src that starts with LINE#ID but includes trailing content", () => {
const content = "aaa\nbbb\nccc";
const srcHash = computeLineHash(2, "bbb");
const edits: HashlineEdit[] = [
{
set_line: {
anchor: `2:${srcHash}export function foo(a, b) {}`, // comma in trailing content
new_text: "BBB",
set: {
ref: `2#${srcHash}export function foo(a, b) {}`, // comma in trailing content
body: ["BBB"],
},
},
];
@@ -528,7 +676,7 @@ describe("applyHashlineEdits — heuristics", () => {
test("treats same-line ranges as single-line replacements", () => {
const content = "aaa\nbbb\nccc";
const good = makeRef(2, "bbb");
const edits: HashlineEdit[] = [{ replace_lines: { start_anchor: good, end_anchor: good, new_text: "BBB" } }];
const edits: HashlineEdit[] = [{ set_range: { beg: good, end: good, body: ["BBB"] } }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBBB\nccc");
});
@@ -538,9 +686,9 @@ describe("applyHashlineEdits — heuristics", () => {
// dst is byte-identical to original (en-dash), so this would normally be a no-op.
const edits: HashlineEdit[] = [
{
set_line: {
anchor: makeRef(2, "devtools–unsupported-bridge-protocol"),
new_text: "devtools–unsupported-bridge-protocol",
set: {
ref: makeRef(2, "devtools–unsupported-bridge-protocol"),
body: ["devtools–unsupported-bridge-protocol"],
},
},
];
@@ -556,14 +704,14 @@ describe("applyHashlineEdits — heuristics", () => {
describe("applyHashlineEdits — multiple edits", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("applies two non-overlapping replaces (bottom-up safe)", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [
{ set_line: { anchor: makeRef(2, "bbb"), new_text: "BBB" } },
{ set_line: { anchor: makeRef(4, "ddd"), new_text: "DDD" } },
{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } },
{ set: { ref: makeRef(4, "ddd"), body: ["DDD"] } },
];
const result = applyHashlineEdits(content, edits);
@@ -574,8 +722,8 @@ describe("applyHashlineEdits — multiple edits", () => {
test("applies replace + delete in one call", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ set_line: { anchor: makeRef(2, "bbb"), new_text: "BBB" } },
{ set_line: { anchor: makeRef(4, "ddd"), new_text: "" } },
{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } },
{ set: { ref: makeRef(4, "ddd"), body: [] } },
];
const result = applyHashlineEdits(content, edits);
@@ -585,8 +733,8 @@ describe("applyHashlineEdits — multiple edits", () => {
test("applies replace + insert in one call", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ set_line: { anchor: makeRef(3, "ccc"), new_text: "CCC" } },
{ insert_after: { anchor: makeRef(1, "aaa"), text: "INSERTED" } },
{ set: { ref: makeRef(3, "ccc"), body: ["CCC"] } },
{ insert: { after: makeRef(1, "aaa"), body: ["INSERTED"] } },
];
const result = applyHashlineEdits(content, edits);
@@ -597,13 +745,13 @@ describe("applyHashlineEdits — multiple edits", () => {
const content = "one\ntwo\nthree\nfour\nfive\nsix";
const edits: HashlineEdit[] = [
{
replace_lines: {
start_anchor: makeRef(2, "two"),
end_anchor: makeRef(3, "three"),
new_text: "TWO_THREE",
set_range: {
beg: makeRef(2, "two"),
end: makeRef(3, "three"),
body: ["TWO_THREE"],
},
},
{ set_line: { anchor: makeRef(6, "six"), new_text: "SIX" } },
{ set: { ref: makeRef(6, "six"), body: ["SIX"] } },
];
const result = applyHashlineEdits(content, edits);
@@ -624,19 +772,19 @@ describe("applyHashlineEdits — multiple edits", () => {
describe("applyHashlineEdits — errors", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}:${computeLineHash(lineNum, content)}`;
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("rejects stale hash", () => {
const content = "aaa\nbbb\nccc";
// Use a hash that doesn't match any line (avoid 00 — ccc hashes to 00)
const edits: HashlineEdit[] = [{ set_line: { anchor: "2:zz", new_text: "BBB" } }];
const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }];
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
});
test("stale hash error shows >>> markers with correct hashes", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [{ set_line: { anchor: "2:zz", new_text: "BBB" } }];
const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }];
try {
applyHashlineEdits(content, edits);
@@ -648,10 +796,10 @@ describe("applyHashlineEdits — errors", () => {
expect(msg).toContain(">>>");
// Should show the correct hash for line 2
const correctHash = computeLineHash(2, "bbb");
expect(msg).toContain(`2:${correctHash}|bbb`);
expect(msg).toContain(`2#${correctHash}|bbb`);
// Context lines should NOT have >>> markers
const lines = msg.split("\n");
const contextLines = lines.filter(l => l.startsWith(" ") && !l.startsWith(" ...") && l.includes(":"));
const contextLines = lines.filter(l => l.startsWith(" ") && !l.startsWith(" ...") && l.includes("#"));
expect(contextLines.length).toBeGreaterThan(0);
}
});
@@ -659,10 +807,7 @@ describe("applyHashlineEdits — errors", () => {
test("stale hash error collects all mismatches", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
// Use hashes that don't match any line (avoid 00 — ccc hashes to 00)
const edits: HashlineEdit[] = [
{ set_line: { anchor: "2:zz", new_text: "BBB" } },
{ set_line: { anchor: "4:zz", new_text: "DDD" } },
];
const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }, { set: { ref: "4#zz", body: ["DDD"] } }];
try {
applyHashlineEdits(content, edits);
@@ -681,8 +826,8 @@ describe("applyHashlineEdits — errors", () => {
test("does not relocate stale line refs even when hash uniquely matches another line", () => {
const content = "aaa\nbbb\nccc";
const staleButUnique = `2:${computeLineHash(1, "ccc")}`;
const edits: HashlineEdit[] = [{ set_line: { anchor: staleButUnique, new_text: "CCC" } }];
const staleButUnique = `2#${computeLineHash(1, "ccc")}`;
const edits: HashlineEdit[] = [{ set: { ref: staleButUnique, body: ["CCC"] } }];
try {
applyHashlineEdits(content, edits);
expect.unreachable("should have thrown");
@@ -695,35 +840,53 @@ describe("applyHashlineEdits — errors", () => {
test("does not relocate when expected hash is non-unique", () => {
const content = "dup\nmid\ndup";
const staleDuplicate = `2:${computeLineHash(1, "dup")}`;
const edits: HashlineEdit[] = [{ set_line: { anchor: staleDuplicate, new_text: "DUP" } }];
const staleDuplicate = `2#${computeLineHash(1, "dup")}`;
const edits: HashlineEdit[] = [{ set: { ref: staleDuplicate, body: ["DUP"] } }];
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
});
test("rejects out-of-range line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ set_line: { anchor: "10:aa", new_text: "X" } }];
const edits: HashlineEdit[] = [{ set: { ref: "10#aa", body: ["X"] } }];
expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/);
});
test("rejects range with start > end", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [
{ replace_lines: { start_anchor: makeRef(5, "eee"), end_anchor: makeRef(2, "bbb"), new_text: "X" } },
];
const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(5, "eee"), end: makeRef(2, "bbb"), body: ["X"] } }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("rejects insert-after with empty dst", () => {
test("rejects insert with after and empty text", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert_after: { anchor: makeRef(1, "aaa"), text: "" } }];
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: [] } }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("rejects insert with before and empty text", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: [] } }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("rejects insert with both anchors and empty text", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), before: makeRef(2, "bbb"), body: [] } }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("rejects insert with non-adjacent anchors", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ insert: { after: makeRef(1, "aaa"), before: makeRef(3, "ccc"), body: ["NEW"] } },
];
expect(() => applyHashlineEdits(content, edits)).toThrow(/adjacent anchors/);
});
test("rejects replace edit (handled separately in EditTool)", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ replace: { old_text: "aaa", new_text: "AAA" } }];
@@ -0,0 +1,604 @@
# Edit Benchmark Report
## Configuration
| Setting | Value |
|---------|-------|
| Date | 2026-02-19T13:44:38.721Z |
| Model | openai-codex/openai-codex/gpt-5.2-codex |
| Thinking Level | low |
| Runs per task | 1 |
| Edit Variant | hashline |
| Edit Fuzzy | auto |
| Edit Fuzzy Threshold | auto |
| Guided Mode | no |
| Max Attempts | 1 |
| No-op Retry Limit | 2 |
| Mutation Scope Window | 20 |
| Require Edit Tool | no |
| Require Read Tool | no |
| No-Edit Baseline | no |
## Summary
| Metric | Value |
|--------|-------|
| Total Tasks | 80 |
| Total Runs | 80 |
| Successful Runs | 65 |
| **Task Success Rate** | **81.3% (65/80)** |
| Verified Rate | 81.3% (65/80) |
| Edit Tool Usage Rate | 100.0% (80/80) |
| **Edit Success Rate** | **100.0%** |
| Timeout Runs | 0 |
| Mutation Intent Match Rate | 81.3% |
| Patch Failure Rate | 0.0% (0/83) |
| Tasks All Passing | 65 |
| Tasks Flaky/Failing | 15 |
### Tool Calls
| Tool | Total | Avg/Run |
|------|-------|---------|
| Read | 176 | 2.2 |
| Edit | 83 | 1.0 |
| Write | 0 | 0.0 |
| **Tool Input Chars** | 22,736 | 284 |
### Tokens & Time
| Metric | Total | Avg/Run |
|--------|-------|---------|
| Input Tokens | 514,176 | 6,427 |
| Output Tokens | 50,974 | 637 |
| Total Tokens | 2,451,230 | 30,640 |
| Duration | 1349.6s | 16.9s |
| **Avg Indent Score** | — | **0.00** |
### Hashline Edit Subtypes
| Operation | Count | % |
|-----------|-------|---|
| set | 73 | 81.1% |
| set_range | 9 | 10.0% |
| insert | 8 | 8.9% |
| replace | 0 | 0.0% |
| **Total** | **90** | 100% |
## Task Results
| Task | File | Success | Edit Hit | R/E/W | Tokens (In/Out) | Time | Indent |
|------|------|---------|----------|-------|-----------------|------|--------|
| Access Remove Optional Chain 001 | fallbackEvalContext.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,740/189 | 8.5s | 0.00 |
| Access Remove Optional Chain 003 | hookNamesCache.js | 0/1 ❌ | 100.0% | 2/1/0 | 4,224/230 | 8.4s | 0.00 |
| Access Remove Optional Chain 005 | registerDevToolsEventLogger.js | 1/1 ✅ | 100.0% | 2/1/0 | 7,443/278 | 31.8s | 0.00 |
| Access Remove Optional Chain 007 | index.js | 0/1 ❌ | 100.0% | 2/1/0 | 9,833/2,294 | 32.5s | 0.00 |
| Call Swap Call Args 001 | formatConsoleArguments.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,599/160 | 5.3s | 0.00 |
| Call Swap Call Args 003 | ReactFlightDOMServerBrowser.js | 1/1 ✅ | 100.0% | 2/1/0 | 20,884/206 | 6.3s | 0.00 |
| Call Swap Call Args 005 | ReactNoopPersistent.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,500/207 | 7.0s | 0.00 |
| Call Swap Call Args 007 | parseSourceAndMetadata.js | 1/1 ✅ | 100.0% | 2/1/0 | 7,829/190 | 9.9s | 0.00 |
| Duplicate Duplicate Line Flip 001 | isCustomElement.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,673/170 | 5.9s | 0.00 |
| Duplicate Duplicate Line Flip 003 | ReactFiberDevToolsHook.js | 0/1 ❌ | 100.0% | 2/1/0 | 7,961/5,020 | 65.6s | 0.00 |
| Duplicate Duplicate Line Flip 005 | shallowEqual.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,933/159 | 5.3s | 0.00 |
| Duplicate Duplicate Line Flip 007 | ReactDOMEventReplaying.js | 1/1 ✅ | 100.0% | 4/1/0 | 11,054/2,052 | 37.4s | 0.00 |
| Identifier Identifier Multi Edit 001 | Button.js | 1/1 ✅ | 100.0% | 2/1/0 | 3,907/167 | 16.4s | 0.00 |
| Identifier Identifier Multi Edit 003 | ReactFlightDOMClientBrowser.js | 1/1 ✅ | 100.0% | 2/1/0 | 9,430/224 | 8.3s | 0.00 |
| Identifier Identifier Multi Edit 005 | githubAPI.js | 1/1 ✅ | 100.0% | 2/1/0 | 3,163/206 | 9.9s | 0.00 |
| Identifier Identifier Multi Edit 007 | ReactFiberComponentStack.js | 1/1 ✅ | 100.0% | 1/1/0 | 3,844/165 | 14.0s | 0.00 |
| Import Swap Named Imports 001 | ListApp.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,338/203 | 6.3s | 0.00 |
| Import Swap Named Imports 003 | index.js | 1/1 ✅ | 100.0% | 2/1/0 | 8,647/275 | 6.5s | 0.00 |
| Import Swap Named Imports 005 | SuspenseScrubber.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,754/187 | 6.7s | 0.00 |
| Import Swap Named Imports 007 | InspectedElement.js | 0/1 ❌ | 100.0% | 4/1/0 | 5,527/998 | 22.6s | 0.00 |
| Literal Flip Boolean 001 | ReactDOMLegacyServerImpl.js | 1/1 ✅ | 100.0% | 2/1/0 | 4,596/659 | 22.9s | 0.00 |
| Literal Flip Boolean 003 | ReactProfilerTimer.js | 1/1 ✅ | 100.0% | 2/1/0 | 11,351/260 | 16.7s | 0.00 |
| Literal Flip Boolean 005 | OpenInEditorButton.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,510/185 | 6.2s | 0.00 |
| Literal Flip Boolean 007 | Element.js | 1/1 ✅ | 100.0% | 2/1/0 | 5,808/183 | 6.2s | 0.00 |
| Literal Off By One 001 | ReactOwnerStackFrames.js | 1/1 ✅ | 100.0% | 2/1/0 | 6,146/175 | 11.0s | 0.00 |
| Literal Off By One 003 | ReactFlightClientConfigBundlerTurbopack.js | 1/1 ✅ | 100.0% | 1/1/0 | 5,726/184 | 31.5s | 0.00 |
| Literal Off By One 005 | ContextMenu.js | 1/1 ✅ | 100.0% | 2/1/0 | 5,055/161 | 5.9s | 0.00 |
| Literal Off By One 007 | index.js | 1/1 ✅ | 100.0% | 2/1/0 | 11,005/210 | 6.1s | 0.00 |
| Operator Remove Negation 001 | prepareInjection.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,437/164 | 10.3s | 0.00 |
| Operator Remove Negation 003 | ReactDOMSelection.js | 1/1 ✅ | 100.0% | 1/1/0 | 4,776/481 | 36.7s | 0.00 |
| Operator Remove Negation 005 | ReactDOMContainer.js | 0/1 ❌ | 100.0% | 1/1/0 | 1,641/2,005 | 45.4s | 0.00 |
| Operator Remove Negation 007 | SelectEventPlugin.js | 1/1 ✅ | 100.0% | 3/1/0 | 5,483/228 | 30.8s | 0.00 |
| Operator Swap Arithmetic 001 | formatConsoleArguments.js | 1/1 ✅ | 100.0% | 2/1/0 | 4,128/164 | 9.0s | 0.00 |
| Operator Swap Arithmetic 003 | ReactFlightDOMServerBrowser.js | 1/1 ✅ | 100.0% | 2/1/0 | 4,615/318 | 7.3s | 0.00 |
| Operator Swap Arithmetic 005 | ReactFiberConfigWithNoResources.js | 1/1 ✅ | 100.0% | 2/1/0 | 5,301/176 | 7.9s | 0.00 |
| Operator Swap Arithmetic 007 | useCanvasInteraction.js | 1/1 ✅ | 100.0% | 2/1/0 | 9,041/249 | 12.6s | 0.00 |
| Operator Swap Comparison 001 | useSmartTooltip.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,914/284 | 6.8s | 0.00 |
| Operator Swap Comparison 003 | ReactFlightDOMServerBrowser.js | 1/1 ✅ | 100.0% | 2/1/0 | 5,560/205 | 5.7s | 0.00 |
| Operator Swap Comparison 005 | Rectangle.js | 1/1 ✅ | 100.0% | 2/1/0 | 3,198/205 | 6.0s | 0.00 |
| Operator Swap Comparison 007 | ReactFiberTreeReflection.js | 1/1 ✅ | 100.0% | 2/1/0 | 13,670/228 | 33.6s | 0.00 |
| Operator Swap Equality 001 | ReactNoopFlightClient.js | 1/1 ✅ | 100.0% | 2/1/0 | 3,245/177 | 8.1s | 0.00 |
| Operator Swap Equality 003 | astUtils.js | 1/1 ✅ | 100.0% | 2/1/0 | 18,143/205 | 8.9s | 0.00 |
| Operator Swap Equality 005 | ReactDOMContainer.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,384/155 | 9.0s | 0.00 |
| Operator Swap Equality 007 | ReactFlightDOMServerBrowser.js | 1/1 ✅ | 100.0% | 4/1/0 | 10,473/415 | 13.1s | 0.00 |
| Operator Swap Increment Decrement 001 | index.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,267/147 | 5.8s | 0.00 |
| Operator Swap Increment Decrement 003 | ReactFlightClientConfigBundlerESM.js | 1/1 ✅ | 100.0% | 2/1/0 | 4,283/180 | 8.2s | 0.00 |
| Operator Swap Increment Decrement 005 | ReactFiberViewTransitionComponent.js | 1/1 ✅ | 100.0% | 2/1/0 | 5,435/181 | 5.7s | 0.00 |
| Operator Swap Increment Decrement 007 | ReactFiberConcurrentUpdates.js | 1/1 ✅ | 100.0% | 1/1/0 | 11,537/203 | 30.0s | 0.00 |
| Operator Swap Logical 001 | ErrorView.js | 0/1 ❌ | 100.0% | 2/1/0 | 3,063/966 | 18.4s | 0.00 |
| Operator Swap Logical 003 | DevTools.js | 1/1 ✅ | 100.0% | 3/1/0 | 11,761/234 | 7.1s | 0.00 |
| Operator Swap Logical 005 | UseEffectEvent.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,211/233 | 6.2s | 0.00 |
| Operator Swap Logical 007 | getHookNameForLocation.js | 1/1 ✅ | 100.0% | 2/1/0 | 6,183/259 | 7.6s | 0.00 |
| Operator Swap Nullish 001 | ElementBadges.js | 1/1 ✅ | 100.0% | 2/1/0 | 8,460/199 | 7.3s | 0.00 |
| Operator Swap Nullish 003 | ReactComponentStackFrame.js | 1/1 ✅ | 100.0% | 3/1/0 | 5,744/214 | 6.9s | 0.00 |
| Operator Swap Nullish 005 | ReactLogo.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,013/228 | 5.8s | 0.00 |
| Operator Swap Nullish 007 | SuspenseBreadcrumbs.js | 0/1 ❌ | 100.0% | 2/1/0 | 7,282/3,404 | 47.1s | 0.00 |
| Regex Swap Regex Quantifier 001 | SimpleValues.js | 1/1 ✅ | 100.0% | 2/1/0 | 1,932/202 | 10.4s | 0.00 |
| Regex Swap Regex Quantifier 003 | utils.js | 1/1 ✅ | 100.0% | 3/1/0 | 9,066/3,501 | 58.4s | 0.00 |
| Regex Swap Regex Quantifier 005 | formatWithStyles.js | 1/1 ✅ | 100.0% | 1/1/0 | 2,312/428 | 16.0s | 0.00 |
| Regex Swap Regex Quantifier 007 | RunReactCompiler.ts | 1/1 ✅ | 100.0% | 2/1/0 | 10,335/4,456 | 62.3s | 0.00 |
| Structural Delete Statement 001 | useExtensionComponentsPanelVisibility.js | 1/1 ✅ | 100.0% | 3/1/0 | 2,461/382 | 11.7s | 0.00 |
| Structural Delete Statement 003 | useCanvasInteraction.js | 1/1 ✅ | 100.0% | 2/1/0 | 8,848/202 | 6.3s | 0.00 |
| Structural Delete Statement 005 | StackTraceView.js | 0/1 ❌ | 100.0% | 3/1/0 | 3,252/706 | 15.1s | 0.00 |
| Structural Delete Statement 007 | ReactDOMFizzStaticBrowser.js | 1/1 ✅ | 100.0% | 3/1/0 | 5,948/942 | 44.2s | 0.00 |
| Structural Remove Early Return 001 | ReactFlightAsyncDispatcher.js | 0/1 ❌ | 100.0% | 4/1/0 | 7,982/1,813 | 42.1s | 0.00 |
| Structural Remove Early Return 003 | ReactDOMComponentTree.js | 0/1 ❌ | 100.0% | 4/2/0 | 28,683/4,366 | 71.7s | 0.00 |
| Structural Remove Early Return 005 | TabBar.js | 0/1 ❌ | 100.0% | 2/1/0 | 3,677/216 | 17.7s | 0.00 |
| Structural Remove Early Return 007 | CommitTreeBuilder.js | 0/1 ❌ | 100.0% | 3/1/0 | 12,551/733 | 15.0s | 0.00 |
| Structural Swap Adjacent Lines 001 | reactPolling.js | 1/1 ✅ | 100.0% | 2/1/0 | 3,796/170 | 17.6s | 0.00 |
| Structural Swap Adjacent Lines 003 | OwnersStack.js | 1/1 ✅ | 100.0% | 2/1/0 | 6,290/197 | 6.7s | 0.00 |
| Structural Swap Adjacent Lines 005 | ReactOwnerStackFrames.js | 1/1 ✅ | 100.0% | 2/1/0 | 3,386/265 | 7.5s | 0.00 |
| Structural Swap Adjacent Lines 007 | ReactFlightClientConfigBundlerWebpack.js | 0/1 ❌ | 100.0% | 2/1/0 | 9,732/455 | 12.2s | 0.00 |
| Structural Swap If Else 001 | utils.js | 0/1 ❌ | 100.0% | 2/2/0 | 5,872/1,415 | 28.1s | 0.00 |
| Structural Swap If Else 003 | ReactDOMFloat.js | 0/1 ❌ | 100.0% | 2/1/0 | 16,578/703 | 13.1s | 0.00 |
| Structural Swap If Else 005 | ReactClientConsoleConfigPlain.js | 1/1 ✅ | 100.0% | 2/1/0 | 4,976/251 | 8.6s | 0.00 |
| Structural Swap If Else 007 | index.js | 1/1 ✅ | 100.0% | 4/2/0 | 7,268/1,144 | 23.9s | 0.00 |
| Unicode Unicode Hyphen 001 | formatProdErrorMessage.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,127/163 | 13.1s | 0.00 |
| Unicode Unicode Hyphen 003 | SourceMapConsumer.js | 1/1 ✅ | 100.0% | 3/1/0 | 5,723/212 | 7.2s | 0.00 |
| Unicode Unicode Hyphen 005 | babel.config.js | 1/1 ✅ | 100.0% | 2/1/0 | 2,129/158 | 5.3s | 0.00 |
| Unicode Unicode Hyphen 007 | ReactInternalTestUtils.js | 1/1 ✅ | 100.0% | 3/1/0 | 7,524/190 | 6.7s | 0.00 |
## Category Summary
| Category | Runs | Verified | Edit Used | Success | Min/Avg/Max Difficulty |
|----------|------|----------|-----------|---------|------------------------|
| access | 4 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) | 3 / 7.5 / 10 |
| call | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | 2 / 7.8 / 14 |
| duplicate | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) | 8 / 11.3 / 17 |
| identifier | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | 4 / 6.8 / 9 |
| import | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) | 2 / 3.8 / 5 |
| literal | 8 | 100.0% (8/8) | 100.0% (8/8) | 100.0% (8/8) | 3 / 6.9 / 10 |
| operator | 28 | 89.3% (25/28) | 100.0% (28/28) | 89.3% (25/28) | 3 / 6.7 / 10 |
| regex | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | 2 / 5.8 / 10 |
| structural | 16 | 50.0% (8/16) | 100.0% (16/16) | 50.0% (8/16) | 4 / 8.3 / 13 |
| unicode | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) | 2 / 4.8 / 8 |
## Mutation Summary
| Mutation | Category | Runs | Verified | Edit Used | Success |
|----------|----------|------|----------|-----------|---------|
| delete-statement | structural | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) |
| duplicate-line-flip | duplicate | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) |
| flip-boolean | literal | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) |
| identifier-multi-edit | identifier | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) |
| off-by-one | literal | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) |
| remove-early-return | structural | 4 | 0.0% (0/4) | 100.0% (4/4) | 0.0% (0/4) |
| remove-negation | operator | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) |
| remove-optional-chain | access | 4 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) |
| swap-adjacent-lines | structural | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) |
| swap-arithmetic | operator | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) |
| swap-call-args | call | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) |
| swap-comparison | operator | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) |
| swap-equality | operator | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) |
| swap-if-else | structural | 4 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) |
| swap-increment-decrement | operator | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) |
| swap-logical | operator | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) |
| swap-named-imports | import | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) |
| swap-nullish | operator | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) |
| swap-regex-quantifier | regex | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) |
| unicode-hyphen | unicode | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) |
## Difficulty Summary
| Difficulty Score | Runs | Verified | Edit Used | Success |
|------------------|------|----------|-----------|---------|
| 0-2 | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/4) |
| 3-5 | 24 | 83.3% (20/24) | 100.0% (24/24) | 83.3% (20/24) |
| 6-8 | 27 | 85.2% (23/27) | 100.0% (27/27) | 85.2% (23/27) |
| 9+ | 25 | 72.0% (18/25) | 100.0% (25/25) | 72.0% (18/25) |
## Failed Tasks (0% passing)
### Access Remove Optional Chain 003 (hookNamesCache.js) — 0/1
**All runs failed with same error:** File mismatch for hookNamesCache.js
**Sample agent response (run 1):**
```
Done.
```
**Diff (expected vs actual):**
```diff
@@ -54 @@
export function hasAlreadyLoadedHookNames(element: Element): boolean {
const record = map.get(element);
- return record != null && record.status === 'fulfilled';
+ return record != null && record?.status === 'fulfilled';
}
export function getAlreadyLoadedHookNames(element: Element): HookNames | null {
@@ -129 @@
event_status: status,
duration_ms: durationMs,
inspected_element_display_name: element.displayName,
- inspected_element_number_of_hooks: resolvedHookNames?.size ?? null,
+ inspected_element_number_of_hooks: resolvedHookNames.size ?? null,
});
};
```
### Access Remove Optional Chain 007 (index.js) — 0/1
**All runs failed with same error:** File mismatch for index.js
**Sample agent response (run 1):**
```
Fixed.
```
**Diff (expected vs actual):**
```diff
@@ -64 @@
},
send(event: string, payload: any, transferable?: Array<any>) {
- port?.postMessage({ event, payload }, transferable);
+ port.postMessage({ event, payload }, transferable);
},
});
@@ -493 @@
// but later user continues the session and Chrome reconnects all ports
// Bridge object is still in-memory, though
if (lastSubscribedBridgeListener) {
- port.onMessage.addListener(lastSubscribedBridgeListener);
+ port?.onMessage.addListener(lastSubscribedBridgeListener);
}
// This port may be disconnected by Chrome at some point, this callback
```
### Duplicate Duplicate Line Flip 003 (ReactFiberDevToolsHook.js) — 0/1
**All runs failed with same error:** File mismatch for ReactFiberDevToolsHook.js
**Sample agent response (run 1):**
```
Fixed the duplicated error log literal in `ReactFiberDevToolsHook.js`.
```
**Diff (expected vs actual):**
```diff
@@ -46 @@
export function injectInternals(internals: Object): boolean {
if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ === 'undefined') {
// No DevTools
- return false;
+ return true;
}
const hook = __REACT_DEVTOOLS_GLOBAL_HOOK__;
if (hook.isDisabled) {
@@ -74 @@
} catch (err) {
// Catch all errors because it is unsafe to throw during initialization.
if (__DEV__) {
- console.error('React instrumentation encountered an error: %o.', err);
+ console.error('React instrumentation encountered an error: %o', err);
}
}
if (hook.checkDCE) {
```
### Import Swap Named Imports 007 (InspectedElement.js) — 0/1
**All runs failed with same error:** File mismatch for InspectedElement.js
**Sample agent response (run 1):**
```
Done.
```
**Diff (expected vs actual):**
```diff
@@ -10 @@
import type { SourceMappedLocation } from 'react-devtools-shared/src/symbolicateSource';
import * as React from 'react';
-import { useCallback, useContext, useSyncExternalStore } from 'react';
+import { useSyncExternalStore, useCallback, useContext } from 'react';
import { TreeStateContext } from './TreeContext';
import { BridgeContext, StoreContext, OptionsContext } from '../context';
import Button from '../Button';
```
### Operator Remove Negation 005 (ReactDOMContainer.js) — 0/1
**All runs failed with same error:** File mismatch for ReactDOMContainer.js
**Diff (expected vs actual):**
```diff
@@ -12 @@
import { ELEMENT_NODE, COMMENT_NODE, DOCUMENT_NODE, DOCUMENT_FRAGMENT_NODE } from './HTMLNodeType';
export function isValidContainer(node: any): boolean {
- return !!(
- node &&
+ return (
+ !!node &&
(node.nodeType === ELEMENT_NODE ||
node.nodeType === DOCUMENT_NODE ||
node.nodeType === DOCUMENT_FRAGMENT_NODE ||
```
### Operator Swap Logical 001 (ErrorView.js) — 0/1
**All runs failed with same error:** File mismatch for ErrorView.js
**Sample agent response (run 1):**
```
Done.
```
**Diff (expected vs actual):**
```diff
@@ -32 @@
{children}
<div className={styles.ErrorInfo}>
<div className={styles.HeaderRow}>
- <div className={styles.ErrorHeader}>Uncaught Error: {errorMessage || ''}</div>
- {dismissError !== null && (
+ <div className={styles.ErrorHeader}>Uncaught Error: {errorMessage && ''}</div>
+ {dismissError !== null || (
<Button className={styles.CloseButton} onClick={dismissError}>
Dismiss
<ButtonIcon className={styles.CloseButtonIcon} type="close" />
```
### Operator Swap Nullish 007 (SuspenseBreadcrumbs.js) — 0/1
**All runs failed with same error:** File mismatch for SuspenseBreadcrumbs.js
**Sample agent response (run 1):**
```
Change applied. No tests run.
```
**Diff (expected vs actual):**
```diff
@@ -225 @@
<Button
className={parent !== null ? undefined : styles.NotInStore}
onClick={parent !== null ? selectElement.bind(null, parentID) : null}
- title={`Up to ${parent === null ? 'Unknown' : parent.name || 'Unknown'}`}
+ title={`Up to ${parent === null ? 'Unknown' : (parent.name || 'Unknown')}`}`}
>
<ButtonIcon type="previous" />
</Button>
```
### Structural Delete Statement 005 (StackTraceView.js) — 0/1
**All runs failed with same error:** File mismatch for StackTraceView.js
**Sample agent response (run 1):**
```
Restored missing `ignored` computation in StackTraceView.js. Not run (not requested).
```
**Diff (expected vs actual):**
```diff
@@ -49 @@
);
const [functionName, url, line, column] =
symbolicatedCallSite !== null ? symbolicatedCallSite.location : callSite;
+
const ignored = symbolicatedCallSite !== null ? symbolicatedCallSite.ignored : false;
// TODO: Make an option to be able to toggle the display of ignore listed rows.
// Ideally this UI should be higher than a single Stack Trace so that there's not
```
### Structural Remove Early Return 001 (ReactFlightAsyncDispatcher.js) — 0/1
**All runs failed with same error:** File mismatch for ReactFlightAsyncDispatcher.js
**Diff (expected vs actual):**
```diff
@@ -17 @@
if (request) {
return getCache(request);
}
+
return new Map();
}
```
### Structural Remove Early Return 003 (ReactDOMComponentTree.js) — 0/1
**All runs failed with same error:** File mismatch for ReactDOMComponentTree.js
**Sample agent response (run 1):**
```
Fix applied in `ReactDOMComponentTree.js` with the missing guard clause restored.
Tests not run (not requested).
```
**Diff (expected vs actual):**
```diff
@@ -188 @@
if (targetFiber) {
return targetFiber;
}
+
// If we don't find a Fiber on the comment, it might be because
// we haven't gotten to hydrate it yet. There might still be a
// parent boundary that hasn't above this one so we need to find
```
### Structural Remove Early Return 005 (TabBar.js) — 0/1
**All runs failed with same error:** File mismatch for TabBar.js
**Sample agent response (run 1):**
```
Done.
```
**Diff (expected vs actual):**
```diff
@@ -90 @@
<Fragment>
{tabs.map((tab) => {
if (tab === null) {
- return <div key="VRule" className={styles.VRule} />;
+ return null;
}
}
-
const { icon, id, label, title } = tab;
let button = (
```
### Structural Remove Early Return 007 (CommitTreeBuilder.js) — 0/1
**All runs failed with same error:** File mismatch for CommitTreeBuilder.js
**Sample agent response (run 1):**
```
Fixed by restoring the early return when the requested commit tree is already cached. No tests run.
```
**Diff (expected vs actual):**
```diff
@@ -63 @@
}
const commitTrees = ((rootToCommitTreeMap.get(rootID): any): Array<CommitTree>);
- if (commitIndex < commitTrees.length) {
- return commitTrees[commitIndex];
- }
const { profilingData } = profilerStore;
if (profilingData === null) {
@@ -84 @@
);
}
+ if (commitTrees.length > commitIndex) {
+ return commitTrees[commitIndex];
+ }
+
let commitTree: CommitTree = ((null: any): CommitTree);
for (let index = commitTrees.length; index <= commitIndex; index++) {
// Commits are generated sequentially and cached.
```
### Structural Swap Adjacent Lines 007 (ReactFlightClientConfigBundlerWebpack.js) — 0/1
**All runs failed with same error:** File mismatch for ReactFlightClientConfigBundlerWebpack.js
**Sample agent response (run 1):**
```
Change applied. No tests run.
```
**Diff (expected vs actual):**
```diff
@@ -184 @@
const entry = chunkCache.get(chunkId);
if (entry === undefined) {
const thenable = loadChunk(chunkId, chunkFilename);
- promises.push(thenable);
- // $FlowFixMe[method-unbinding]
const resolve = chunkCache.set.bind(chunkCache, chunkId, null);
const resolve = chunkCache.set.bind(chunkCache, chunkId, null);
- thenable.then(resolve, ignoreReject);
+ // $FlowFixMe[method-unbinding]
+ promises.push(thenable);
chunkCache.set(chunkId, thenable);
chunkCache.set(chunkId, thenable);
+ thenable.then(resolve, ignoreReject);
} else if (entry !== null) {
promises.push(entry);
}
```
### Structural Swap If Else 001 (utils.js) — 0/1
**All runs failed with same error:** File mismatch for utils.js
**Diff (expected vs actual):**
```diff
@@ -23 @@
if (sourceURL.startsWith('file:///')) {
filePath = new URL(sourceURL).pathname;
} else if (sourceURL.includes('://')) {
- // $FlowFixMe[cannot-resolve-name]
+ filePath = sourceURL;
+ } else {
if (!__IS_INTERNAL_VERSION__) {
if (!__IS_INTERNAL_VERSION__) {
- // In this case, we can't really determine the path to a file, disable a button
- return { url: null, shouldDisableButton: true };
- } else {
const endOfSourceMapURLPattern = '.js/';
const endOfSourceMapURLIndex = sourceURL.lastIndexOf(endOfSourceMapURLPattern);
const endOfSourceMapURLPattern = '.js/';
const endOfSourceMapURLIndex = sourceURL.lastIndexOf(endOfSourceMapURLPattern);
-
if (endOfSourceMapURLIndex === -1) {
if (endOfSourceMapURLIndex === -1) {
- return { url: null, shouldDisableButton: true };
+ return {
+ url: null,
+ shouldDisableButton: true,
+ };
} else {
filePath = sourceURL.slice(
endOfSourceMapURLIndex + endOfSourceMapURLPattern.length,
@@ -39 @@
sourceURL.length,
);
}
+ } else {
+ return {
+ url: null,
+ shouldDisableButton: true,
+ };
}
}
- } else {
- filePath = sourceURL;
}
const lineNumberAsString = String(line);
```
### Structural Swap If Else 003 (ReactDOMFloat.js) — 0/1
**All runs failed with same error:** File mismatch for ReactDOMFloat.js
**Sample agent response (run 1):**
```
Fix applied in `ReactDOMFloat.js`. Swapped the `if (encountered)` and `else` branches in the `preinitModule` dev warning logic.
```
**Diff (expected vs actual):**
```diff
@@ -257 @@
case 'script': {
break;
}
-
- // We have an invalid as type and need to warn
default: {
const typeOfAs = getValueDescriptorExpectingEnumForWarning(as);
console.error(
```
+2 -2
View File
@@ -158,13 +158,13 @@ export function generateReport(result: BenchmarkResult): string {
lines.push("");
if (summary.hashlineEditSubtypes) {
const total = Object.values(summary.hashlineEditSubtypes).reduce((a, b) => a + b, 0);
const order = ["set", "set_range", "insert"] as const;
const total = order.reduce((sum, key) => sum + (summary.hashlineEditSubtypes?.[key] ?? 0), 0);
if (total > 0) {
lines.push("### Hashline Edit Subtypes");
lines.push("");
lines.push("| Operation | Count | % |");
lines.push("|-----------|-------|---|");
const order = ["set_line", "replace_lines", "insert_after", "replace"];
for (const key of order) {
const count = summary.hashlineEditSubtypes[key] ?? 0;
const pct = formatPercent(count / total);
+19 -19
View File
@@ -57,7 +57,7 @@ function getEditPathFromArgs(args: unknown): string | null {
return typeof pathValue === "string" && pathValue.length > 0 ? pathValue : null;
}
const HASHLINE_SUBTYPES = ["set_line", "replace_lines", "insert_after", "replace"] as const;
const HASHLINE_SUBTYPES = ["set", "set_range", "insert"] as const;
function countHashlineEditSubtypes(args: unknown): Record<string, number> {
const counts: Record<string, number> = Object.fromEntries(HASHLINE_SUBTYPES.map(k => [k, 0]));
@@ -106,7 +106,7 @@ function buildMutationPreviewAgainstOriginal(original: string, current: string):
if (change.removed) {
for (const line of lines) {
const hash = computeLineHash(lineNum, line);
preview.push(`${lineNum}:${hash} -${line}`);
preview.push(`${lineNum}#${hash}|-${line}`);
lineNum += 1;
}
continue;
@@ -114,7 +114,7 @@ function buildMutationPreviewAgainstOriginal(original: string, current: string):
for (const line of lines) {
const hash = computeLineHash(lineNum, line);
preview.push(`${lineNum}:${hash} +${line}`);
preview.push(`${lineNum}#${hash}|+${line}`);
}
}
@@ -304,9 +304,9 @@ async function evaluateMutationIntent(
}
type GuidedHashlineEdit =
| { set_line: { anchor: string; new_text: string } }
| { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
| { insert_after: { anchor: string; text: string } };
| { set: { ref: string; body: string[] } }
| { set_range: { beg: string; end: string; body: string[] } }
| { insert: { after: string; body: string[] } };
function buildGuidedHashlineEdits(actual: string, expected: string): GuidedHashlineEdit[] {
const changes = diffLines(actual, expected);
@@ -328,38 +328,38 @@ function buildGuidedHashlineEdits(actual: string, expected: string): GuidedHashl
if (pendingAdded.length === 0) return;
if (insertLine === 1) {
const firstLine = actualLines[0] ?? "";
const firstRef = `1:${computeLineHash(1, firstLine)}`;
const firstRef = `1#${computeLineHash(1, firstLine)}`;
edits.push({
set_line: { anchor: firstRef, new_text: `${pendingAdded.join("\n")}\n${firstLine}` },
set: { ref: firstRef, body: [...pendingAdded, firstLine] },
});
} else if (insertLine <= actualLines.length) {
const afterLine = actualLines[insertLine - 2] ?? "";
const afterRef = `${insertLine - 1}:${computeLineHash(insertLine - 1, afterLine)}`;
const afterRef = `${insertLine - 1}#${computeLineHash(insertLine - 1, afterLine)}`;
edits.push({
insert_after: { anchor: afterRef, text: pendingAdded.join("\n") },
insert: { after: afterRef, body: [...pendingAdded] },
});
} else if (insertLine === actualLines.length + 1 && actualLines.length > 0) {
const afterLine = actualLines[actualLines.length - 1] ?? "";
const afterRef = `${actualLines.length}:${computeLineHash(actualLines.length, afterLine)}`;
const afterRef = `${actualLines.length}#${computeLineHash(actualLines.length, afterLine)}`;
edits.push({
insert_after: { anchor: afterRef, text: pendingAdded.join("\n") },
insert: { after: afterRef, body: [...pendingAdded] },
});
}
} else {
const startLine = pendingStart;
const endLine = pendingStart + pendingRemoved.length - 1;
const startContent = actualLines[startLine - 1] ?? "";
const startRef = `${startLine}:${computeLineHash(startLine, startContent)}`;
const startRef = `${startLine}#${computeLineHash(startLine, startContent)}`;
if (startLine === endLine) {
edits.push({ set_line: { anchor: startRef, new_text: pendingAdded.join("\n") } });
edits.push({ set: { ref: startRef, body: [...pendingAdded] } });
} else {
const endContent = actualLines[endLine - 1] ?? "";
const endRef = `${endLine}:${computeLineHash(endLine, endContent)}`;
const endRef = `${endLine}#${computeLineHash(endLine, endContent)}`;
edits.push({
replace_lines: {
start_anchor: startRef,
end_anchor: endRef,
new_text: pendingAdded.join("\n"),
set_range: {
beg: startRef,
end: endRef,
body: [...pendingAdded],
},
});
}
+10 -2
View File
@@ -148,8 +148,11 @@ export async function verifyExpectedFileSubset(
expectedNormalized,
actualNormalized,
);
const expectedFormatted = await formatContent(expectedPath, expectedNormalized);
const actualFormatted = await formatContent(actualPath, actualNormalizedWithPreservedWhitespace);
const expectedFormatted = await formatContent(expectedPath, normalizeBlankLines(expectedNormalized));
const actualFormatted = await formatContent(
actualPath,
normalizeBlankLines(actualNormalizedWithPreservedWhitespace),
);
const formattedEquivalent = expectedFormatted.formatted === actualFormatted.formatted;
// Indent score: distance between agent's raw output and formatted output
@@ -251,6 +254,11 @@ function normalizeLineEndings(value: string): string {
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
/** Collapse runs of 2+ blank lines into a single blank line. */
function normalizeBlankLines(text: string): string {
return text.replace(/\n{3,}/g, "\n\n");
}
function restoreWhitespaceOnlyLineDiffs(expected: string, actual: string): string {
const expectedLines = expected.split("\n");
const actualLines = actual.split("\n");