diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index e9ba703fe..b9e8c17c0 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -123,7 +123,7 @@ function normalizeMessagesForProvider( return changed ? normalized : messages; } -export const INTENT_FIELD = "_intent"; +export const INTENT_FIELD = "agent__intent"; function injectIntentIntoSchema(schema: unknown): unknown { if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema; diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index 421cd75dc..1e55ff6f5 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -116,7 +116,10 @@ describe("Tool argument coercion", () => { id: "call-7", name: "t7", arguments: - '{"path":"somefile.js","edits":"[{\\"target\\":\\"13#cf\\",\\"new_content\\":\\"...\\"}]"}' as unknown as Record, + '{"path":"somefile.js","edits":"[{\\"target\\":\\"13#cf\\",\\"new_content\\":\\"...\\"}]"}' as unknown as Record< + string, + unknown + >, }; const result = validateToolArguments(tool, toolCall); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a19fa8375..0e4cd7ed8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Display streamed tool intent in working message during agent execution @@ -13,6 +12,12 @@ ### Changed +- Hashline format separator changed from pipe (`|`) to colon (`:`) for improved readability (e.g., `LINE#ID:content` instead of `LINE#ID|content`) +- Hashline hash representation changed from 4-character base36 to 2-character hexadecimal for more compact line references +- Hashline edit API: renamed `delete` parameter to `rm` for consistency with standard file operations +- Hashline edit API: renamed `rename` parameter to `mv` for consistency with standard file operations +- Hashline edit API: content-replace operations now require explicit `op: "replaceText"` field to distinguish from other edit types +- Hashline documentation terminology updated: references to 'anchors' replaced with 'tags' for clearer semantics - Intent tracing now uses `_intent` field name in tool schemas - Hashline edit API: renamed `set` operation to `target`/`new_content` for clearer semantics - Hashline edit API: renamed `set_range` operation to `first`/`last`/`new_content` @@ -4834,4 +4839,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections +- HTML export with syntax highlighting and collapsible sections \ No newline at end of file diff --git a/packages/coding-agent/src/config/prompt-templates.ts b/packages/coding-agent/src/config/prompt-templates.ts index e179c2385..000c1fa95 100644 --- a/packages/coding-agent/src/config/prompt-templates.ts +++ b/packages/coding-agent/src/config/prompt-templates.ts @@ -247,11 +247,11 @@ handlebars.registerHelper("hlineref", (lineNum: unknown, content: unknown): stri /** * {{hlinefull lineNum "content"}} — format a full read-style line with prefix. - * Returns `"lineNum#hash|content"`. + * Returns `"lineNum#hash:content"`. */ handlebars.registerHelper("hlinefull", (lineNum: unknown, content: unknown): string => { const { ref, text } = formatHashlineRef(lineNum, content); - return `${ref}|${text}`; + return `${ref}:${text}`; }); export function renderPromptTemplate(template: string, context: TemplateContext = {}): string { diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 3da2f0946..8c1b00441 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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#ID|content)", + description: "Include line hashes in read output for hashline edit mode (LINE#ID:content)", }, }, showHardwareCursor: { diff --git a/packages/coding-agent/src/patch/diff.ts b/packages/coding-agent/src/patch/diff.ts index 13d99870a..3370d0f2d 100644 --- a/packages/coding-agent/src/patch/diff.ts +++ b/packages/coding-agent/src/patch/diff.ts @@ -8,8 +8,8 @@ import * as Diff from "diff"; import { resolveToCwd } from "../tools/path-utils"; import { previewPatch } from "./applicator"; import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy"; +import type { HashlineEdit } from "./hashline"; import { applyHashlineEdits } from "./hashline"; -import type { HashlineEdit } from "./index"; import { adjustIndentation, normalizeToLF, stripBom } from "./normalize"; import type { DiffError, DiffResult, PatchInput } from "./types"; import { EditMatchError } from "./types"; diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/patch/hashline.ts index abb8bf6c4..ea3d37b50 100644 --- a/packages/coding-agent/src/patch/hashline.ts +++ b/packages/coding-agent/src/patch/hashline.ts @@ -2,103 +2,27 @@ * Hashline edit mode — a line-addressable edit format using content hashes. * * 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). + * hexadecimal hash derived from the normalized line content (xxHash32, truncated to 2 + * hex chars). * 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#aa"`) */ -import type { HashlineEdit } from "./index"; import type { HashMismatch } from "./types"; -type ParsedRefs = - | { kind: "single"; ref: { line: number; hash: string } } - | { kind: "range"; start: { line: number; hash: string }; end: { line: number; hash: string } } - | { kind: "insertAfter"; after: { line: number; hash: string } } - | { kind: "insertBefore"; before: { line: number; hash: string } } - | { kind: "insertBetween"; after: { line: number; hash: string }; before: { line: number; hash: string } } - | { kind: "insertAtEof" }; - -function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dstLines: string[] } { - if ("set" in edit) { - return { - spec: { kind: "single", ref: parseLineRef(edit.set.ref) }, - dstLines: edit.set.body, - }; - } - if ("set_range" in edit) { - const r = edit.set_range as Record; - const start = parseLineRef(r.beg as string); - if (!r.end) { - return { - spec: { kind: "single", ref: start }, - dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")), - }; - } - const end = parseLineRef(r.end as string); - return { - spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end }, - dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")), - }; - } - if ("insert" in edit) { - const r = edit.insert as Record; - 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"); - } - 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#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 = /^\+(?!\+)/; +export type LineTag = { line: number; hash: string }; +export type HashlineEdit = + | { op: "set"; tag: LineTag; content: string[] } + | { op: "replace"; first: LineTag; last: LineTag; content: string[] } + | { op: "append"; after?: LineTag; content: string[] } + | { op: "prepend"; before?: LineTag; content: string[] } + | { op: "insert"; after: LineTag; before: LineTag; content: string[] }; +export type ReplaceTextEdit = { op: "replaceText"; old_text: string; new_text: string; all?: boolean }; +export type EditSpec = HashlineEdit | ReplaceTextEdit; /** * Compare two strings ignoring all whitespace differences. @@ -144,16 +68,6 @@ function restoreLeadingIndent(templateLine: string, line: string): string { return templateIndent + line; } -const CONFUSABLE_HYPHENS_RE = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g; - -function normalizeConfusableHyphens(s: string): string { - return s.replace(CONFUSABLE_HYPHENS_RE, "-"); -} - -function normalizeConfusableHyphensInLines(lines: string[]): string[] { - return lines.map(l => normalizeConfusableHyphens(l)); -} - function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]): string[] { if (oldLines.length !== newLines.length) return newLines; let changed = false; @@ -261,50 +175,19 @@ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: return out; } -/** - * Strip hashline display prefixes and diff `+` markers from replacement lines. - * - * 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. - */ -function stripNewLinePrefixes(lines: string[]): string[] { - // Detect whether the *majority* of non-empty lines carry a prefix — - // if only one line out of many has a match it's likely real content. - let hashPrefixCount = 0; - let diffPlusCount = 0; - let nonEmpty = 0; - for (const l of lines) { - if (l.length === 0) continue; - nonEmpty++; - if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++; - if (DIFF_PLUS_RE.test(l)) diffPlusCount++; - } - if (nonEmpty === 0) return lines; +const NIBBLE_STR = "ZPMQVRWSNKTXJBYH"; - const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5; - const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5; - - if (!stripHash && !stripPlus) return lines; - - return lines.map(l => { - if (stripHash) return l.replace(HASHLINE_PREFIX_RE, ""); - if (stripPlus) return l.replace(DIFF_PLUS_RE, ""); - return l; - }); -} - -const HASH_LEN = 2; -const RADIX = 16; -const HASH_MOD = RADIX ** HASH_LEN; - -const DICT = Array.from({ length: HASH_MOD }, (_, i) => i.toString(RADIX).padStart(HASH_LEN, "0")); +const DICT = Array.from({ length: 256 }, (_, i) => { + const h = i >>> 4; + const l = i & 0x0f; + return `${NIBBLE_STR[h]}${NIBBLE_STR[l]}`; +}); /** - * Compute a short base36 hash of a single line. + * Compute a short hexadecimal hash of a single line. * - * Uses xxHash64 on a whitespace-normalized line, truncated to {@link HASH_LEN} - * base36 characters. The `idx` parameter is accepted for compatibility with older + * Uses xxHash32 on a whitespace-normalized line, truncated to {@link HASH_LEN} + * hex characters. The `idx` parameter is accepted for compatibility with older * call sites, but is not currently mixed into the hash. * The line input should not include a trailing newline. */ @@ -314,13 +197,20 @@ export function computeLineHash(idx: number, line: string): string { } line = line.replace(/\s+/g, ""); void idx; // Might use line, but for now, let's not. - return DICT[Bun.hash.xxHash32(line) % HASH_MOD]; + return DICT[Bun.hash.xxHash32(line) & 0xff]; +} + +/** + * Formats a tag given the line number and content. + */ +export function formatLineTag(line: number, content: string): string { + return `${line}#${computeLineHash(line, content)}`; } /** * 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) @@ -329,7 +219,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 { @@ -337,8 +227,7 @@ export function formatHashLines(content: string, startLine = 1): string { return lines .map((line, i) => { const num = startLine + i; - const hash = computeLineHash(num, line); - return `${num}#${hash}|${line}`; + return `${formatLineTag(num, line)}:${line}`; }) .join("\n"); } @@ -410,7 +299,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[] = []; @@ -504,7 +393,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[] = []; @@ -560,18 +449,14 @@ export async function* streamHashLinesFromLines( * * @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" - // 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 match = strictMatch ?? prefixMatch; +export function parseTag(ref: string): { line: number; hash: string } { + // This regex captures: + // 1. optional leading ">+" and whitespace + // 2. line number (1+ digits) + // 3. "#" with optional surrounding spaces + // 4. hash (2 hex chars) + // 5. optional trailing display suffix (":..." or " ...") + const match = ref.match(/^\s*[>+-]*\s*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})/); if (!match) { throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`); } @@ -648,9 +533,9 @@ export class HashlineMismatchError extends Error { const prefix = `${lineNum}#${hash}`; if (mismatchSet.has(lineNum)) { - lines.push(`>>> ${prefix}|${content}`); + lines.push(`>>> ${prefix}:${content}`); } else { - lines.push(` ${prefix}|${content}`); + lines.push(` ${prefix}:${content}`); } } return lines.join("\n"); @@ -670,7 +555,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines: throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`); } const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]); - if (actualHash !== ref.hash.toLowerCase()) { + if (actualHash !== ref.hash) { throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines); } } @@ -683,7 +568,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines: * Apply an array of hashline edits to file content. * * Each edit operation identifies target lines directly (`set`, `set_range`, - * `insert`). Line references are resolved via {@link parseLineRef} + * `insert`). Line references are resolved via {@link parseTag} * and hashes validated before any mutation. * * Edits are sorted bottom-up (highest effective line first) so earlier @@ -711,36 +596,29 @@ export function applyHashlineEdits( 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(parsedEdit.dstLines), - }; - }); - function collectExplicitlyTouchedLines(): Set { const touched = new Set(); - for (const { spec } of parsed) { - switch (spec.kind) { - case "single": - touched.add(spec.ref.line); + for (const edit of edits) { + switch (edit.op) { + case "set": + touched.add(edit.tag.line); break; - case "range": - for (let ln = spec.start.line; ln <= spec.end.line; ln++) touched.add(ln); + case "replace": + for (let ln = edit.first.line; ln <= edit.last.line; ln++) touched.add(ln); break; - case "insertAfter": - touched.add(spec.after.line); + case "append": + if (edit.after) { + touched.add(edit.after.line); + } break; - case "insertBefore": - touched.add(spec.before.line); + case "prepend": + if (edit.before) { + touched.add(edit.before.line); + } break; - case "insertBetween": - touched.add(spec.after.line); - touched.add(spec.before.line); - break; - case "insertAtEof": + case "insert": + touched.add(edit.after.line); + touched.add(edit.before.line); break; } } @@ -755,59 +633,53 @@ export function applyHashlineEdits( throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`); } const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]); - if (actualHash === ref.hash.toLowerCase()) { + if (actualHash === ref.hash) { return true; } mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash }); return false; } - for (const { spec, dstLines } of parsed) { - switch (spec.kind) { - case "single": { - if (!validateRef(spec.ref)) continue; + for (const edit of edits) { + switch (edit.op) { + case "set": { + if (!validateRef(edit.tag)) continue; break; } - case "insertAfter": { - if (dstLines.length === 0) { + case "append": { + if (edit.content.length === 0) { throw new Error('Insert-after edit (src "N#HH..") requires non-empty dst'); } - if (!validateRef(spec.after)) continue; + if (edit.after && !validateRef(edit.after)) continue; break; } - case "insertBefore": { - if (dstLines.length === 0) { + case "prepend": { + if (edit.content.length === 0) { throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst'); } - if (!validateRef(spec.before)) continue; + if (edit.before && !validateRef(edit.before)) continue; break; } - case "insertBetween": { - if (dstLines.length === 0) { + case "insert": { + if (edit.content.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) { + if (edit.before.line !== edit.after.line + 1) { throw new Error( - `insert requires adjacent anchors (after ${spec.after.line}, before ${spec.before.line})`, + `insert requires adjacent anchors (after ${edit.after.line}, before ${edit.before.line})`, ); } - const afterValid = validateRef(spec.after); - const beforeValid = validateRef(spec.before); + const afterValid = validateRef(edit.after); + const beforeValid = validateRef(edit.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}`); + case "replace": { + if (edit.first.line > edit.last.line) { + throw new Error(`Range start line ${edit.first.line} must be <= end line ${edit.last.line}`); } - const startValid = validateRef(spec.start); - const endValid = validateRef(spec.end); + const startValid = validateRef(edit.first); + const endValid = validateRef(edit.last); if (!startValid || !endValid) continue; break; } @@ -819,30 +691,35 @@ export function applyHashlineEdits( // Deduplicate identical edits targeting the same line(s) const seenEditKeys = new Map(); const dedupIndices = new Set(); - for (let i = 0; i < parsed.length; i++) { - const p = parsed[i]; + for (let i = 0; i < edits.length; i++) { + const edit = edits[i]; let lineKey: string; - switch (p.spec.kind) { - case "single": - lineKey = `s:${p.spec.ref.line}`; + switch (edit.op) { + case "set": + lineKey = `s:${edit.tag.line}`; break; - case "range": - lineKey = `r:${p.spec.start.line}:${p.spec.end.line}`; + case "replace": + lineKey = `r:${edit.first.line}:${edit.last.line}`; break; - 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": + case "append": + if (edit.after) { + lineKey = `i:${edit.after.line}`; + break; + } lineKey = "ieof"; break; + case "prepend": + if (edit.before) { + lineKey = `ib:${edit.before.line}`; + break; + } + lineKey = "ibef"; + break; + case "insert": + lineKey = `ix:${edit.after.line}:${edit.before.line}`; + break; } - const dstKey = `${lineKey}|${p.dstLines.join("\n")}`; + const dstKey = `${lineKey}:${edit.content.join("\n")}`; if (seenEditKeys.has(dstKey)) { dedupIndices.add(i); } else { @@ -850,51 +727,47 @@ export function applyHashlineEdits( } } if (dedupIndices.size > 0) { - for (let i = parsed.length - 1; i >= 0; i--) { - if (dedupIndices.has(i)) parsed.splice(i, 1); + for (let i = edits.length - 1; i >= 0; i--) { + if (dedupIndices.has(i)) edits.splice(i, 1); } } // Compute sort key (descending) — bottom-up application - const annotated = parsed.map((p, idx) => { + const annotated = edits.map((edit, idx) => { let sortLine: number; let precedence: number; - switch (p.spec.kind) { - case "single": - sortLine = p.spec.ref.line; + switch (edit.op) { + case "set": + sortLine = edit.tag.line; precedence = 0; break; - case "range": - sortLine = p.spec.end.line; + case "replace": + sortLine = edit.last.line; precedence = 0; break; - case "insertAfter": - sortLine = p.spec.after.line; + case "append": + sortLine = edit.after ? edit.after.line : fileLines.length + 1; precedence = 1; break; - case "insertBefore": - sortLine = p.spec.before.line; + case "prepend": + sortLine = edit.before ? edit.before.line : 0; precedence = 2; break; - case "insertBetween": - sortLine = p.spec.before.line; + case "insert": + sortLine = edit.before.line; precedence = 3; break; - case "insertAtEof": - sortLine = fileLines.length + 1; - precedence = 4; - break; } - return { ...p, idx, sortLine, precedence }; + return { edit, idx, sortLine, precedence }; }); annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx); // Apply edits bottom-up - for (const { spec, dstLines, idx } of annotated) { - switch (spec.kind) { - case "single": { - const merged = autocorrect ? maybeExpandSingleLineMerge(spec.ref.line, dstLines) : null; + for (const { edit, idx } of annotated) { + switch (edit.op) { + case "set": { + const merged = autocorrect ? maybeExpandSingleLineMerge(edit.tag.line, edit.content) : null; if (merged) { const origLines = originalFileLines.slice( merged.startLine - 1, @@ -902,16 +775,11 @@ export function applyHashlineEdits( ); let nextLines = merged.newLines; nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines); - if ( - origLines.join("\n") === nextLines.join("\n") && - origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l)) - ) { - nextLines = normalizeConfusableHyphensInLines(nextLines); - } - if (origLines.join("\n") === nextLines.join("\n")) { + + if (origLines.every((line, i) => line === nextLines[i])) { noopEdits.push({ editIndex: idx, - loc: `${spec.ref.line}#${spec.ref.hash}`, + loc: `${edit.tag.line}#${edit.tag.hash}`, currentContent: origLines.join("\n"), }); break; @@ -922,112 +790,113 @@ export function applyHashlineEdits( } const count = 1; - const origLines = originalFileLines.slice(spec.ref.line - 1, spec.ref.line); + const origLines = originalFileLines.slice(edit.tag.line - 1, edit.tag.line); let stripped = autocorrect - ? stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines) - : dstLines; + ? stripRangeBoundaryEcho(originalFileLines, edit.tag.line, edit.tag.line, edit.content) + : edit.content; 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")) { + const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped; + if (origLines.every((line, i) => line === newLines[i])) { noopEdits.push({ editIndex: idx, - loc: `${spec.ref.line}#${spec.ref.hash}`, + loc: `${edit.tag.line}#${edit.tag.hash}`, currentContent: origLines.join("\n"), }); break; } - fileLines.splice(spec.ref.line - 1, count, ...newLines); - trackFirstChanged(spec.ref.line); + fileLines.splice(edit.tag.line - 1, count, ...newLines); + trackFirstChanged(edit.tag.line); break; } - case "range": { - const count = spec.end.line - spec.start.line + 1; - const origLines = originalFileLines.slice(spec.start.line - 1, spec.start.line - 1 + count); + case "replace": { + const count = edit.last.line - edit.first.line + 1; + const origLines = originalFileLines.slice(edit.first.line - 1, edit.first.line - 1 + count); let stripped = autocorrect - ? stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines) - : dstLines; + ? stripRangeBoundaryEcho(originalFileLines, edit.first.line, edit.last.line, edit.content) + : edit.content; 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")) { + const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped; + if (autocorrect && origLines.every((line, i) => line === newLines[i])) { noopEdits.push({ editIndex: idx, - loc: `${spec.start.line}#${spec.start.hash}`, + loc: `${edit.first.line}#${edit.first.hash}`, currentContent: origLines.join("\n"), }); break; } - fileLines.splice(spec.start.line - 1, count, ...newLines); - trackFirstChanged(spec.start.line); + fileLines.splice(edit.first.line - 1, count, ...newLines); + trackFirstChanged(edit.first.line); break; } - case "insertAfter": { - const anchorLine = originalFileLines[spec.after.line - 1]; - const inserted = autocorrect ? stripInsertAnchorEchoAfter(anchorLine, dstLines) : dstLines; + case "append": { + const inserted = edit.after + ? autocorrect + ? stripInsertAnchorEchoAfter(originalFileLines[edit.after.line - 1], edit.content) + : edit.content + : edit.content; if (inserted.length === 0) { noopEdits.push({ editIndex: idx, - loc: `${spec.after.line}#${spec.after.hash}`, - currentContent: originalFileLines[spec.after.line - 1], + loc: edit.after ? `${edit.after.line}#${edit.after.hash}` : "EOF", + currentContent: edit.after ? originalFileLines[edit.after.line - 1] : "", }); break; } - fileLines.splice(spec.after.line, 0, ...inserted); - trackFirstChanged(spec.after.line + 1); + if (edit.after) { + fileLines.splice(edit.after.line, 0, ...inserted); + trackFirstChanged(edit.after.line + 1); + } else { + if (fileLines.length === 1 && fileLines[0] === "") { + fileLines.splice(0, 1, ...inserted); + trackFirstChanged(1); + } else { + fileLines.splice(fileLines.length, 0, ...inserted); + trackFirstChanged(fileLines.length - inserted.length + 1); + } + } break; } - case "insertBefore": { - const anchorLine = originalFileLines[spec.before.line - 1]; - const inserted = autocorrect ? stripInsertAnchorEchoBefore(anchorLine, dstLines) : dstLines; + case "prepend": { + const inserted = edit.before + ? autocorrect + ? stripInsertAnchorEchoBefore(originalFileLines[edit.before.line - 1], edit.content) + : edit.content + : edit.content; if (inserted.length === 0) { noopEdits.push({ editIndex: idx, - loc: `${spec.before.line}#${spec.before.hash}`, - currentContent: originalFileLines[spec.before.line - 1], + loc: edit.before ? `${edit.before.line}#${edit.before.hash}` : "BOF", + currentContent: edit.before ? originalFileLines[edit.before.line - 1] : "", }); break; } - fileLines.splice(spec.before.line - 1, 0, ...inserted); - trackFirstChanged(spec.before.line); + if (edit.before) { + fileLines.splice(edit.before.line - 1, 0, ...inserted); + trackFirstChanged(edit.before.line); + } else { + if (fileLines.length === 1 && fileLines[0] === "") { + fileLines.splice(0, 1, ...inserted); + } else { + fileLines.splice(0, 0, ...inserted); + } + trackFirstChanged(1); + } 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; + case "insert": { + const afterLine = originalFileLines[edit.after.line - 1]; + const beforeLine = originalFileLines[edit.before.line - 1]; + const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, edit.content) : edit.content; if (inserted.length === 0) { noopEdits.push({ editIndex: idx, - loc: `${spec.after.line}#${spec.after.hash}..${spec.before.line}#${spec.before.hash}`, + loc: `${edit.after.line}#${edit.after.hash}..${edit.before.line}#${edit.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); + fileLines.splice(edit.before.line - 1, 0, ...inserted); + trackFirstChanged(edit.before.line); break; } } @@ -1047,12 +916,12 @@ export function applyHashlineEdits( function maybeExpandSingleLineMerge( line: number, - dst: string[], + content: string[], ): { startLine: number; deleteCount: number; newLines: string[] } | null { - if (dst.length !== 1) return null; + if (content.length !== 1) return null; if (line < 1 || line > fileLines.length) return null; - const newLine = dst[0]; + const newLine = content[0]; const newCanon = stripAllWhitespace(newLine); const newCanonForMergeOps = stripMergeOperatorChars(newCanon); if (newCanon.length === 0) return null; diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 132e21548..d2738e844 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -34,7 +34,14 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard" import { applyPatch } from "./applicator"; import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff"; import { findMatch } from "./fuzzy"; -import { applyHashlineEdits, computeLineHash, parseLineRef } from "./hashline"; +import { + applyHashlineEdits, + computeLineHash, + type HashlineEdit, + type LineTag, + parseTag, + type ReplaceTextEdit, +} from "./hashline"; import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; import { buildNormativeUpdateInput } from "./normative"; import { type EditToolDetails, getLspBatchRequest } from "./shared"; @@ -66,7 +73,7 @@ export { computeLineHash, formatHashLines, HashlineMismatchError, - parseLineRef, + parseTag, streamHashLinesFromLines, streamHashLinesFromUtf8, validateLineRef, @@ -128,26 +135,68 @@ const patchEditSchema = Type.Object({ export type ReplaceParams = Static; export type PatchParams = Static; -type HashlineEditUnion = Static; -type HashlineReplaceEdit = { replace: { old_text: string; new_text: string; all?: boolean } }; -export type HashlineEdit = - | { set: { ref: string; body: string[] } } - | { set_range: { beg: string; end: string; body: string[] } } - | { insert: { before?: string; after?: string; body: string[] } } - | HashlineReplaceEdit; +/** Pattern matching hashline display format: `LINE#ID:CONTENT` */ +const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}:/; -const hashlineContentFormat = (kind: string) => +/** Pattern matching a unified-diff `+` prefix (but not `++`) */ +const DIFF_PLUS_RE = /^[+-](?![+-])/; + +/** + * Strip hashline display prefixes and diff `+` markers from replacement lines. + * + * 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. + */ +function stripNewLinePrefixes(lines: string[]): string[] { + // Detect whether the *majority* of non-empty lines carry a prefix — + // if only one line out of many has a match it's likely real content. + let hashPrefixCount = 0; + let diffPlusCount = 0; + let nonEmpty = 0; + for (const l of lines) { + if (l.length === 0) continue; + nonEmpty++; + if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++; + if (DIFF_PLUS_RE.test(l)) diffPlusCount++; + } + if (nonEmpty === 0) return lines; + + const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5; + const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5; + + if (!stripHash && !stripPlus) return lines; + + return lines.map(l => { + if (stripHash) return l.replace(HASHLINE_PREFIX_RE, ""); + if (stripPlus) return l.replace(DIFF_PLUS_RE, ""); + return l; + }); +} + +const hashlineReplaceContentFormat = (kind: string) => Type.Union([ Type.Null(), Type.Array(Type.String(), { description: `${kind} lines` }), Type.String({ description: `${kind} line` }), ]); +const hashlineInsertContentFormat = (kind: string) => + Type.Union([ + Type.Array(Type.String(), { description: `${kind} lines`, minItems: 1 }), + Type.String({ description: `${kind} line`, minLength: 1 }), + ]); + +const hashlineTagFormat = (what: string) => + Type.String({ + description: `Tag identifying the ${what} in "LINE#ID" format`, + }); + function hashlineParseContent(edit: string | string[] | null): string[] { if (edit === null) return []; if (Array.isArray(edit)) return edit; - const lines = edit.split("\n"); + const lines = stripNewLinePrefixes(edit.split("\n")); if (lines.length === 0) return []; if (lines[lines.length - 1].trim() === "") return lines.slice(0, -1); return lines; @@ -159,113 +208,89 @@ function hashlineParseContentString(edit: string | string[] | null): string { return edit; } -const hashlineTargetEditSchema = Type.Object({ - target: Type.String({ description: 'Line reference "LINE#ID"' }), - new_content: hashlineContentFormat("Replacement"), -}); +const hashlineTargetEditSchema = Type.Object( + { + op: Type.Literal("set"), + tag: hashlineTagFormat("line being replaced"), + content: hashlineReplaceContentFormat("Replacement"), + }, + { additionalProperties: false }, +); -function hashlineParseTargetEdit(edit: HashlineEditUnion): HashlineEdit | null { - if ("target" in edit) { - return { - set: { - ref: edit.target, - body: hashlineParseContent(edit.new_content), - }, - }; - } - return null; -} +const hashlineAppendEditSchema = Type.Object( + { + op: Type.Literal("append"), + after: Type.Optional(hashlineTagFormat("line after which to append")), + content: hashlineInsertContentFormat("Appended"), + }, + { additionalProperties: false }, +); -const hashlineRangeEditSchema = Type.Object({ - first: Type.String({ description: 'Start line ref "LINE#ID"' }), - last: Type.String({ description: 'End line ref "LINE#ID"' }), - new_content: hashlineContentFormat("Replacement"), -}); +const hashlinePrependEditSchema = Type.Object( + { + op: Type.Literal("prepend"), + before: Type.Optional(hashlineTagFormat("line before which to prepend")), + content: hashlineInsertContentFormat("Prepended"), + }, + { additionalProperties: false }, +); -function hashlineParseRangeEdit(edit: HashlineEditUnion): HashlineEdit | null { - if ("first" in edit && "last" in edit) { - return { - set_range: { - beg: edit.first, - end: edit.last, - body: hashlineParseContent(edit.new_content), - }, - }; - } - return null; -} +const hashlineRangeEditSchema = Type.Object( + { + op: Type.Literal("replace"), + first: hashlineTagFormat("first line"), + last: hashlineTagFormat("last line"), + content: hashlineReplaceContentFormat("Replacement"), + }, + { additionalProperties: false }, +); -const hashlineInsertEditSchema = Type.Object({ - before: Type.Optional(Type.String({ description: 'Insert before this line "LINE#ID"' })), - after: Type.Optional(Type.String({ description: 'Insert after this line "LINE#ID"' })), - inserted_lines: hashlineContentFormat("Inserted"), -}); +const hashlineInsertEditSchema = Type.Object( + { + op: Type.Literal("insert"), + before: Type.Optional(hashlineTagFormat("line before which to insert")), + after: Type.Optional(hashlineTagFormat("line after which to insert")), + content: hashlineInsertContentFormat("Inserted"), + }, + { additionalProperties: false }, +); -function hashlineParseInsertEdit(edit: HashlineEditUnion): HashlineEdit | null { - if ("inserted_lines" in edit) { - const { before = "", after = "", inserted_lines } = edit; - return { - insert: { - before: before.length > 0 ? before : undefined, - after: after.length > 0 ? after : undefined, - body: hashlineParseContent(inserted_lines), - }, - }; - } - return null; -} +const hashlineReplaceTextEditSchema = Type.Object( + { + op: Type.Literal("replaceText"), + old_text: Type.String({ description: "Text to find", minLength: 1 }), + new_text: hashlineReplaceContentFormat("Replacement"), + all: Type.Optional(Type.Boolean({ description: "Replace all occurrences" })), + }, + { additionalProperties: false }, +); -const hashlineReplaceTextEditSchema = Type.Object({ - old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }), - new_text: hashlineContentFormat("Replacement"), - all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })), -}); +const HL_REPLACE_ENABLED = Bun.env.PI_HL_REPLACETXT === "1"; -function hashlineParseReplaceTextEdit(edit: HashlineEditUnion): HashlineReplaceEdit | null { - if ("old_text" in edit && "new_text" in edit) { - const { old_text, new_text, all } = edit; - return { - replace: { - old_text: old_text, - new_text: hashlineParseContentString(new_text), - all: all, - }, - }; - } - return null; -} - -const hashlineEditItemSchema = Type.Union([ +const hashlineEditSpecSchema = Type.Union([ hashlineTargetEditSchema, hashlineRangeEditSchema, + hashlineAppendEditSchema, + hashlinePrependEditSchema, hashlineInsertEditSchema, -]); -const hashlineEditItemSchemaWithReplace = Type.Union([ - hashlineTargetEditSchema, - hashlineRangeEditSchema, - hashlineInsertEditSchema, - hashlineReplaceTextEditSchema, + ...(HL_REPLACE_ENABLED ? [hashlineReplaceTextEditSchema] : []), ]); -const hashlineEditSchema = Type.Object({ - path: Type.String({ description: "File path (relative or absolute)" }), - edits: Type.Array(hashlineEditItemSchema, { description: "Array of edit operations" }), - delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })), - rename: Type.Optional(Type.String({ description: "New path for move" })), -}); -const hashlineEditSchemaWithReplace = Type.Object({ - path: Type.String({ description: "File path (relative or absolute)" }), - edits: Type.Array(hashlineEditItemSchemaWithReplace, { description: "Array of edit operations" }), - delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })), - rename: Type.Optional(Type.String({ description: "New path for move" })), -}); +const hashlineEditSchema = Type.Object( + { + path: Type.String({ description: "File path (relative or absolute)" }), + edits: Type.Array(hashlineEditSpecSchema, { + description: "Changes to apply to the file at `path`", + minItems: 0, + }), + delete: Type.Optional(Type.Boolean({ description: "Delete the file when true" })), + rename: Type.Optional(Type.String({ description: "New path if moving" })), + }, + { additionalProperties: false }, +); -export type HashlineToolEdit = Static; -export type HashlineParams = Static; - -function isHashlineReplaceTextEnabled(): boolean { - return Bun.env.PI_HL_REPLACETXT === "1"; -} +export type HashlineToolEdit = Static; +export type HashlineParams = Static; // ═══════════════════════════════════════════════════════════════════════════ // LSP FileSystem for patch mode @@ -355,11 +380,7 @@ function mergeDiagnosticsWithWarnings( // Tool Class // ═══════════════════════════════════════════════════════════════════════════ -type TInput = - | typeof replaceEditSchema - | typeof patchEditSchema - | typeof hashlineEditSchema - | typeof hashlineEditSchemaWithReplace; +type TInput = typeof replaceEditSchema | typeof patchEditSchema | typeof hashlineEditSchema; export type EditMode = "replace" | "patch" | "hashline"; @@ -465,7 +486,7 @@ export class EditTool implements AgentTool { case "patch": return renderPromptTemplate(patchDescription); case "hashline": - return renderPromptTemplate(hashlineDescription, { allowReplaceText: isHashlineReplaceTextEnabled() }); + return renderPromptTemplate(hashlineDescription, { allowReplaceText: HL_REPLACE_ENABLED }); default: return renderPromptTemplate(replaceDescription); } @@ -479,7 +500,7 @@ export class EditTool implements AgentTool { case "patch": return patchEditSchema; case "hashline": - return isHashlineReplaceTextEnabled() ? hashlineEditSchemaWithReplace : hashlineEditSchema; + return hashlineEditSchema; default: return replaceEditSchema; } @@ -499,9 +520,8 @@ export class EditTool implements AgentTool { // ───────────────────────────────────────────────────────────────── if (this.mode === "hashline") { const { path, edits, delete: deleteFile, rename } = params as HashlineParams; - const op: Operation = deleteFile === true ? "delete" : "update"; - enforcePlanModeWrite(this.session, path, { op, rename }); + enforcePlanModeWrite(this.session, path, { op: deleteFile ? "delete" : "update", rename }); if (path.endsWith(".ipynb") && edits?.length > 0) { throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); @@ -511,7 +531,7 @@ export class EditTool implements AgentTool { const resolvedRename = rename ? resolvePlanPath(this.session, rename) : undefined; const file = Bun.file(absolutePath); - if (deleteFile === true) { + if (deleteFile) { if (await file.exists()) { await file.unlink(); } @@ -527,30 +547,115 @@ export class EditTool implements AgentTool { } if (!(await file.exists())) { - throw new Error(`File not found: ${path}`); - } - - const allowReplaceText = isHashlineReplaceTextEnabled(); - const anchorEdits: HashlineEdit[] = []; - const replaceEdits: HashlineReplaceEdit[] = []; - for (let i = 0; i < edits.length; i++) { - const edit = edits[i] as HashlineEditUnion; - - const anchorEdit = - hashlineParseTargetEdit(edit) ?? hashlineParseRangeEdit(edit) ?? hashlineParseInsertEdit(edit); - if (!anchorEdit) { - if (allowReplaceText) { - const replaceEdit = hashlineParseReplaceTextEdit(edit); - if (replaceEdit) { - replaceEdits.push(replaceEdit); - continue; + const content: string[] = []; + for (const edit of edits) { + switch (edit.op) { + case "append": { + if (edit.after) { + throw new Error(`File not found: ${path}`); + } + content.push(...hashlineParseContent(edit.content)); + break; + } + case "prepend": { + if (edit.before) { + throw new Error(`File not found: ${path}`); + } + content.unshift(...hashlineParseContent(edit.content)); + break; + } + default: { + throw new Error(`File not found: ${path}`); } } - throw new Error( - `edits[${i}] must match exactly one variant: {target,new_content} | {first,last,new_content} | {before/after,inserted_lines}${allowReplaceText ? " | {old_text,new_text}" : ""}. Got keys: [${Object.keys(edit).join(", ")}].`, - ); } - anchorEdits.push(anchorEdit); + await file.write(content.join("\n")); + return { + content: [{ type: "text", text: `Created ${path}` }], + details: { + diff: "", + op: "create", + meta: outputMeta().get(), + }, + }; + } + + const anchorEdits: HashlineEdit[] = []; + const replaceEdits: ReplaceTextEdit[] = []; + for (const edit of edits) { + switch (edit.op) { + case "set": { + const { tag, content } = edit; + anchorEdits.push({ op: "set", tag: parseTag(tag), content: hashlineParseContent(content) }); + break; + } + case "replace": { + const { first, last, content } = edit; + anchorEdits.push({ + op: "replace", + first: parseTag(first), + last: parseTag(last), + content: hashlineParseContent(content), + }); + break; + } + case "append": { + const { after, content } = edit; + anchorEdits.push({ + op: "append", + ...(after ? { after: parseTag(after) } : {}), + content: hashlineParseContent(content), + }); + break; + } + case "prepend": { + const { before, content } = edit; + anchorEdits.push({ + op: "prepend", + ...(before ? { before: parseTag(before) } : {}), + content: hashlineParseContent(content), + }); + break; + } + case "insert": { + const { before, after, content } = edit; + if (before && !after) { + anchorEdits.push({ + op: "prepend", + before: parseTag(before), + content: hashlineParseContent(content), + }); + } else if (after && !before) { + anchorEdits.push({ + op: "append", + after: parseTag(after), + content: hashlineParseContent(content), + }); + } else if (before && after) { + anchorEdits.push({ + op: "insert", + before: parseTag(before), + after: parseTag(after), + content: hashlineParseContent(content), + }); + } else { + throw new Error(`Insert must have both before and after tags.`); + } + break; + } + case "replaceText": { + const { old_text, new_text, all } = edit; + replaceEdits.push({ + op: "replaceText", + old_text: old_text, + new_text: hashlineParseContentString(new_text), + all: all ?? false, + }); + break; + } + default: + throw new Error(`Invalid edit operation: ${JSON.stringify(edit)}`); + } } const rawContent = await file.text(); @@ -565,12 +670,12 @@ export class EditTool implements AgentTool { // Apply content-replace edits (substr-style fuzzy replace) for (const r of replaceEdits) { - if (r.replace.old_text.length === 0) { - throw new Error("replace.old_text must not be empty."); + if (r.old_text.length === 0) { + throw new Error("old_text must not be empty."); } - const rep = replaceText(normalizedContent, r.replace.old_text, r.replace.new_text, { + const rep = replaceText(normalizedContent, r.old_text, r.new_text, { fuzzy: this.#allowFuzzy, - all: r.replace.all ?? false, + all: r.all ?? false, threshold: this.#fuzzyThreshold, }); normalizedContent = rep.content; @@ -598,21 +703,35 @@ export class EditTool implements AgentTool { // Edits were not literally identical but heuristics normalized them back const lines = result.content.split("\n"); const targetLines: string[] = []; + const refs: LineTag[] = []; for (const edit of anchorEdits) { - const refs: string[] = []; - 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); + refs.length = 0; + switch (edit.op) { + case "set": + refs.push(edit.tag); + break; + case "replace": + refs.push(edit.first, edit.last); + break; + case "append": + if (edit.after) refs.push(edit.after); + break; + case "prepend": + if (edit.before) refs.push(edit.before); + break; + case "insert": + refs.push(edit.after, edit.before); + break; + default: + break; } + 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}`); + if (ref.line >= 1 && ref.line <= lines.length) { + const lineContent = lines[ref.line - 1]; + const hash = computeLineHash(ref.line, lineContent); + targetLines.push(`${ref.line}#${hash}:${lineContent}`); } } catch { /* skip malformed refs */ @@ -667,7 +786,7 @@ export class EditTool implements AgentTool { diff: diffResult.diff, firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine, diagnostics, - op, + op: "update", rename, meta, }, @@ -714,17 +833,10 @@ export class EditTool implements AgentTool { // Generate diff for display let diffResult = { diff: "", firstChangedLine: undefined as number | undefined }; - let normative: PatchInput | undefined; if (result.change.type === "update" && result.change.oldContent && result.change.newContent) { const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text); const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text); diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew); - normative = buildNormativeUpdateInput({ - path, - rename: effRename, - oldContent: result.change.oldContent, - newContent: result.change.newContent, - }); } let resultText: string; @@ -762,7 +874,6 @@ export class EditTool implements AgentTool { rename: effRename, meta, }, - $normative: normative, }; } diff --git a/packages/coding-agent/src/prompts/tools/grep.md b/packages/coding-agent/src/prompts/tools/grep.md index 98fddf1ff..a4526fb7a 100644 --- a/packages/coding-agent/src/prompts/tools/grep.md +++ b/packages/coding-agent/src/prompts/tools/grep.md @@ -13,7 +13,7 @@ Powerful search tool built on ripgrep. - Results are always content mode. {{#if IS_HASHLINE_MODE}} -- Text output is CID prefixed: `LINE#ID|content` +- Text output is CID prefixed: `LINE#ID:content` {{else}} {{#if IS_LINE_NUMBER_MODE}} - Text output is line-number-prefixed diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index cec6f5e84..332249877 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -1,12 +1,11 @@ # Edit -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`. +Apply precise file edits using `LINE#ID` tags, anchoring to the file content. -1. `read` the target range to capture current `LINE#ID` anchors. +1. `read` the target range to capture current `LINE#ID` tags. 2. Pick the smallest operation per change site (line/range/insert/content-replace). -3. Direction-lock every edit: exact current text -> intended text. +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. @@ -14,23 +13,22 @@ Apply precise file edits using `LINE#ID` anchors from `read` output. - **Single line replace/delete** - - `{ target: "LINE#ID", new_content: ["..."] }` - - `new_content: null` deletes the line; `new_content: [""]` keeps a blank line. + - `{ op: "set", tag: "N#ID", content: […] }` + - `content: null` deletes the line; `content: [""]` keeps a blank line. - **Range replace/delete** - - `{ first: "LINE#ID", last: "LINE#ID", new_content: ["..."] }` - - Use for swaps, block rewrites, or deleting a full span (`new_content: null`). + - `{ op: "replace", first: "N#ID", last: "N#ID", content: […] }` + - Use for swaps, block rewrites, or deleting a full span (`content: null`). - **Insert** (new content) - - `{ before: "LINE#ID", inserted_lines: ["..."] }` - - `{ after: "LINE#ID", inserted_lines: ["..."] }` - - `{ after: "LINE#ID", before: "LINE#ID", inserted_lines: ["..."] }` (between adjacent anchors; safest for blocks) - - `inserted_lines` must be non-empty. + - `{ op: "prepend", before: "N#ID", content: […] }` or `{ op: "prepend", content: […] }` (no `before` = insert at beginning of file) + - `{ op: "append", after: "N#ID", content: […] }` or `{ op: "append", content: […] }` (no `after` = insert at end of file) + - `{ op: "insert", after: "N#ID", before: "N#ID", content: […] }` (between adjacent anchors; safest for blocks) {{#if allowReplaceText}} -- **Content replace** (fallback when anchors unavailable) - - `{ old_text: "...", new_text: "...", all?: boolean }` +- **Content replace** + - `{ op: "replaceText", old_text: "…", new_text: "…", all?: boolean }` {{/if}} - **File-level controls** - `{ delete: true, edits: [] }` deletes the file (cannot be combined with `rename`). - - `{ rename: "new/path.ts", edits: [...] }` writes result to new path and removes old path. + - `{ rename: "new/path.ts", edits: […] }` writes result to new path and removes old path. **Atomicity:** all ops validate against the same pre-edit file snapshot; refs are interpreted against last `read`; applicator applies bottom-up. @@ -44,61 +42,61 @@ Apply precise file edits using `LINE#ID` anchors from `read` output. 7. **For swaps/moves:** prefer one range operation over multiple single-line operations. - -- One wrong line -> `{ target, new_content }` -- Adjacent block changed -> `{ first, last, new_content }` -- Missing line/block -> insert with `before`/`after` + `inserted_lines` - + +- One wrong line → `set` +- Adjacent block changed → `insert` +- Missing line/block → insert with `append`/`prepend` + - -- Copy anchor IDs exactly from `read` or error output. -- Never handcraft hashes. -- For inserts, prefer `after+before` dual anchors when both boundaries are known. + +- Copy tags exactly from the prefix of the `read` or error output. +- Never guess tags. +- For inserts, prefer `insert` > `append`/`prepend` when both boundaries are known. - Re-read after each successful edit call before issuing another on same file. - + -**Hash mismatch (`>>>`)** -- Retry with the updated anchors shown in error output. -- Re-read only if required anchors are missing from error snippet. +**Tag mismatch (`>>>`)** +- Retry with the updated tags shown in error output. +- Re-read only if required tags 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. - + ```ts {{hlinefull 23 " const timeout: number = 5000;"}} ``` ``` -target: "{{hlineref 23 " const timeout: number = 5000;"}}" -new_content: [" const timeout: number = 30_000;"] +op: "set" +tag: "{{hlineref 23 " const timeout: number = 5000;"}}" +content: [" const timeout: number = 30_000;"] ``` - + ```ts {{hlinefull 7 "// @ts-ignore"}} {{hlinefull 8 "const data = fetchSync(url);"}} ``` ``` -target: "{{hlineref 7 "// @ts-ignore"}}" -new_content: null +op: "set" +tag: "{{hlineref 7 "// @ts-ignore"}}" +content: null ``` - + ```ts {{hlinefull 14 " placeholder: \"DO NOT SHIP\","}} ``` ``` -target: "{{hlineref 14 " placeholder: \"DO NOT SHIP\","}}" -new_content: [""] +op: "set" +tag: "{{hlineref 14 " placeholder: \"DO NOT SHIP\","}}" +content: [""] ``` - + ```ts {{hlinefull 60 " } catch (err) {"}} {{hlinefull 61 " console.error(err);"}} @@ -106,13 +104,14 @@ new_content: [""] {{hlinefull 63 " }"}} ``` ``` +op: "replace" first: "{{hlineref 60 " } catch (err) {"}}" last: "{{hlineref 63 " }"}}" -new_content: [" } catch (err) {", " if (isEnoent(err)) return null;", " throw err;", " }"] +content: [" } catch (err) {", " if (isEnoent(err)) return null;", " throw err;", " }"] ``` - + ```ts {{hlinefull 80 " // TODO: remove after migration"}} {{hlinefull 81 " if (legacy) {"}} @@ -120,49 +119,53 @@ new_content: [" } catch (err) {", " if (isEnoent(err)) return null;", " {{hlinefull 83 " }"}} ``` ``` +op: "replace" first: "{{hlineref 80 " // TODO: remove after migration"}}" last: "{{hlineref 83 " }"}}" -new_content: null +content: null ``` - + ```ts {{hlinefull 1 "import * as fs from \"node:fs/promises\";"}} {{hlinefull 2 "import * as path from \"node:path\";"}} ``` ``` +op: "prepend" before: "{{hlineref 1 "import * as fs from \"node:fs/promises\";"}}" -inserted_lines: ["import * as os from \"node:os\";"] +content: ["import * as os from \"node:os\";"] ``` -Use `before` when prepending at the top of a block or file — there is no meaningful anchor above. +Use `before` for anchored insertion before a specific line. Omit `before` to prepend at BOF. - + ```ts {{hlinefull 260 "export { serialize, deserialize };"}} ``` ``` +op: "append" after: "{{hlineref 260 "export { serialize, deserialize };"}}" -inserted_lines: ["export { validate };"] +content: ["export { validate };"] ``` -Use `after` when appending at the bottom — there is no anchor below. +Use `after` for anchored insertion after a specific line. Omit `after` to append at EOF. - + ```ts {{hlinefull 44 " \"build\": \"bun run compile\","}} {{hlinefull 45 " \"test\": \"bun test\""}} ``` ``` +op: "insert" after: "{{hlineref 44 " \"build\": \"bun run compile\","}}" before: "{{hlineref 45 " \"test\": \"bun test\""}}" -inserted_lines: [" \"lint\": \"biome check\","] +content: [" \"lint\": \"biome check\","] ``` Dual anchors pin the insert to exactly one gap, preventing drift from edits elsewhere in the file. **Always prefer dual anchors when both boundaries are content lines.** - + ```ts {{hlinefull 100 " return buf.toString(\"hex\");"}} {{hlinefull 101 "}"}} @@ -170,18 +173,21 @@ Dual anchors pin the insert to exactly one gap, preventing drift from edits else {{hlinefull 103 "export function serialize(data: unknown): string {"}} ``` ``` +op: "insert" before: "{{hlineref 103 "export function serialize(data: unknown): string {"}}" -inserted_lines: ["function validate(data: unknown): boolean {", " return data != null && typeof data === \"object\";", "}", ""] +content: ["function validate(data: unknown): boolean {", " return data != null && typeof data === \"object\";", "}", ""] ``` -The trailing `""` in `inserted_lines` preserves the blank-line separator. **Anchor to the structural line (`export function ...`), not the blank line above it** — blank lines are ambiguous and may be added or removed by other edits. +The trailing `""` in `content` preserves the blank-line separator. **Anchor to the structural line (`export function ...`), not the blank line above it** — blank lines are ambiguous and may be added or removed by other edits. {{#if allowReplaceText}} - + ``` +op: "replaceText" old_text: "x = 42" new_text: "x = 99" ``` + Use only when line anchors aren't available. `old_text` must match exactly one location in the file (or set `"all": true` for all occurrences). {{/if}} @@ -193,34 +199,34 @@ delete: true ``` - + ``` path: "src/utils.ts" rename: "src/helpers/utils.ts" -edits: [..] +edits: […] ``` -Bad — anchors to a blank line; fragile if blank lines shift: +Bad — tags to a blank line; fragile if blank lines shift: ``` after: "{{hlineref 102 ""}}" -inserted_lines: ["function validate() { ... }"] +content: ["function validate() {", …, "}"] ``` Good — anchors to the structural target: + ``` before: "{{hlineref 103 "export function serialize(data: unknown): string {"}}" -inserted_lines: ["function validate() { ... }", ""] +content: ["function validate() {", …, "}"] ``` - -- [ ] Payload shape is `{ "path": string, "edits": [operation, ...], "delete"?: true, "rename"?: string }` -- [ ] Every operation matches exactly one variant -- [ ] Every anchor is copied exactly as `LINE#ID` (no spaces, no `|content`) -- [ ] `new_content` / `inserted_lines` 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 - -**Final reminder:** anchors are immutable references to the last read snapshot. Re-read when state changes, then edit. \ No newline at end of file + +Ensure: +- Payload shape is `{ "path": string, "edits": [operation, …], "delete"?: boolean, "rename"?: string }` +- Every edit matches exactly one variant +- Every tag has been copied EXACTLY from a tool result as `N#ID` +- Scope is minimal and formatting is preserved except targeted token changes + +**Final reminder:** tags are immutable references to the last read snapshot. Re-read when state changes, then edit. \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 3788b9b83..e55324ff2 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -6,7 +6,7 @@ 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 CID prefixed: `LINE#ID|content` +- Text output is CID prefixed: `LINE#ID:content` {{else}} {{#if IS_LINE_NUMBER_MODE}} - Text output is line-number-prefixed diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 0d7f84e85..938170671 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -220,10 +220,10 @@ export class GrepTool implements AgentTool { const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => { if (useHashLines) { const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`; - return isMatch ? `>>${ref}|${line}` : ` ${ref}|${line}`; + return isMatch ? `>>${ref}:${line}` : ` ${ref}:${line}`; } const padded = lineNumber.toString().padStart(lineWidth, " "); - return isMatch ? `>>${padded}|${line}` : ` ${padded}|${line}`; + return isMatch ? `>>${padded}:${line}` : ` ${padded}:${line}`; }; // Add context before diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 6d696b20a..6ca990035 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -772,7 +772,7 @@ export class ReadTool implements AgentTool { 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 { }; 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); diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index efc0b38f2..f6f37f507 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -1,41 +1,45 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { applyHashlineEdits, computeLineHash, formatHashLines, - type HashlineEdit, HashlineMismatchError, - parseLineRef, + parseTag, streamHashLinesFromLines, streamHashLinesFromUtf8, validateLineRef, } from "@oh-my-pi/pi-coding-agent/patch"; +import { formatLineTag, type HashlineEdit, type LineTag } from "@oh-my-pi/pi-coding-agent/patch/hashline"; + +function makeTag(line: number, content: string): LineTag { + return parseTag(formatLineTag(line, content)); +} // ═══════════════════════════════════════════════════════════════════════════ // computeLineHash // ═══════════════════════════════════════════════════════════════════════════ describe("computeLineHash", () => { - test("returns 2-4 character alphanumeric hash string", () => { + it("returns 2-4 character alphanumeric hash string", () => { const hash = computeLineHash(1, "hello"); - expect(hash).toMatch(/^[0-9a-z]{2,4}$/); + expect(hash).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/); }); - test("same content at same line produces same hash", () => { + it("same content at same line produces same hash", () => { const a = computeLineHash(1, "hello"); const b = computeLineHash(1, "hello"); expect(a).toBe(b); }); - test("different content produces different hash", () => { + it("different content produces different hash", () => { const a = computeLineHash(1, "hello"); const b = computeLineHash(1, "world"); expect(a).not.toBe(b); }); - test("empty line produces valid hash", () => { + it("empty line produces valid hash", () => { const hash = computeLineHash(1, ""); - expect(hash).toMatch(/^[0-9a-z]{2,4}$/); + expect(hash).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/); }); }); @@ -44,13 +48,13 @@ describe("computeLineHash", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("formatHashLines", () => { - test("formats single line", () => { + it("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", () => { + it("formats multiple lines with 1-indexed numbers", () => { const result = formatHashLines("foo\nbar\nbaz"); const lines = result.split("\n"); expect(lines).toHaveLength(3); @@ -59,27 +63,27 @@ describe("formatHashLines", () => { expect(lines[2]).toStartWith("3#"); }); - test("respects custom startLine", () => { + it("respects custom startLine", () => { const result = formatHashLines("foo\nbar", 10); const lines = result.split("\n"); expect(lines[0]).toStartWith("10#"); expect(lines[1]).toStartWith("11#"); }); - test("handles empty lines in content", () => { + it("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#[ZPMQVRWSNKTXJBYH]{2}:$/); }); - test("round-trips with computeLineHash", () => { + it("round-trips with computeLineHash", () => { const content = "function hello() {\n return 42;\n}"; const formatted = formatHashLines(content); 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+)#([ZPMQVRWSNKTXJBYH]{2}):(.*)$/); expect(match).not.toBeNull(); const lineNum = Number.parseInt(match![1], 10); const hash = match![2]; @@ -109,26 +113,26 @@ describe("streamHashLinesFrom*", () => { } } - test("streamHashLinesFromUtf8 matches formatHashLines", async () => { + it("streamHashLinesFromUtf8 matches formatHashLines", async () => { const content = "foo\nbar\nbaz"; const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 })); expect(streamed).toBe(formatHashLines(content)); }); - test("streamHashLinesFromUtf8 handles empty content", async () => { + it("streamHashLinesFromUtf8 handles empty content", async () => { const content = ""; const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 })); expect(streamed).toBe(formatHashLines(content)); }); - test("streamHashLinesFromLines matches formatHashLines (including trailing newline)", async () => { + it("streamHashLinesFromLines matches formatHashLines (including trailing newline)", async () => { const content = "foo\nbar\n"; const lines = ["foo", "bar", ""]; // match `content.split("\\n")` const streamed = await collectText(streamHashLinesFromLines(lines, { maxChunkLines: 2 })); expect(streamed).toBe(formatHashLines(content)); }); - test("chunking respects maxChunkLines", async () => { + it("chunking respects maxChunkLines", async () => { const content = "a\nb\nc"; const parts: string[] = []; for await (const part of streamHashLinesFromUtf8(utf8Chunks(content, 1), { @@ -143,47 +147,46 @@ describe("streamHashLinesFrom*", () => { }); // ═══════════════════════════════════════════════════════════════════════════ -// parseLineRef +// parseTag // ═══════════════════════════════════════════════════════════════════════════ -describe("parseLineRef", () => { - test("parses valid reference", () => { - const ref = parseLineRef("5#abcd"); - expect(ref).toEqual({ line: 5, hash: "abcd" }); +describe("parseTag", () => { + it("parses valid reference", () => { + const ref = parseTag("5#QQ"); + expect(ref).toEqual({ line: 5, hash: "QQ" }); }); - test("parses single-digit hash", () => { - const ref = parseLineRef("1#a"); - expect(ref).toEqual({ line: 1, hash: "a" }); + it("rejects single-character hash", () => { + expect(() => parseTag("1#Q")).toThrow(/Invalid line reference/); }); - test("parses long hash", () => { - const ref = parseLineRef("100#abcdef0123456789"); - expect(ref).toEqual({ line: 100, hash: "abcdef0123456789" }); + it("parses long hash by taking strict 2-char prefix", () => { + const ref = parseTag("100#QQQQ"); + expect(ref).toEqual({ line: 100, hash: "QQ" }); }); - test("rejects missing separator", () => { - expect(() => parseLineRef("5abcd")).toThrow(/Invalid line reference/); + it("rejects missing separator", () => { + expect(() => parseTag("5QQ")).toThrow(/Invalid line reference/); }); - test("rejects non-numeric line", () => { - expect(() => parseLineRef("abc#1234")).toThrow(/Invalid line reference/); + it("rejects non-numeric line", () => { + expect(() => parseTag("abc#Q")).toThrow(/Invalid line reference/); }); - test("rejects non-alphanumeric hash", () => { - expect(() => parseLineRef("5#$$$$")).toThrow(/Invalid line reference/); + it("rejects non-alphanumeric hash", () => { + expect(() => parseTag("5#$$$$")).toThrow(/Invalid line reference/); }); - test("rejects line number 0", () => { - expect(() => parseLineRef("0#abcd")).toThrow(/Line number must be >= 1/); + it("rejects line number 0", () => { + expect(() => parseTag("0#QQ")).toThrow(/Line number must be >= 1/); }); - test("rejects empty string", () => { - expect(() => parseLineRef("")).toThrow(/Invalid line reference/); + it("rejects empty string", () => { + expect(() => parseTag("")).toThrow(/Invalid line reference/); }); - test("rejects empty hash", () => { - expect(() => parseLineRef("5#")).toThrow(/Invalid line reference/); + it("rejects empty hash", () => { + expect(() => parseTag("5#")).toThrow(/Invalid line reference/); }); }); @@ -192,29 +195,29 @@ describe("parseLineRef", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("validateLineRef", () => { - test("accepts valid ref with matching hash", () => { + it("accepts valid ref with matching hash", () => { const lines = ["hello", "world"]; const hash = computeLineHash(1, "hello"); expect(() => validateLineRef({ line: 1, hash }, lines)).not.toThrow(); }); - test("rejects line out of range (too high)", () => { + it("rejects line out of range (too high)", () => { const lines = ["hello"]; const hash = computeLineHash(1, "hello"); expect(() => validateLineRef({ line: 2, hash }, lines)).toThrow(/does not exist/); }); - test("rejects line out of range (zero)", () => { + it("rejects line out of range (zero)", () => { const lines = ["hello"]; expect(() => validateLineRef({ line: 0, hash: "aaaa" }, lines)).toThrow(/does not exist/); }); - test("rejects mismatched hash", () => { + it("rejects mismatched hash", () => { const lines = ["hello", "world"]; expect(() => validateLineRef({ line: 1, hash: "0000" }, lines)).toThrow(/has changed since last read/); }); - test("validates last line correctly", () => { + it("validates last line correctly", () => { const lines = ["a", "b", "c"]; const hash = computeLineHash(3, "c"); expect(() => validateLineRef({ line: 3, hash }, lines)).not.toThrow(); @@ -226,31 +229,29 @@ describe("validateLineRef", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — replace", () => { - function makeRef(lineNum: number, content: string): string { - return `${lineNum}#${computeLineHash(lineNum, content)}`; - } - - test("replaces single line", () => { + it("replaces single line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } }]; + const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nBBB\nccc"); expect(result.firstChangedLine).toBe(2); }); - test("range replace (shrink)", () => { + it("range replace (shrink)", () => { const content = "aaa\nbbb\nccc\nddd"; - const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: ["ONE"] } }]; + const edits: HashlineEdit[] = [ + { op: "replace", first: makeTag(2, "bbb"), last: makeTag(3, "ccc"), content: ["ONE"] }, + ]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nONE\nddd"); }); - test("range replace (same count)", () => { + it("range replace (same count)", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ - { set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: ["XXX", "YYY"] } }, + { op: "replace", first: makeTag(2, "bbb"), last: makeTag(3, "ccc"), content: ["XXX", "YYY"] }, ]; const result = applyHashlineEdits(content, edits); @@ -258,18 +259,18 @@ describe("applyHashlineEdits — replace", () => { expect(result.firstChangedLine).toBe(2); }); - test("replaces first line", () => { + it("replaces first line", () => { const content = "first\nsecond\nthird"; - const edits: HashlineEdit[] = [{ set: { ref: makeRef(1, "first"), body: ["FIRST"] } }]; + const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(1, "first"), content: ["FIRST"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("FIRST\nsecond\nthird"); expect(result.firstChangedLine).toBe(1); }); - test("replaces last line", () => { + it("replaces last line", () => { const content = "first\nsecond\nthird"; - const edits: HashlineEdit[] = [{ set: { ref: makeRef(3, "third"), body: ["THIRD"] } }]; + const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(3, "third"), content: ["THIRD"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("first\nsecond\nTHIRD"); @@ -282,38 +283,34 @@ describe("applyHashlineEdits — replace", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — delete", () => { - function makeRef(lineNum: number, content: string): string { - return `${lineNum}#${computeLineHash(lineNum, content)}`; - } - - test("deletes single line", () => { + it("deletes single line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ set: { ref: makeRef(2, "bbb"), body: [] } }]; + const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(2, "bbb"), content: [] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nccc"); expect(result.firstChangedLine).toBe(2); }); - test("deletes range of lines", () => { + it("deletes range of lines", () => { const content = "aaa\nbbb\nccc\nddd"; - const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: [] } }]; + const edits: HashlineEdit[] = [{ op: "replace", first: makeTag(2, "bbb"), last: makeTag(3, "ccc"), content: [] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nddd"); }); - test("deletes first line", () => { + it("deletes first line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ set: { ref: makeRef(1, "aaa"), body: [] } }]; + const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(1, "aaa"), content: [] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("bbb\nccc"); }); - test("deletes last line", () => { + it("deletes last line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ set: { ref: makeRef(3, "ccc"), body: [] } }]; + const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(3, "ccc"), content: [] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nbbb"); @@ -325,63 +322,59 @@ describe("applyHashlineEdits — delete", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — insert", () => { - function makeRef(lineNum: number, content: string): string { - return `${lineNum}#${computeLineHash(lineNum, content)}`; - } - - test("inserts after a line", () => { + it("inserts after a line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: ["NEW"] } }]; + const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: ["NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); expect(result.firstChangedLine).toBe(2); }); - test("inserts multiple lines", () => { + it("inserts multiple lines", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: ["x", "y", "z"] } }]; + const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: ["x", "y", "z"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nx\ny\nz\nbbb"); }); - test("inserts after last line", () => { + it("inserts after last line", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert: { after: makeRef(2, "bbb"), body: ["NEW"] } }]; + const edits: HashlineEdit[] = [{ op: "append", after: makeTag(2, "bbb"), content: ["NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nbbb\nNEW"); }); - test("insert with empty dst throws", () => { + it("insert with empty dst throws", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: [] } }]; + const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: [] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); - test("inserts at EOF without anchors", () => { + it("inserts at EOF without anchors", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert: { body: ["NEW"] } }]; + const edits = [{ op: "append", content: ["NEW"] }] as unknown as HashlineEdit[]; 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", () => { + it("inserts at EOF into empty file without anchors", () => { const content = ""; - const edits: HashlineEdit[] = [{ insert: { body: ["NEW"] } }]; + const edits = [{ op: "append", content: ["NEW"] }] as unknown as HashlineEdit[]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("NEW"); expect(result.firstChangedLine).toBe(1); }); - test("insert at EOF with empty dst throws", () => { + it("insert at EOF with empty dst throws", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert: { body: [] } }]; + const edits = [{ op: "append", content: [] }] as unknown as HashlineEdit[]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); @@ -392,62 +385,66 @@ describe("applyHashlineEdits — insert", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — insert (before)", () => { - function makeRef(lineNum: number, content: string): string { - return `${lineNum}#${computeLineHash(lineNum, content)}`; - } - - test("inserts before a line", () => { + it("inserts before a line", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["NEW"] } }]; + const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(2, "bbb"), content: ["NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nNEW\nbbb\nccc"); expect(result.firstChangedLine).toBe(2); }); - test("inserts multiple lines before", () => { + it("inserts multiple lines before", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["x", "y", "z"] } }]; + const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(2, "bbb"), content: ["x", "y", "z"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nx\ny\nz\nbbb"); }); - test("inserts before first line", () => { + it("inserts before first line", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: ["NEW"] } }]; + const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(1, "aaa"), content: ["NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("NEW\naaa\nbbb"); }); - test("insert with before and empty text throws", () => { + it("prepends at BOF without anchor", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: [] } }]; + const edits = [{ op: "prepend", content: ["NEW"] }] as unknown as HashlineEdit[]; + const result = applyHashlineEdits(content, edits); + expect(result.content).toBe("NEW\naaa\nbbb"); + expect(result.firstChangedLine).toBe(1); + }); + + it("insert with before and empty text throws", () => { + const content = "aaa\nbbb"; + const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(1, "aaa"), content: [] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); - test("strips anchor echo from end of inserted text (autocorrect)", () => { + it("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 edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(2, "bbb"), content: ["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", () => { + it("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"] } }, + { op: "prepend", before: makeTag(2, "bbb"), content: ["BEFORE"] }, + { op: "append", after: makeTag(2, "bbb"), content: ["AFTER"] }, ]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nBEFORE\nbbb\nAFTER\nccc"); }); - test("insert before with set at same line", () => { + it("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"] } }, + { op: "prepend", before: makeTag(2, "bbb"), content: ["BEFORE"] }, + { op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] }, ]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nBEFORE\nBBB\nccc"); @@ -459,45 +456,39 @@ describe("applyHashlineEdits — insert (before)", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — insert (between)", () => { - function makeRef(lineNum: number, content: string): string { - return `${lineNum}#${computeLineHash(lineNum, content)}`; - } - - test("inserts between adjacent anchors", () => { + it("inserts between adjacent anchors", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [ - { insert: { after: makeRef(1, "aaa"), before: makeRef(2, "bbb"), body: ["NEW"] } }, + { op: "insert", after: makeTag(1, "aaa"), before: makeTag(2, "bbb"), content: ["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", () => { + it("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"], - }, + op: "insert", + after: makeTag(1, "aaa"), + before: makeTag(2, "bbb"), + content: ["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)", () => { + it("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"], - }, + op: "insert", + after: makeTag(1, "aaa"), + before: makeTag(2, "bbb"), + content: ["aaa", "NEW", "bbb"], }, ]; const result = applyHashlineEdits(content, edits); @@ -520,19 +511,15 @@ describe("applyHashlineEdits — heuristics", () => { Bun.env.PI_HL_AUTOCORRECT = origEnv; }); - function makeRef(lineNum: number, content: string): string { - return `${lineNum}#${computeLineHash(lineNum, content)}`; - } - - test("strips insert-after anchor echo", () => { + it("strips insert-after anchor echo", () => { const content = "aaa\nbbb\nccc"; - const edits: HashlineEdit[] = [{ insert: { after: makeRef(2, "bbb"), body: ["bbb", "NEW"] } }]; + const edits: HashlineEdit[] = [{ op: "append", after: makeTag(2, "bbb"), content: ["bbb", "NEW"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nbbb\nNEW\nccc"); }); - test("strips range boundary echo and preserves whitespace on unchanged lines", () => { + it("strips range boundary echo and preserves whitespace on unchanged lines", () => { const content = [ "import { foo } from 'x';", "if (cond) {", @@ -547,12 +534,11 @@ describe("applyHashlineEdits — heuristics", () => { const end = 6; const edits: HashlineEdit[] = [ { - set_range: { - beg: makeRef(start, "if (cond) {"), - end: makeRef(end, "}"), - // Echoes line after the range ("after();") and also reformats the import line. - body: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"], - }, + op: "replace", + first: makeTag(start, "if (cond) {"), + last: makeTag(end, "}"), + // Echoes line after the range ("after();") and also reformats the import line. + content: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"], }, ]; @@ -562,15 +548,14 @@ describe("applyHashlineEdits — heuristics", () => { expect(result.content).toBe(content); }); - test("does not override model whitespace choices in replacement content", () => { + it("does not override model whitespace choices in replacement content", () => { const content = ["import { foo } from 'x';", "import { bar } from 'y';", "const x = 1;"].join("\n"); const edits: HashlineEdit[] = [ { - 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"], - }, + op: "replace", + first: makeTag(1, "import { foo } from 'x';"), + last: makeTag(2, "import { bar } from 'y';"), + content: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"], }, ]; const result = applyHashlineEdits(content, edits); @@ -582,27 +567,26 @@ describe("applyHashlineEdits — heuristics", () => { expect(outLines[3]).toBe("const x = 1;"); }); - test("restores a long wrapped line when model reflows it across many lines", () => { + it("restores a long wrapped line when model reflows it across many lines", () => { const longLine = "const options = veryLongIdentifier + anotherLongIdentifier + thirdLongIdentifier + fourthLongIdentifier;"; const content = ["before();", longLine, "after();"].join("\n"); const edits: HashlineEdit[] = [ { - set: { - ref: makeRef(2, longLine), - body: [ - "const", - "options", - "=", - "veryLongIdentifier", - "+", - "anotherLongIdentifier", - "+", - "thirdLongIdentifier", - "+", - "fourthLongIdentifier;", - ], - }, + op: "set", + tag: makeTag(2, longLine), + content: [ + "const", + "options", + "=", + "veryLongIdentifier", + "+", + "anotherLongIdentifier", + "+", + "thirdLongIdentifier", + "+", + "fourthLongIdentifier;", + ], }, ]; @@ -610,18 +594,17 @@ describe("applyHashlineEdits — heuristics", () => { expect(result.content).toBe(content); }); - test("repairs single-line replacement that absorbed the next line (prevents duplication)", () => { + it("repairs single-line replacement that absorbed the next line (prevents duplication)", () => { const content = [" typeof HOOK === 'undefined' &&", " typeof HOOK.checkDCE !== 'function'", "tail();"].join( "\n", ); const edits: HashlineEdit[] = [ { - set: { - ref: makeRef(1, " typeof HOOK === 'undefined' &&"), - // Model merged both lines into one and dropped indentation. - body: ["typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'"], - }, + op: "set", + tag: makeTag(1, " typeof HOOK === 'undefined' &&"), + // Model merged both lines into one and dropped indentation. + content: ["typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'"], }, ]; @@ -631,7 +614,7 @@ describe("applyHashlineEdits — heuristics", () => { ); }); - test("repairs single-line replacement that absorbed the previous line (prevents duplication)", () => { + it("repairs single-line replacement that absorbed the previous line (prevents duplication)", () => { const content = [ " const nativeStyleResolver: ResolveNativeStyle | void =", " resolveRNStyle || hook.resolveRNStyle;", @@ -640,11 +623,10 @@ describe("applyHashlineEdits — heuristics", () => { const edits: HashlineEdit[] = [ { - set: { - ref: makeRef(2, " resolveRNStyle || hook.resolveRNStyle;"), - // Model absorbed the declaration line and dropped indentation. - body: ["const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;"], - }, + op: "set", + tag: makeTag(2, " resolveRNStyle || hook.resolveRNStyle;"), + // Model absorbed the declaration line and dropped indentation. + content: ["const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;"], }, ]; @@ -657,15 +639,14 @@ describe("applyHashlineEdits — heuristics", () => { ); }); - test("accepts polluted src that starts with LINE#ID but includes trailing content", () => { + it("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: { - ref: `2#${srcHash}export function foo(a, b) {}`, // comma in trailing content - body: ["BBB"], - }, + op: "set", + tag: parseTag(`2#${srcHash}export function foo(a, b) {}`), // comma in trailing content + content: ["BBB"], }, ]; @@ -673,29 +654,13 @@ describe("applyHashlineEdits — heuristics", () => { expect(result.content).toBe("aaa\nBBB\nccc"); }); - test("treats same-line ranges as single-line replacements", () => { + it("treats same-line ranges as single-line replacements", () => { const content = "aaa\nbbb\nccc"; - const good = makeRef(2, "bbb"); - const edits: HashlineEdit[] = [{ set_range: { beg: good, end: good, body: ["BBB"] } }]; + const good = makeTag(2, "bbb"); + const edits: HashlineEdit[] = [{ op: "replace", first: good, last: good, content: ["BBB"] }]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nBBB\nccc"); }); - - test("normalizes unicode-confusable hyphens when an edit would otherwise be a no-op", () => { - const content = "aaa\ndevtools–unsupported-bridge-protocol\nccc"; - // dst is byte-identical to original (en-dash), so this would normally be a no-op. - const edits: HashlineEdit[] = [ - { - set: { - ref: makeRef(2, "devtools–unsupported-bridge-protocol"), - body: ["devtools–unsupported-bridge-protocol"], - }, - }, - ]; - - const result = applyHashlineEdits(content, edits); - expect(result.content).toBe("aaa\ndevtools-unsupported-bridge-protocol\nccc"); - }); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -703,15 +668,11 @@ describe("applyHashlineEdits — heuristics", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — multiple edits", () => { - function makeRef(lineNum: number, content: string): string { - return `${lineNum}#${computeLineHash(lineNum, content)}`; - } - - test("applies two non-overlapping replaces (bottom-up safe)", () => { + it("applies two non-overlapping replaces (bottom-up safe)", () => { const content = "aaa\nbbb\nccc\nddd\neee"; const edits: HashlineEdit[] = [ - { set: { ref: makeRef(2, "bbb"), body: ["BBB"] } }, - { set: { ref: makeRef(4, "ddd"), body: ["DDD"] } }, + { op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] }, + { op: "set", tag: makeTag(4, "ddd"), content: ["DDD"] }, ]; const result = applyHashlineEdits(content, edits); @@ -719,46 +680,45 @@ describe("applyHashlineEdits — multiple edits", () => { expect(result.firstChangedLine).toBe(2); }); - test("applies replace + delete in one call", () => { + it("applies replace + delete in one call", () => { const content = "aaa\nbbb\nccc\nddd"; const edits: HashlineEdit[] = [ - { set: { ref: makeRef(2, "bbb"), body: ["BBB"] } }, - { set: { ref: makeRef(4, "ddd"), body: [] } }, + { op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] }, + { op: "set", tag: makeTag(4, "ddd"), content: [] }, ]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nBBB\nccc"); }); - test("applies replace + insert in one call", () => { + it("applies replace + insert in one call", () => { const content = "aaa\nbbb\nccc"; const edits: HashlineEdit[] = [ - { set: { ref: makeRef(3, "ccc"), body: ["CCC"] } }, - { insert: { after: makeRef(1, "aaa"), body: ["INSERTED"] } }, + { op: "set", tag: makeTag(3, "ccc"), content: ["CCC"] }, + { op: "append", after: makeTag(1, "aaa"), content: ["INSERTED"] }, ]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("aaa\nINSERTED\nbbb\nCCC"); }); - test("applies non-overlapping edits against original anchors when line counts change", () => { + it("applies non-overlapping edits against original anchors when line counts change", () => { const content = "one\ntwo\nthree\nfour\nfive\nsix"; const edits: HashlineEdit[] = [ { - set_range: { - beg: makeRef(2, "two"), - end: makeRef(3, "three"), - body: ["TWO_THREE"], - }, + op: "replace", + first: makeTag(2, "two"), + last: makeTag(3, "three"), + content: ["TWO_THREE"], }, - { set: { ref: makeRef(6, "six"), body: ["SIX"] } }, + { op: "set", tag: makeTag(6, "six"), content: ["SIX"] }, ]; const result = applyHashlineEdits(content, edits); expect(result.content).toBe("one\nTWO_THREE\nfour\nfive\nSIX"); }); - test("empty edits array is a no-op", () => { + it("empty edits array is a no-op", () => { const content = "aaa\nbbb"; const result = applyHashlineEdits(content, []); expect(result.content).toBe(content); @@ -771,20 +731,16 @@ describe("applyHashlineEdits — multiple edits", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("applyHashlineEdits — errors", () => { - function makeRef(lineNum: number, content: string): string { - return `${lineNum}#${computeLineHash(lineNum, content)}`; - } - - test("rejects stale hash", () => { + it("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: { ref: "2#zz", body: ["BBB"] } }]; + const edits: HashlineEdit[] = [{ op: "set", tag: parseTag("2#QQ"), content: ["BBB"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); }); - test("stale hash error shows >>> markers with correct hashes", () => { + it("stale hash error shows >>> markers with correct hashes", () => { const content = "aaa\nbbb\nccc\nddd\neee"; - const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }]; + const edits: HashlineEdit[] = [{ op: "set", tag: parseTag("2#QQ"), content: ["BBB"] }]; try { applyHashlineEdits(content, edits); @@ -796,7 +752,7 @@ 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("#")); @@ -804,10 +760,13 @@ describe("applyHashlineEdits — errors", () => { } }); - test("stale hash error collects all mismatches", () => { + it("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: { ref: "2#zz", body: ["BBB"] } }, { set: { ref: "4#zz", body: ["DDD"] } }]; + const edits: HashlineEdit[] = [ + { op: "set", tag: parseTag("2#ZZ"), content: ["BBB"] }, + { op: "set", tag: parseTag("4#ZZ"), content: ["DDD"] }, + ]; try { applyHashlineEdits(content, edits); @@ -824,10 +783,10 @@ describe("applyHashlineEdits — errors", () => { } }); - test("does not relocate stale line refs even when hash uniquely matches another line", () => { + it("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: { ref: staleButUnique, body: ["CCC"] } }]; + const staleButUnique = parseTag(`2#${computeLineHash(1, "ccc")}`); + const edits: HashlineEdit[] = [{ op: "set", tag: staleButUnique, content: ["CCC"] }]; try { applyHashlineEdits(content, edits); expect.unreachable("should have thrown"); @@ -838,59 +797,56 @@ describe("applyHashlineEdits — errors", () => { } }); - test("does not relocate when expected hash is non-unique", () => { + it("does not relocate when expected hash is non-unique", () => { const content = "dup\nmid\ndup"; - const staleDuplicate = `2#${computeLineHash(1, "dup")}`; - const edits: HashlineEdit[] = [{ set: { ref: staleDuplicate, body: ["DUP"] } }]; + const staleDuplicate = parseTag(`2#${computeLineHash(1, "dup")}`); + const edits: HashlineEdit[] = [{ op: "set", tag: staleDuplicate, content: ["DUP"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError); }); - test("rejects out-of-range line", () => { + it("rejects out-of-range line", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ set: { ref: "10#aa", body: ["X"] } }]; + const edits: HashlineEdit[] = [{ op: "set", tag: parseTag("10#ZZ"), content: ["X"] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/); }); - test("rejects range with start > end", () => { + it("rejects range with start > end", () => { const content = "aaa\nbbb\nccc\nddd\neee"; - const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(5, "eee"), end: makeRef(2, "bbb"), body: ["X"] } }]; + const edits: HashlineEdit[] = [ + { op: "replace", first: makeTag(5, "eee"), last: makeTag(2, "bbb"), content: ["X"] }, + ]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); - test("rejects insert with after and empty text", () => { + it("rejects insert with after and empty text", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: [] } }]; + const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: [] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); - test("rejects insert with before and empty text", () => { + it("rejects insert with before and empty text", () => { const content = "aaa\nbbb"; - const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: [] } }]; + const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(1, "aaa"), content: [] }]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); - test("rejects insert with both anchors and empty text", () => { + it("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: [] } }]; + const edits: HashlineEdit[] = [ + { op: "insert", after: makeTag(1, "aaa"), before: makeTag(2, "bbb"), content: [] }, + ]; expect(() => applyHashlineEdits(content, edits)).toThrow(); }); - test("rejects insert with non-adjacent anchors", () => { + it("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"] } }, + { op: "insert", after: makeTag(1, "aaa"), before: makeTag(3, "ccc"), content: ["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" } }]; - - expect(() => applyHashlineEdits(content, edits)).toThrow(/replace edits are applied separately/); - }); }); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index ac3283ecf..050b003d9 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -490,7 +490,7 @@ function b() { const output = getTextOutput(result); expect(output).toContain("1. example.txt:2"); - expect(output).toMatch(/>>\s*2.*\|match line/); + expect(output).toMatch(/>>\s*2#[ZPMQVRWSNKTXJBYH]{2}:match line/); }); it("should respect global limit and include context lines", async () => { @@ -508,9 +508,9 @@ function b() { const output = getTextOutput(result); expect(output).toContain("1. context.txt:2"); - expect(output).toMatch(/\b1.*\|before/); - expect(output).toMatch(/>>\s*2.*\|match one/); - expect(output).toMatch(/\b3.*\|after/); + expect(output).toMatch(/\b1#[ZPMQVRWSNKTXJBYH]{2}:before/); + expect(output).toMatch(/>>\s*2#[ZPMQVRWSNKTXJBYH]{2}:match one/); + expect(output).toMatch(/\b3#[ZPMQVRWSNKTXJBYH]{2}:after/); expect(output).toContain("[1 matches limit reached. Use limit=2 for more]"); // Ensure second match is not present expect(output).not.toContain("match two"); @@ -669,7 +669,7 @@ describe("edit tool CRLF handling", () => { const hashlineEditTool = new EditTool(session); const result = await hashlineEditTool.execute("hashline-replace-1", { path: testFile, - edits: [{ old_text: "x = 42", new_text: "x = 99" }], + edits: [{ op: "replaceText", old_text: "x = 42", new_text: "x = 99" }], }); expect(getTextOutput(result)).toContain("Updated"); diff --git a/packages/react-edit-benchmark/runs/claude-haiku-4-5_hashline_2026-02-19T19-25-55.md b/packages/react-edit-benchmark/runs/claude-haiku-4-5_hashline_2026-02-19T19-25-55.md new file mode 100644 index 000000000..52ba53003 --- /dev/null +++ b/packages/react-edit-benchmark/runs/claude-haiku-4-5_hashline_2026-02-19T19-25-55.md @@ -0,0 +1,1737 @@ +# Edit Benchmark Report + +## Configuration + +| Setting | Value | +|---------|-------| +| Date | 2026-02-19T19:22:45.328Z | +| Model | p-anthropic/p-anthropic/claude-haiku-4-5 | +| 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 | 61 | +| **Task Success Rate** | **76.3% (61/80)** | +| Verified Rate | 76.3% (61/80) | +| Edit Tool Usage Rate | 100.0% (80/80) | +| **Edit Success Rate** | **75.6%** | +| Timeout Runs | 0 | +| Mutation Intent Match Rate | 75.0% | +| Patch Failure Rate | 24.4% (29/119) | +| Tasks All Passing | 61 | +| Tasks Flaky/Failing | 19 | + +### Tool Calls + +| Tool | Total | Avg/Run | +|------|-------|---------| +| Read | 220 | 2.8 | +| Edit | 119 | 1.5 | +| Write | 0 | 0.0 | +| **Tool Input Chars** | 29,482 | 369 | + +### Tokens & Time + +| Metric | Total | Avg/Run | +|--------|-------|---------| +| Input Tokens | 1,906 | 24 | +| Output Tokens | 94,921 | 1,187 | +| Total Tokens | 4,665,301 | 58,316 | +| Duration | 1225.2s | 15.3s | +| **Avg Indent Score** | — | **0.00** | + +## 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 | 18/519 | 8.0s | 0.00 | +| Access Remove Optional Chain 003 | hookNamesCache.js | 1/1 ✅ | 50.0% | 3/2/0 | 25/873 | 10.5s | 0.00 | +| Access Remove Optional Chain 005 | registerDevToolsEventLogger.js | 1/1 ✅ | 50.0% | 3/2/0 | 29/1,301 | 17.3s | 0.00 | +| Access Remove Optional Chain 007 | index.js | 0/1 ❌ | 0.0% | 2/1/0 | 12/200 | 16.9s | 0.00 | +| Call Swap Call Args 001 | formatConsoleArguments.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/531 | 6.6s | 0.00 | +| Call Swap Call Args 003 | ReactFlightDOMServerBrowser.js | 1/1 ✅ | 50.0% | 4/2/0 | 29/1,064 | 13.8s | 0.00 | +| Call Swap Call Args 005 | ReactNoopPersistent.js | 1/1 ✅ | 100.0% | 2/1/0 | 17/655 | 8.5s | 0.00 | +| Call Swap Call Args 007 | parseSourceAndMetadata.js | 1/1 ✅ | 100.0% | 3/1/0 | 20/702 | 8.1s | 0.00 | +| Duplicate Duplicate Line Flip 001 | isCustomElement.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/691 | 7.9s | 0.00 | +| Duplicate Duplicate Line Flip 003 | ReactFiberDevToolsHook.js | 0/1 ❌ | 100.0% | 5/2/0 | 44/5,075 | 168.8s | 0.00 | +| Duplicate Duplicate Line Flip 005 | shallowEqual.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/543 | 8.1s | 0.00 | +| Duplicate Duplicate Line Flip 007 | ReactDOMEventReplaying.js | 1/1 ✅ | 100.0% | 3/1/0 | 20/3,881 | 35.9s | 0.00 | +| Identifier Identifier Multi Edit 001 | Button.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/614 | 6.7s | 0.00 | +| Identifier Identifier Multi Edit 003 | ReactFlightDOMClientBrowser.js | 1/1 ✅ | 50.0% | 4/2/0 | 31/1,220 | 14.1s | 0.00 | +| Identifier Identifier Multi Edit 005 | githubAPI.js | 1/1 ✅ | 100.0% | 2/1/0 | 17/488 | 5.9s | 0.00 | +| Identifier Identifier Multi Edit 007 | ReactFiberComponentStack.js | 1/1 ✅ | 100.0% | 3/1/0 | 20/786 | 11.5s | 0.00 | +| Import Swap Named Imports 001 | ListApp.js | 1/1 ✅ | 100.0% | 2/1/0 | 19/703 | 8.6s | 0.00 | +| Import Swap Named Imports 003 | index.js | 0/1 ❌ | 100.0% | 2/1/0 | 18/1,068 | 13.3s | 0.00 | +| Import Swap Named Imports 005 | SuspenseScrubber.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/668 | 8.0s | 0.00 | +| Import Swap Named Imports 007 | InspectedElement.js | 0/1 ❌ | 100.0% | 6/3/0 | 42/4,629 | 43.6s | 0.00 | +| Literal Flip Boolean 001 | ReactDOMLegacyServerImpl.js | 0/1 ❌ | 100.0% | 2/1/0 | 19/960 | 13.1s | 0.00 | +| Literal Flip Boolean 003 | ReactProfilerTimer.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/1,127 | 15.2s | 0.00 | +| Literal Flip Boolean 005 | OpenInEditorButton.js | 1/1 ✅ | 100.0% | 2/1/0 | 19/1,023 | 11.3s | 0.00 | +| Literal Flip Boolean 007 | Element.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/537 | 7.6s | 0.00 | +| Literal Off By One 001 | ReactOwnerStackFrames.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/614 | 6.8s | 0.00 | +| Literal Off By One 003 | ReactFlightClientConfigBundlerTurbopack.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/552 | 8.0s | 0.00 | +| Literal Off By One 005 | ContextMenu.js | 1/1 ✅ | 100.0% | 2/1/0 | 19/576 | 7.5s | 0.00 | +| Literal Off By One 007 | index.js | 1/1 ✅ | 100.0% | 2/1/0 | 19/600 | 7.7s | 0.00 | +| Operator Remove Negation 001 | prepareInjection.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/544 | 8.2s | 0.00 | +| Operator Remove Negation 003 | ReactDOMSelection.js | 0/1 ❌ | 33.3% | 4/3/0 | 36/1,709 | 19.2s | 0.00 | +| Operator Remove Negation 005 | ReactDOMContainer.js | 0/1 ❌ | 100.0% | 2/1/0 | 18/1,478 | 17.1s | 0.00 | +| Operator Remove Negation 007 | SelectEventPlugin.js | 1/1 ✅ | 100.0% | 3/1/0 | 20/838 | 10.3s | 0.00 | +| Operator Swap Arithmetic 001 | formatConsoleArguments.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/581 | 7.2s | 0.00 | +| Operator Swap Arithmetic 003 | ReactFlightDOMServerBrowser.js | 1/1 ✅ | 50.0% | 2/2/0 | 24/898 | 10.7s | 0.00 | +| Operator Swap Arithmetic 005 | ReactFiberConfigWithNoResources.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/481 | 6.1s | 0.00 | +| Operator Swap Arithmetic 007 | useCanvasInteraction.js | 1/1 ✅ | 50.0% | 3/2/0 | 29/1,079 | 13.3s | 0.00 | +| Operator Swap Comparison 001 | useSmartTooltip.js | 1/1 ✅ | 50.0% | 2/2/0 | 24/1,136 | 13.0s | 0.00 | +| Operator Swap Comparison 003 | ReactFlightDOMServerBrowser.js | 1/1 ✅ | 100.0% | 3/1/0 | 22/1,122 | 14.0s | 0.00 | +| Operator Swap Comparison 005 | Rectangle.js | 1/1 ✅ | 50.0% | 2/2/0 | 24/976 | 11.5s | 0.00 | +| Operator Swap Comparison 007 | ReactFiberTreeReflection.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/795 | 10.0s | 0.00 | +| Operator Swap Equality 001 | ReactNoopFlightClient.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/552 | 8.6s | 0.00 | +| Operator Swap Equality 003 | astUtils.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/819 | 11.7s | 0.00 | +| Operator Swap Equality 005 | ReactDOMContainer.js | 1/1 ✅ | 50.0% | 6/2/0 | 27/1,467 | 19.7s | 0.00 | +| Operator Swap Equality 007 | ReactFlightDOMServerBrowser.js | 1/1 ✅ | 50.0% | 4/2/0 | 30/1,316 | 15.0s | 0.00 | +| Operator Swap Increment Decrement 001 | index.js | 1/1 ✅ | 100.0% | 2/1/0 | 19/537 | 7.5s | 0.00 | +| Operator Swap Increment Decrement 003 | ReactFlightClientConfigBundlerESM.js | 1/1 ✅ | 100.0% | 2/1/0 | 19/508 | 7.2s | 0.00 | +| Operator Swap Increment Decrement 005 | ReactFiberViewTransitionComponent.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/674 | 7.7s | 0.00 | +| Operator Swap Increment Decrement 007 | ReactFiberConcurrentUpdates.js | 1/1 ✅ | 50.0% | 3/2/0 | 26/839 | 11.3s | 0.00 | +| Operator Swap Logical 001 | ErrorView.js | 0/1 ❌ | 100.0% | 2/1/0 | 18/1,240 | 13.4s | 0.00 | +| Operator Swap Logical 003 | DevTools.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/667 | 9.3s | 0.00 | +| Operator Swap Logical 005 | UseEffectEvent.js | 1/1 ✅ | 100.0% | 3/1/0 | 23/812 | 9.6s | 0.00 | +| Operator Swap Logical 007 | getHookNameForLocation.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/655 | 9.8s | 0.00 | +| Operator Swap Nullish 001 | ElementBadges.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/641 | 7.3s | 0.00 | +| Operator Swap Nullish 003 | ReactComponentStackFrame.js | 1/1 ✅ | 100.0% | 3/1/0 | 21/701 | 8.1s | 0.00 | +| Operator Swap Nullish 005 | ReactLogo.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/743 | 9.9s | 0.00 | +| Operator Swap Nullish 007 | SuspenseBreadcrumbs.js | 1/1 ✅ | 50.0% | 4/2/0 | 29/1,893 | 18.6s | 0.00 | +| Regex Swap Regex Quantifier 001 | SimpleValues.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/640 | 8.3s | 0.00 | +| Regex Swap Regex Quantifier 003 | utils.js | 1/1 ✅ | 100.0% | 4/1/0 | 22/3,106 | 33.4s | 0.00 | +| Regex Swap Regex Quantifier 005 | formatWithStyles.js | 1/1 ✅ | 36.4% | 8/11/0 | 107/4,063 | 47.1s | 0.00 | +| Regex Swap Regex Quantifier 007 | RunReactCompiler.ts | 1/1 ✅ | 60.0% | 7/5/0 | 70/10,129 | 92.5s | 0.00 | +| Structural Delete Statement 001 | useExtensionComponentsPanelVisibility.js | 0/1 ❌ | 100.0% | 2/1/0 | 18/642 | 10.6s | 0.00 | +| Structural Delete Statement 003 | useCanvasInteraction.js | 0/1 ❌ | 50.0% | 4/2/0 | 31/1,216 | 12.8s | 0.00 | +| Structural Delete Statement 005 | StackTraceView.js | 0/1 ❌ | 100.0% | 6/4/0 | 56/1,742 | 19.6s | 0.00 | +| Structural Delete Statement 007 | ReactDOMFizzStaticBrowser.js | 0/1 ❌ | 50.0% | 2/2/0 | 24/1,050 | 11.1s | 0.00 | +| Structural Remove Early Return 001 | ReactFlightAsyncDispatcher.js | 0/1 ❌ | 50.0% | 3/2/0 | 31/1,145 | 18.0s | 0.00 | +| Structural Remove Early Return 003 | ReactDOMComponentTree.js | 0/1 ❌ | 50.0% | 5/2/0 | 39/2,318 | 24.1s | 0.00 | +| Structural Remove Early Return 005 | TabBar.js | 0/1 ❌ | 100.0% | 2/1/0 | 18/728 | 8.6s | 0.00 | +| Structural Remove Early Return 007 | CommitTreeBuilder.js | 0/1 ❌ | 50.0% | 3/2/0 | 29/1,046 | 13.5s | 0.00 | +| Structural Swap Adjacent Lines 001 | reactPolling.js | 1/1 ✅ | 100.0% | 3/1/0 | 27/862 | 9.1s | 0.00 | +| Structural Swap Adjacent Lines 003 | OwnersStack.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/663 | 7.1s | 0.00 | +| Structural Swap Adjacent Lines 005 | ReactOwnerStackFrames.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/732 | 11.6s | 0.00 | +| Structural Swap Adjacent Lines 007 | ReactFlightClientConfigBundlerWebpack.js | 0/1 ❌ | 100.0% | 2/1/0 | 19/1,022 | 9.9s | 0.00 | +| Structural Swap If Else 001 | utils.js | 0/1 ❌ | 100.0% | 2/1/0 | 18/841 | 9.6s | 0.00 | +| Structural Swap If Else 003 | ReactDOMFloat.js | 0/1 ❌ | 100.0% | 2/1/0 | 18/1,151 | 11.6s | 0.00 | +| Structural Swap If Else 005 | ReactClientConsoleConfigPlain.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/784 | 8.6s | 0.00 | +| Structural Swap If Else 007 | index.js | 1/1 ✅ | 100.0% | 2/1/0 | 19/770 | 9.5s | 0.00 | +| Unicode Unicode Hyphen 001 | formatProdErrorMessage.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/535 | 6.3s | 0.00 | +| Unicode Unicode Hyphen 003 | SourceMapConsumer.js | 1/1 ✅ | 100.0% | 3/1/0 | 20/687 | 10.4s | 0.00 | +| Unicode Unicode Hyphen 005 | babel.config.js | 1/1 ✅ | 100.0% | 2/1/0 | 18/507 | 8.2s | 0.00 | +| Unicode Unicode Hyphen 007 | ReactInternalTestUtils.js | 1/1 ✅ | 100.0% | 3/1/0 | 21/641 | 9.4s | 0.00 | + +## Category Summary + +| Category | Runs | Verified | Edit Used | Success | Min/Avg/Max Difficulty | +|----------|------|----------|-----------|---------|------------------------| +| access | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/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 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) | 2 / 3.8 / 5 | +| literal | 8 | 87.5% (7/8) | 100.0% (8/8) | 87.5% (7/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 | 31.3% (5/16) | 100.0% (16/16) | 31.3% (5/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 | 0.0% (0/4) | 100.0% (4/4) | 0.0% (0/4) | +| duplicate-line-flip | duplicate | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/4) | +| flip-boolean | literal | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/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 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) | +| remove-optional-chain | access | 4 | 75.0% (3/4) | 100.0% (4/4) | 75.0% (3/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 | 50.0% (2/4) | 100.0% (4/4) | 50.0% (2/4) | +| swap-nullish | operator | 4 | 100.0% (4/4) | 100.0% (4/4) | 100.0% (4/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 | 75.0% (18/24) | 100.0% (24/24) | 75.0% (18/24) | +| 6-8 | 27 | 74.1% (20/27) | 100.0% (27/27) | 74.1% (20/27) | +| 9+ | 25 | 76.0% (19/25) | 100.0% (25/25) | 76.0% (19/25) | + +## Edit Tool Errors + +Failures where the edit tool returned an error or failed to apply the patch. + +### Access Remove Optional Chain 003 (hookNamesCache.js) + +#### Run 1 + +##### Attempt 1 + +- Path: hookNamesCache.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "YY#NW". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "hookNamesCache.js", + "edits": [ + { + "op": "set", + "tag": "YY#NW", + "content": " inspected_element_number_of_hooks: resolvedHookNames?.size ?? null," + } + ] +} +``` + +### Access Remove Optional Chain 005 (registerDevToolsEventLogger.js) + +#### Run 1 + +##### Attempt 1 + +- Path: registerDevToolsEventLogger.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "YT". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "registerDevToolsEventLogger.js", + "edits": [ + { + "op": "set", + "tag": "YT", + "content": " currentLoggingIFrame?.contentWindow?.postMessage(" + } + ] +} +``` + +### Access Remove Optional Chain 007 (index.js) + +#### Run 1 + +##### Attempt 1 + +- Path: index.js +- Operation: — + +**Tool error** + +``` +Tool execution failed due to an error. +``` + +**Patch args** + +```diff +{ + "path": "index.js", + "edits": [ + { + "op": "set", + "tag": "67#HH", + "content": " port?.postMessage({ event, payload }, transferable);" + } + ] +} +``` + +### Call Swap Call Args 003 (ReactFlightDOMServerBrowser.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ReactFlightDOMServerBrowser.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "WM#WM". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "ReactFlightDOMServerBrowser.js", + "edits": [ + { + "op": "set", + "tag": "WM#WM", + "content": " startFlowing(request, controller);" + } + ] +} +``` + +### Identifier Identifier Multi Edit 003 (ReactFlightDOMClientBrowser.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ReactFlightDOMClientBrowser.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "TP". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "ReactFlightDOMClientBrowser.js", + "edits": [ + { + "op": "set", + "tag": "TP", + "content": " close(response);" + }, + { + "op": "set", + "tag": "JK", + "content": " startReadingFromUniversalStream(response, options.debugChannel.readable, handleDone);" + }, + { + "op": "set", + "tag": "BN", + "content": " startReadingFromStream(response, stream, close.bind(null, response), stream);" + } + ] +} +``` + +### Operator Remove Negation 003 (ReactDOMSelection.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ReactDOMSelection.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "XY#1". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "ReactDOMSelection.js", + "edits": [ + { + "op": "set", + "tag": "XY#1", + "content": " if (!(next = node.nextSibling)) {" + } + ] +} +``` + +##### Attempt 2 + +- Path: ReactDOMSelection.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "XY#1". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "ReactDOMSelection.js", + "edits": [ + { + "op": "set", + "tag": "XY#1", + "content": " if (!(next = node.nextSibling)) {" + } + ] +} +``` + +### Operator Swap Arithmetic 003 (ReactFlightDOMServerBrowser.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ReactFlightDOMServerBrowser.js +- Operation: — + +**Tool error** + +``` +1 line has changed since last read. Use the updated LINE#ID references shown below (>>> marks changed lines). + + 71#PR: : readPartialStringChunk(stringDecoder, buffer); + 72#YP: const messages = stringBuffer.split('\n'); +>>> 73#YW: for (let i = 0; i < messages.length + 1; i++) { + 74#WR: resolveDebugMessage(request, messages[i]); + 75#PN: } +``` + +**Patch args** + +```diff +{ + "path": "ReactFlightDOMServerBrowser.js", + "edits": [ + { + "op": "set", + "tag": "73#WR", + "content": " for (let i = 0; i < messages.length - 1; i++) {" + } + ] +} +``` + +### Operator Swap Arithmetic 007 (useCanvasInteraction.js) + +#### Run 1 + +##### Attempt 1 + +- Path: useCanvasInteraction.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "MW". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "useCanvasInteraction.js", + "edits": [ + { + "op": "set", + "tag": "MW", + "content": " x: localCoordinates.x - canvasRect.left," + } + ] +} +``` + +### Operator Swap Comparison 001 (useSmartTooltip.js) + +#### Run 1 + +##### Attempt 1 + +- Path: useSmartTooltip.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "WT#WT". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "useSmartTooltip.js", + "edits": [ + { + "op": "set", + "tag": "WT#WT", + "content": " if (mouseX + TOOLTIP_OFFSET_BOTTOM + element.offsetWidth >= width) {" + } + ] +} +``` + +### Operator Swap Comparison 005 (Rectangle.js) + +#### Run 1 + +##### Attempt 1 + +- Path: Rectangle.js +- Operation: — + +**Tool error** + +``` +Validation failed for tool "edit": + - edits: must have required property 'edits' + +Received arguments: +{ + "path": "Rectangle.js" +} +``` + +**Patch args** + +```diff +{ + "path": "Rectangle.js" +} +``` + +### Operator Swap Equality 005 (ReactDOMContainer.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ReactDOMContainer.js +- Operation: — + +**Tool error** + +``` +Validation failed for tool "edit": + - edits: must have required property 'edits' + +Received arguments: +{ + "path": "ReactDOMContainer.js" +} +``` + +**Patch args** + +```diff +{ + "path": "ReactDOMContainer.js" +} +``` + +### Operator Swap Equality 007 (ReactFlightDOMServerBrowser.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ReactFlightDOMServerBrowser.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "SB". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "ReactFlightDOMServerBrowser.js", + "edits": [ + { + "op": "set", + "tag": "SB", + "content": " debugChannelReadable !== undefined," + } + ] +} +``` + +### Operator Swap Increment Decrement 007 (ReactFiberConcurrentUpdates.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ReactFiberConcurrentUpdates.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "YJ#96". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "ReactFiberConcurrentUpdates.js", + "edits": [ + { + "op": "set", + "tag": "YJ#96", + "content": " concurrentQueues[concurrentQueuesIndex++] = update;" + } + ] +} +``` + +### Operator Swap Nullish 007 (SuspenseBreadcrumbs.js) + +#### Run 1 + +##### Attempt 1 + +- Path: SuspenseBreadcrumbs.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "JR#228". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "SuspenseBreadcrumbs.js", + "edits": [ + { + "op": "set", + "tag": "JR#228", + "content": " title={`Up to ${parent === null ? 'Unknown' : (parent.name || 'Unknown')}`}" + } + ] +} +``` + +### Regex Swap Regex Quantifier 005 (formatWithStyles.js) + +#### Run 1 + +##### Attempt 1 + +- Path: formatWithStyles.js +- Operation: — + +**Tool error** + +``` +1 line has changed since last read. Use the updated LINE#ID references shown below (>>> marks changed lines). + + 43#ZR: + 44#WQ: // Matches any of %(o|O|d|i|s|f), but not %%(o|O|d|i|s|f) +>>> 45#WN: const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; + 46#MP: if (typeof inputArgs[0] === 'string' && inputArgs[0].match(REGEXP)) { + 47#QX: return [`%c${inputArgs[0]}`, style, ...inputArgs.slice(1)]; +``` + +**Patch args** + +```diff +{ + "path": "formatWithStyles.js", + "edits": [ + { + "op": "set", + "tag": "45#NS", + "content": " const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g;" + } + ] +} +``` + +##### Attempt 2 + +- Path: formatWithStyles.js +- Operation: — + +**Tool error** + +``` +No changes made to formatWithStyles.js. The edits produced identical content. +Edit 0: replacement for 45#WN is identical to current content: + 45#WN| const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +Your content must differ from what the file already contains. Re-read the file to see the current state. +The file differs from the original fixture at these lines: +45#XV|- const REGEXP = /([^%]|^)((%%)+)(%([oOdisf]))/g; +46#WN|+ const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +``` + +**Patch args** + +```diff +{ + "path": "formatWithStyles.js", + "edits": [ + { + "op": "set", + "tag": "45#WN", + "content": " const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g;" + } + ] +} +``` + +##### Attempt 3 + +- Path: formatWithStyles.js +- Operation: — + +**Tool error** + +``` +No changes made to formatWithStyles.js. The edits produced identical content. +Edit 0: replacement for 45#WN is identical to current content: + 45#WN| const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +Your content must differ from what the file already contains. Re-read the file to see the current state. +The file differs from the original fixture at these lines: +45#XV|- const REGEXP = /([^%]|^)((%%)+)(%([oOdisf]))/g; +46#WN|+ const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +``` + +**Patch args** + +```diff +{ + "path": "formatWithStyles.js", + "edits": [ + { + "op": "set", + "tag": "45#WN", + "content": " const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g;" + } + ] +} +``` + +##### Attempt 4 + +- Path: formatWithStyles.js +- Operation: — + +**Tool error** + +``` +No changes made to formatWithStyles.js. The edits produced identical content. +Edit 0: replacement for 45#WN is identical to current content: + 45#WN| const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +Your content must differ from what the file already contains. Re-read the file to see the current state. +The file differs from the original fixture at these lines: +45#XV|- const REGEXP = /([^%]|^)((%%)+)(%([oOdisf]))/g; +46#WN|+ const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +``` + +**Patch args** + +```diff +{ + "path": "formatWithStyles.js", + "edits": [ + { + "op": "set", + "tag": "45#WN", + "content": " const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g;" + } + ] +} +``` + +##### Attempt 5 + +- Path: formatWithStyles.js +- Operation: — + +**Tool error** + +``` +No changes made to formatWithStyles.js. The edits produced identical content. +Edit 0: replacement for 45#WN is identical to current content: + 45#WN| const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +Your content must differ from what the file already contains. Re-read the file to see the current state. +The file differs from the original fixture at these lines: +45#XV|- const REGEXP = /([^%]|^)((%%)+)(%([oOdisf]))/g; +46#WN|+ const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +``` + +**Patch args** + +```diff +{ + "path": "formatWithStyles.js", + "edits": [ + { + "op": "set", + "tag": "45#WN", + "content": " const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g;" + } + ] +} +``` + +##### Attempt 6 + +- Path: formatWithStyles.js +- Operation: — + +**Tool error** + +``` +No changes made to formatWithStyles.js. The edits produced identical content. +Edit 0: replacement for 45#WN is identical to current content: + 45#WN| const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +Your content must differ from what the file already contains. Re-read the file to see the current state. +The file differs from the original fixture at these lines: +45#XV|- const REGEXP = /([^%]|^)((%%)+)(%([oOdisf]))/g; +46#WN|+ const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +``` + +**Patch args** + +```diff +{ + "path": "formatWithStyles.js", + "edits": [ + { + "op": "set", + "tag": "45#WN", + "content": " const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g;" + } + ] +} +``` + +##### Attempt 7 + +- Path: formatWithStyles.js +- Operation: — + +**Tool error** + +``` +No changes made to formatWithStyles.js. The edits produced identical content. +Edit 0: replacement for 45#WN is identical to current content: + 45#WN| const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +Your content must differ from what the file already contains. Re-read the file to see the current state. +The file differs from the original fixture at these lines: +45#XV|- const REGEXP = /([^%]|^)((%%)+)(%([oOdisf]))/g; +46#WN|+ const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g; +``` + +**Patch args** + +```diff +{ + "path": "formatWithStyles.js", + "edits": [ + { + "op": "set", + "tag": "45#WN", + "content": " const REGEXP = /([^%]|^)((%%)*)(%([ oOdisf]))/g;" + } + ] +} +``` + +### Regex Swap Regex Quantifier 007 (RunReactCompiler.ts) + +#### Run 1 + +##### Attempt 1 + +- Path: RunReactCompiler.ts +- Operation: — + +**Tool error** + +``` +No changes made to RunReactCompiler.ts. The edits produced identical content. +Edit 0: replacement for 154#ZT is identical to current content: + 154#ZT| const FLOW_SUPPRESSION_REGEX = /\$FlowFixMe\[([^\]]*)]\]/g; +Your content must differ from what the file already contains. Re-read the file to see the current state. +The file differs from the original fixture at these lines: +154#RS|-const FLOW_SUPPRESSION_REGEX = /\$FlowFixMe\[([^\]]+)\]/g; +155#KK|+const FLOW_SUPPRESSION_REGEX = /\$FlowFixMe\[([^\]]*)\]/g; +``` + +**Patch args** + +```diff +{ + "path": "RunReactCompiler.ts", + "edits": [ + { + "op": "set", + "tag": "154#ZT", + "content": "const FLOW_SUPPRESSION_REGEX = /\\$FlowFixMe\\[([^\\]]*)]\\]/g;" + } + ] +} +``` + +##### Attempt 2 + +- Path: RunReactCompiler.ts +- Operation: — + +**Tool error** + +``` +No changes made to RunReactCompiler.ts. The edits produced identical content. +Edit 0: replacement for 154#ZT is identical to current content: + 154#ZT| const FLOW_SUPPRESSION_REGEX = /\$FlowFixMe\[([^\]]*)]\]/g; +Your content must differ from what the file already contains. Re-read the file to see the current state. +The file differs from the original fixture at these lines: +154#RS|-const FLOW_SUPPRESSION_REGEX = /\$FlowFixMe\[([^\]]+)\]/g; +155#KK|+const FLOW_SUPPRESSION_REGEX = /\$FlowFixMe\[([^\]]*)\]/g; +``` + +**Patch args** + +```diff +{ + "path": "RunReactCompiler.ts", + "edits": [ + { + "op": "set", + "tag": "154#ZT", + "content": "const FLOW_SUPPRESSION_REGEX = /\\$FlowFixMe\\[([^\\]]*)]\\]/g;" + } + ] +} +``` + +### Structural Delete Statement 003 (useCanvasInteraction.js) + +#### Run 1 + +##### Attempt 1 + +- Path: useCanvasInteraction.js +- Operation: — + +**Tool error** + +``` +No changes made to useCanvasInteraction.js. The edits produced identical content. +Edit 0: replacement for 162#VY is identical to current content: + 162#VY| const onCanvasMouseDown: MouseEventHandler = (event) => { +Your content must differ from what the file already contains. Re-read the file to see the current state. +The file differs from the original fixture at these lines: +163#PQ|+ isMouseDownRef.current = true; +``` + +**Patch args** + +```diff +{ + "path": "useCanvasInteraction.js", + "edits": [ + { + "op": "set", + "tag": "162#VY", + "content": " const onCanvasMouseDown: MouseEventHandler = (event) => {\n isMouseDownRef.current = true;\n didMouseMoveWhileDownRef.current = false;" + } + ] +} +``` + +### Structural Delete Statement 007 (ReactDOMFizzStaticBrowser.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ReactDOMFizzStaticBrowser.js +- Operation: — + +**Tool error** + +``` +Validation failed for tool "edit": + - edits: must be array + +Received arguments: +{ + "path": "ReactDOMFizzStaticBrowser.js", + "edits": "[{\"op\": \"insert\", \"after\": \"71#NV: function onAllReady() {\", \"content\": \" const stream = new ReadableStream(\\n {\\n type: 'bytes',\\n pull: (controller): ?Promise => {\\n startFlowing(request, controller);\\n },\\n cancel: (reason): ?Promise => {\\n stopFlowing(request);\\n abort(request, reason);\\n },\\n },\\n // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.\\n { highWaterMark: 0 },\\n );\"]}]" +} +``` + +**Patch args** + +```diff +{ + "path": "ReactDOMFizzStaticBrowser.js", + "edits": "[{\"op\": \"insert\", \"after\": \"71#NV: function onAllReady() {\", \"content\": \" const stream = new ReadableStream(\\n {\\n type: 'bytes',\\n pull: (controller): ?Promise => {\\n startFlowing(request, controller);\\n },\\n cancel: (reason): ?Promise => {\\n stopFlowing(request);\\n abort(request, reason);\\n },\\n },\\n // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.\\n { highWaterMark: 0 },\\n );\"]}]" +} +``` + +### Structural Remove Early Return 001 (ReactFlightAsyncDispatcher.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ReactFlightAsyncDispatcher.js +- Operation: — + +**Tool error** + +``` +insert requires adjacent anchors (after 16, before 18) +``` + +**Patch args** + +```diff +{ + "path": "ReactFlightAsyncDispatcher.js", + "edits": [ + { + "op": "insert", + "after": "16#BS", + "before": "18#YZ", + "content": [ + " if (!request) {", + " return new Map();", + " }" + ] + } + ] +} +``` + +### Structural Remove Early Return 003 (ReactDOMComponentTree.js) + +#### Run 1 + +##### Attempt 1 + +- Path: ReactDOMComponentTree.js +- Operation: — + +**Tool error** + +``` +No changes made to ReactDOMComponentTree.js. The edits produced identical content. +Edit 0: replacement for 131#SH is identical to current content: + 131#SH| if (targetInst) { +Your content must differ from what the file already contains. Re-read the file to see the current state. +The file differs from the original fixture at these lines: +132#XQ|- // Don't return HostRoot, SuspenseComponent or ActivityComponent here. +133#ZB|- return targetInst; +134#VN|+ const tag = targetInst.tag; +134#VK|+ if ( +134#JK|+ tag === HostComponent || +134#BW|+ tag === HostText || +134#SB|+ tag === HostHoistable || +134#JW|+ tag === HostSingleton +134#PZ|+ ) { +134#XQ|+ // Don't return HostRoot, SuspenseComponent or ActivityComponent here. +134#ZB|+ return targetInst; +134#PN|+ } +``` + +**Patch args** + +```diff +{ + "path": "ReactDOMComponentTree.js", + "edits": [ + { + "op": "set", + "tag": "131#SH", + "content": " if (targetInst) {\n const tag = targetInst.tag;\n if (\n tag === HostComponent ||\n tag === HostText ||\n tag === HostHoistable ||\n tag === HostSingleton\n ) {\n // Don't return HostRoot, SuspenseComponent or ActivityComponent here." + } + ] +} +``` + +### Structural Remove Early Return 007 (CommitTreeBuilder.js) + +#### Run 1 + +##### Attempt 1 + +- Path: CommitTreeBuilder.js +- Operation: — + +**Tool error** + +``` +Invalid line reference "MK: // No-op". Expected format "LINE#ID" (e.g. "5#aa"). +``` + +**Patch args** + +```diff +{ + "path": "CommitTreeBuilder.js", + "edits": [ + { + "op": "set", + "tag": "MK: // No-op", + "content": [ + " // No-op", + " continue;" + ] + } + ] +} +``` + +## Failed Tasks (0% passing) + +### 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):** +``` +Now I need to look for the bug. The task mentions that "optional chaining was removed from a property access." Let me search for lines where optional chaining (`?.`) should be present but isn't. Looking at the code, I can see a few patterns: + +1. Line 61: `port?.onMessage.removeListener(bridgeListener);` - has optional chaining +2. Line 438: `bridge?.shutdown();` - has optional chaining +3. Line 463: `bridge?.shutdown();` - has optional chaining +4. Line 476: `port?.disconnect();` - has optional ... +``` + +**Diff (expected vs actual):** +```diff +@@ -64 @@ + }, + + send(event: string, payload: any, transferable?: Array) { +- port?.postMessage({ event, payload }, transferable); ++ port.postMessage({ event, payload }, transferable); + }, + }); + +``` + +### 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):** +``` +Perfect! I found and fixed the bug! Line 391 was checking for the wrong hook name. It was checking for `markPassiveEffectsStopped` when it should have been checking for `markPassiveEffectsStarted`. + +This is indeed a duplicated line (from line 380) with a subtle LITERAL change (the function name). The original copy-paste error introduced the wrong function name, and my fix changed it back to the correct one: `markPassiveEffectsStarted`. + +The fixed code now correctly checks that the `markPassiv... +``` + +**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) { +@@ -388 @@ + if (enableSchedulingProfiler) { + if ( + injectedProfilingHooks !== null && +- typeof injectedProfilingHooks.markPassiveEffectsStopped === 'function' ++ typeof injectedProfilingHooks.markPassiveEffectsStarted === 'function' + ) { + injectedProfilingHooks.markPassiveEffectsStopped(); + } +``` + +### Import Swap Named Imports 003 (index.js) — 0/1 + +**All runs failed with same error:** File mismatch for index.js + +**Sample agent response (run 1):** +``` +Perfect. The fix has been applied. The two swapped named imports `useState` and `useDebugValue` have been corrected. The import statement now reads: + +```javascript +import { useState, useDebugValue, Fragment } from 'react'; +``` + +Instead of the buggy version: +```javascript +import { useDebugValue, Fragment, useState } from 'react'; +``` +``` + +**Diff (expected vs actual):** +```diff +@@ -8 @@ + */ + + import * as React from 'react'; +-import { Fragment, useDebugValue, useState } from 'react'; ++import { useState, useDebugValue, Fragment } from 'react'; + + const div = document.createElement('div'); + const exampleFunction = () => {}; +``` + +### 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):** +``` +The fix is complete. Line 13 now correctly imports `useMemo` instead of `useSyncExternalStore`. This fixes the bug where the two named imports were swapped - `useMemo` is the function that's actually being used in the code (on line 68), while `useSyncExternalStore` was incorrectly listed in the imports. +``` + +**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 { useMemo, useContext, useCallback } from 'react'; + import { TreeStateContext } from './TreeContext'; + import { BridgeContext, StoreContext, OptionsContext } from '../context'; + import Button from '../Button'; +``` + +### Literal Flip Boolean 001 (ReactDOMLegacyServerImpl.js) — 0/1 + +**All runs failed with same error:** File mismatch for ReactDOMLegacyServerImpl.js + +**Sample agent response (run 1):** +``` +Perfect. The boolean literal on line 45 (within the function that starts on line 41) has been flipped from `true` to `false`. The fix is complete and verified. +``` + +**Diff (expected vs actual):** +```diff +@@ -33 @@ + generateStaticMarkup: boolean, + abortReason: string, + ): string { +- let didFatal = false; ++ let didFatal = true; + let fatalError = null; + let result = ''; + const destination = { +@@ -42 @@ + if (chunk !== null) { + result += chunk; + } +- return true; ++ return false; + }, + // $FlowFixMe[missing-local-annot] + destroy(error) { +``` + +### Operator Remove Negation 003 (ReactDOMSelection.js) — 0/1 + +**All runs failed with same error:** File mismatch for ReactDOMSelection.js + +**Sample agent response (run 1):** +``` +Perfect. The fix has been applied correctly. The logical negation operator `!` has been restored at line 106. The condition now reads: + +```javascript +if (!(next = node.nextSibling)) { +``` + +This properly checks if `nextSibling` is null/falsy before breaking out of the inner while loop, which aligns with the intended control flow logic where we break when there is no next sibling to move to. +``` + +**Diff (expected vs actual):** +```diff +@@ -103 @@ + if (parentNode === focusNode && ++indexWithinFocus === focusOffset) { + end = length; + } +- if ((next = node.nextSibling) !== null) { ++ if (!(next = node.nextSibling)) { + break; + } + node = parentNode; +@@ -156 @@ + + // IE 11 uses modern selection, but doesn't support the extend method. + // Flip backward selections, so we can set with a single range. +- if (!selection.extend && start > end) { ++ if (selection.extend && start > end) { + const temp = end; + end = start; + start = temp; +``` + +### Operator Remove Negation 005 (ReactDOMContainer.js) — 0/1 + +**All runs failed with same error:** File mismatch for ReactDOMContainer.js + +**Sample agent response (run 1):** +``` +Perfect. The fix is applied. Line 16 now correctly reads `node &&` instead of `!node &&`, which allows the subsequent checks of `node.nodeType` to make logical sense. The function now: + +1. Returns true only if `node` exists (truthy) +2. AND the node has one of the valid nodeTypes: ELEMENT_NODE, DOCUMENT_NODE, DOCUMENT_FRAGMENT_NODE, or (if comments are enabled) COMMENT_NODE + +The bug was the incorrect negation that would have rejected all valid nodes and tried to access properties on null/undef... +``` + +**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 !!( ++ return ( + node && + (node.nodeType === ELEMENT_NODE || + node.nodeType === DOCUMENT_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):** +``` +Perfect. The fix has been applied. Line 36 now reads `{dismissError && (` instead of `{dismissError !== null && (`. This is the correct boolean operator usage—checking if `dismissError` is truthy (i.e., a function exists) rather than explicitly checking if it's not null. The change is minimal and only affects the target line as required. +``` + +**Diff (expected vs actual):** +```diff +@@ -32 @@ + {children} +
+
+-
Uncaught Error: {errorMessage || ''}
+- {dismissError !== null && ( ++
Uncaught Error: {errorMessage && ''}
++ {dismissError && ( + +``` + +### Regex Swap Regex Quantifier 007 (RunReactCompiler.ts) — 0/1 + +**All runs failed with same error:** File mismatch for RunReactCompiler.ts + +**Diff (expected vs actual):** +```diff +@@ -151 @@ + filename: string; + userOpts: PluginOptions; + }; +-const FLOW_SUPPRESSION_REGEX = /\$FlowFixMe\[([^\]]*)\]/g; ++const FLOW_SUPPRESSION_REGEX = /\\$FlowFixMe\\s+\\[([^\\]]+)\\]/g; + + function getFlowSuppressions(sourceCode: SourceCode): Array<{ line: number; code: string }> { + const comments = sourceCode.getAllComments(); +``` + +### Structural Delete Statement 001 (useExtensionComponentsPanelVisibility.js) — 0/1 + +**All runs failed with same error:** File mismatch for useExtensionComponentsPanelVisibility.js + +**Diff (expected vs actual):** +```diff +@@ -13 @@ + // Events that are prefixed with `extension` will only be emitted for the browser extension implementation. + // For other implementations, this hook will just return constant `true` value. + export function useExtensionComponentsPanelVisibility(bridge: FrontendBridge): boolean { +- const [isVisible, setIsVisible] = useState(true); +- + useEffect(() => { + function onPanelShown() { + setIsVisible(true); +``` + +### Structural Delete Statement 005 (StackTraceView.js) — 0/1 + +**All runs failed with same error:** File mismatch for StackTraceView.js + +**Sample agent response (run 1):** +``` +I've restored the missing `ignored` variable definition in `CallSiteView` by extracting it from `symbolicatedCallSite`. This fixes the `undefined` reference at the `className` assignment on line 61. + +```javascript + const [functionName, url, line, column] = + symbolicatedCallSite !== null ? symbolicatedCallSite.location : callSite; + const ignored = symbolicatedCallSite !== null && symbolicatedCallSite.ignored; +``` +``` + +**Diff (expected vs actual):** +```diff +@@ -49 @@ + ); + const [functionName, url, line, column] = + symbolicatedCallSite !== null ? symbolicatedCallSite.location : callSite; +- const ignored = symbolicatedCallSite !== null ? symbolicatedCallSite.ignored : false; ++ const ignored = symbolicatedCallSite !== null && symbolicatedCallSite.ignored; ++ + // 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 + // multiple buttons in a single inspection taking up space. +``` + +### Structural Delete Statement 007 (ReactDOMFizzStaticBrowser.js) — 0/1 + +**All runs failed with same error:** File mismatch for ReactDOMFizzStaticBrowser.js + +**Sample agent response (run 1):** +``` +I have fixed the bug in `ReactDOMFizzStaticBrowser.js` where a critical block of code was missing in the `prerender` function. The missing `ReadableStream` initialization, which is required for the `StaticResult.prelude`, has been restored to match the implementation pattern used in `resumeAndPrerender`. + +```javascript +// Function: prerender +// Location: inside onAllReady() callback + const stream = new ReadableStream( + { + type: 'bytes', + pull: (controller): ?Prom... +``` + +**Diff (expected vs actual):** +```diff +@@ -83 @@ + // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. + { highWaterMark: 0 }, + ); +- + const result: StaticResult = { + postponed: getPostponedState(request), + prelude: stream, +``` + +### 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):** +``` +A bug was fixed in `ReactDOMComponentTree.js` where a guard clause within `getClosestInstanceFromNode` had been removed. This clause ensures that if an internal fiber instance is found for a hydration boundary, it is returned immediately. + +```javascript + const targetFiber = enableInternalInstanceMap + ? internalInstanceMap.get(hydrationInstance) + : // $FlowFixMe[prop-missing] + hydrationInstance[internalInstanceKey]; + + if (targetFiber) { + ... +``` + +**Diff (expected vs actual):** +```diff +@@ -185 @@ + ? internalInstanceMap.get(hydrationInstance) + : // $FlowFixMe[prop-missing] + hydrationInstance[internalInstanceKey]; ++ + if (targetFiber) { + return targetFiber; + } + 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 + +**Diff (expected vs actual):** +```diff +@@ -90 @@ + + {tabs.map((tab) => { + if (tab === null) { +- return
; ++ 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):** +``` +I've restored the missing guard clause in `CommitTreeBuilder.js` within the `TREE_OPERATION_UPDATE_TREE_BASE_DURATION` operation. This ensures that if a node ID is not found in the current tree (a possible scenario during updates), the increment of the operation pointer `i` is still handled correctly before exiting the case, preventing the builder from getting into an inconsistent state. + +```javascript + case TREE_OPERATION_UPDATE_TREE_BASE_DURATION: { + id = operations[i + 1]; + ... +``` + +**Diff (expected vs actual):** +```diff +@@ -63 @@ + } + + const commitTrees = ((rootToCommitTreeMap.get(rootID): any): Array); +- if (commitIndex < commitTrees.length) { +- return commitTrees[commitIndex]; +- } + + const { profilingData } = profilerStore; + if (profilingData === null) { +@@ -145 @@ + } + + function updateTree(commitTree: CommitTree, operations: Array): CommitTree { +- // Clone the original tree so edits don't affect it. + const nodes = new Map(commitTree.nodes); + + // Clone nodes before mutating them so edits don't affect them. +@@ -323 @@ + } + case TREE_OPERATION_UPDATE_TREE_BASE_DURATION: { + id = operations[i + 1]; ++ if (!nodes.has(id)) { ++ i += 3; ++ break; ++ } + + const node = getClonedNode(id); + node.treeBaseDuration = operations[i + 2] / 1000; // Convert microseconds back to milliseconds; +``` + +### 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):** +``` +Fixed the bug in `ReactFlightClientConfigBundlerWebpack.js` where `thenable.then(resolve, ...)` was called before `chunkCache.set(chunkId, thenable)`. This sequence created a race condition: if the chunk loaded synchronously, the `resolve` callback (which performs `chunkCache.set(chunkId, null)`) would execute immediately, followed by the original `chunkCache.set(chunkId, thenable)`, incorrectly leaving a pending `thenable` in the cache instead of `null`. + +The statements have been reordered t... +``` + +**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 + +**Sample agent response (run 1):** +``` +The bug in `utils.js` was identified where the logic for handling `sourceURL` starting with `file:///` was swapped with the general URL handling logic. + +I have corrected this by swapping the branch bodies: +- When `sourceURL` starts with `file:///`, `filePath` is now assigned the original `sourceURL`. +- When `sourceURL` includes `://` (but doesn't start with `file:///`), `filePath` is now derived from the `pathname` of the URL. + +```javascript + // Check if sourceURL is a correct URL, which h... +``` + +**Diff (expected vs actual):** +```diff +@@ -21 @@ + + // Check if sourceURL is a correct URL, which has a protocol specified + if (sourceURL.startsWith('file:///')) { +- filePath = new URL(sourceURL).pathname; ++ filePath = sourceURL; + } else if (sourceURL.includes('://')) { + } else if (sourceURL.includes('://')) { +- // $FlowFixMe[cannot-resolve-name] ++ filePath = new URL(sourceURL).pathname; ++ } 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 }; ++ return { ++ url: null, ++ shouldDisableButton: true, ++ }; + } else { + const endOfSourceMapURLPattern = '.js/'; + const endOfSourceMapURLIndex = sourceURL.lastIndexOf(endOfSourceMapURLPattern); + } else { + 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, +@@ -40 @@ + ); + } + } +- } 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):** +``` +I've fixed the bug in `ReactDOMFloat.js` where the `if` and `else` branches within the `preinitModule` function's development warning block were swapped. + +The logic now correctly checks if any validation errors were `encountered` (such as a missing or invalid `href` or `options` object) and logs a generic error message. If no such errors occurred, it proceeds to the `else` block to validate the `as` property specifically. + +```javascript +// Before + if (encountered) { + const as = option... +``` + +**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( +``` + +### Structural Swap If Else 005 (ReactClientConsoleConfigPlain.js) — 0/1 + +**All runs failed with same error:** File mismatch for ReactClientConsoleConfigPlain.js + +**Diff (expected vs actual):** +```diff +@@ -32 @@ + + const newArgs = args.slice(0); + if (typeof newArgs[offset] === 'string') { +- newArgs.splice(offset, 1, badgeFormat + ' ' + newArgs[offset], pad + badgeName + pad); +- } else { + newArgs.splice(offset, 0, badgeFormat, pad + badgeName + pad); + newArgs.splice(offset, 0, badgeFormat, pad + badgeName + pad); ++ } else { ++ newArgs.splice(offset, 1, badgeFormat + ' ' + newArgs[offset], pad + badgeName + pad); + } + + // The "this" binding in the "bind"; +```