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:
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, " ");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" } }];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
```
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user