feat(coding-agent/patch): enhanced hashline edits to strip prefixes and preserve whitespace-only changes

- Added automatic stripping of `LINE:HASH|` display prefixes and unified-diff `+` markers from replacement content in hashline edits.
- Enhanced replace edits to preserve original whitespace on lines where only whitespace differs, preventing spurious formatting diffs when models reformat code.
- Added helper functions `stripNewLinePrefixes()`, `preserveWhitespaceOnlyLines()`, and `equalsIgnoringWhitespace()` to improve edit robustness.
This commit is contained in:
can1357
2026-02-10 17:17:26 +01:00
parent 20663d0e0d
commit 2f81da0bec
16 changed files with 38697 additions and 11 deletions
+84 -6
View File
@@ -27,6 +27,81 @@ function toArray(v: string | string[]): string[] {
return [v];
}
/** Pattern matching hashline display format: `LINE:HASH| CONTENT` */
const HASHLINE_PREFIX_RE = /^\d+:[0-9a-fA-F]{1,16}\| /;
/** Pattern matching a unified-diff `+` prefix (but not `++`) */
const DIFF_PLUS_RE = /^\+(?!\+)/;
/**
* Compare two strings ignoring all whitespace differences.
*
* Returns true when the non-whitespace characters are identical — meaning
* the only differences are in spaces, tabs, or other whitespace.
*/
function equalsIgnoringWhitespace(a: string, b: string): boolean {
// Fast path: identical strings
if (a === b) return true;
// Compare with all whitespace removed
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "");
}
/**
* For replace edits (N old → N new), preserve original content on lines where
* the only difference is whitespace.
*
* Models frequently reformat code (e.g., removing spaces inside import braces)
* when making targeted edits. This detects lines that changed only in
* whitespace and keeps the original, preventing spurious formatting diffs.
*/
function preserveWhitespaceOnlyLines(oldLines: string[], newLines: string[]): string[] {
if (oldLines.length !== newLines.length) return newLines;
let anyPreserved = false;
const result = new Array<string>(newLines.length);
for (let i = 0; i < newLines.length; i++) {
if (oldLines[i] !== newLines[i] && equalsIgnoringWhitespace(oldLines[i], newLines[i])) {
result[i] = oldLines[i];
anyPreserved = true;
} else {
result[i] = newLines[i];
}
}
return anyPreserved ? result : newLines;
}
/**
* Strip hashline display prefixes and diff `+` markers from replacement lines.
*
* Models frequently copy the `LINE:HASH| ` 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 HASH_LEN = 2;
const HASH_MASK = BigInt((1 << (HASH_LEN * 4)) - 1);
@@ -176,10 +251,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
}
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
if (actualHash !== ref.hash.toLowerCase()) {
throw new HashlineMismatchError(
[{ line: ref.line, expected: ref.hash, actual: actualHash }],
fileLines,
);
throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
}
}
@@ -212,7 +284,11 @@ export function applyHashlineEdits(
let firstChangedLine: number | undefined;
// Normalize string → string[] for old/new fields
const normalized = edits.map(e => ({ old: toArray(e.old), new: toArray(e.new), after: e.after }));
const normalized = edits.map(e => ({
old: toArray(e.old),
new: stripNewLinePrefixes(toArray(e.new)),
after: e.after,
}));
// Pre-validate all line refs and collect hash mismatches in one pass.
// Structural errors (out of range, malformed, non-consecutive) still throw immediately.
@@ -278,7 +354,9 @@ export function applyHashlineEdits(
const count = endLine - startLine + 1;
// Splice: remove `count` lines starting at startLine-1, insert new
fileLines.splice(startLine - 1, count, ...edit.new);
const origLines = fileLines.slice(startLine - 1, startLine - 1 + count);
const newLines = preserveWhitespaceOnlyLines(origLines, edit.new);
fileLines.splice(startLine - 1, count, ...newLines);
trackFirstChanged(startLine);
}