mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
8ce1e18d2f
* fix(agents): apply_patch rewrites bytes on hunk context lines A fuzzy apply_patch update replaced the entire matched span with the model-authored patch text, so trailing whitespace, typographic punctuation, and tab indentation on lines the hunk marked as context were overwritten while the tool reported plain success. The parser now records which emitted lines came in as context and which old line each one came from, and the update applier keeps the file's own bytes for those lines. Added and removed lines are still written from the patch. * test(agents): cover apply_patch context preservation --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
222 lines
7.0 KiB
TypeScript
222 lines
7.0 KiB
TypeScript
/**
|
|
* Update-hunk application for the apply_patch parser.
|
|
* Locates expected old lines with tolerant matching, applies chunks in order,
|
|
* and returns normalized file contents with a trailing newline.
|
|
*/
|
|
import fs from "node:fs/promises";
|
|
import { formatErrorMessage } from "../infra/errors.js";
|
|
import { hasOnlyCrlfLineEndings, normalizeToLF, restoreLineEndings } from "./line-endings.js";
|
|
|
|
const DASH_PUNCTUATION = /[\u2010-\u2015\u2212]/g;
|
|
const SINGLE_QUOTE_PUNCTUATION = /[\u2018-\u201B]/g;
|
|
const DOUBLE_QUOTE_PUNCTUATION = /[\u201C-\u201F]/g;
|
|
const SPACE_PUNCTUATION = /[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g;
|
|
|
|
type UpdateFileChunk = {
|
|
changeContext?: string;
|
|
oldLines: string[];
|
|
newLines: string[];
|
|
contextOldIndexes: Array<number | undefined>;
|
|
isEndOfFile: boolean;
|
|
};
|
|
|
|
async function defaultReadFile(filePath: string): Promise<string> {
|
|
return fs.readFile(filePath, "utf8");
|
|
}
|
|
|
|
/** Apply parsed update chunks to one file and return the new file contents. */
|
|
export async function applyUpdateHunk(
|
|
filePath: string,
|
|
chunks: UpdateFileChunk[],
|
|
options?: { readFile?: (filePath: string) => Promise<string> },
|
|
): Promise<string> {
|
|
const reader = options?.readFile ?? defaultReadFile;
|
|
const originalContents = await reader(filePath).catch((err: unknown) => {
|
|
throw new Error(`Failed to read file to update ${filePath}: ${formatErrorMessage(err)}`);
|
|
});
|
|
|
|
// Normalizing mixed endings would rewrite untouched lines across the file.
|
|
// Keep the existing localized behavior unless every terminator is CRLF.
|
|
const preserveCrlf = hasOnlyCrlfLineEndings(originalContents);
|
|
const matchingContents = preserveCrlf ? normalizeToLF(originalContents) : originalContents;
|
|
const originalLines = matchingContents.split("\n");
|
|
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
|
|
originalLines.pop();
|
|
}
|
|
|
|
const replacements = computeReplacements(originalLines, filePath, chunks);
|
|
let newLines = applyReplacements(originalLines, replacements);
|
|
if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
|
|
newLines = [...newLines, ""];
|
|
}
|
|
const updatedContents = newLines.join("\n");
|
|
return preserveCrlf ? restoreLineEndings(updatedContents, "\r\n") : updatedContents;
|
|
}
|
|
|
|
function computeReplacements(
|
|
originalLines: string[],
|
|
filePath: string,
|
|
chunks: UpdateFileChunk[],
|
|
): Array<[number, number, string[]]> {
|
|
const replacements: Array<[number, number, string[]]> = [];
|
|
let lineIndex = 0;
|
|
|
|
for (const chunk of chunks) {
|
|
if (chunk.changeContext) {
|
|
const ctxIndex = seekSequence(originalLines, [chunk.changeContext], lineIndex, false);
|
|
if (ctxIndex === null) {
|
|
throw new Error(`Failed to find context '${chunk.changeContext}' in ${filePath}`);
|
|
}
|
|
lineIndex = ctxIndex + 1;
|
|
}
|
|
|
|
if (chunk.oldLines.length === 0) {
|
|
const insertionIndex =
|
|
chunk.changeContext && !chunk.isEndOfFile
|
|
? lineIndex
|
|
: originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
|
|
? originalLines.length - 1
|
|
: originalLines.length;
|
|
replacements.push([insertionIndex, 0, chunk.newLines]);
|
|
lineIndex = insertionIndex;
|
|
continue;
|
|
}
|
|
|
|
let pattern = chunk.oldLines;
|
|
let newSlice = chunk.newLines;
|
|
let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
|
|
|
|
if (found === null && pattern[pattern.length - 1] === "") {
|
|
// Parsed hunks may carry an EOF sentinel as a blank trailing line. Retry
|
|
// without it so equivalent file contents still match.
|
|
pattern = pattern.slice(0, -1);
|
|
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
|
|
newSlice = newSlice.slice(0, -1);
|
|
}
|
|
found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
|
|
}
|
|
|
|
if (found === null) {
|
|
throw new Error(
|
|
`Failed to find expected lines in ${filePath}:\n${chunk.oldLines.join("\n")}`,
|
|
);
|
|
}
|
|
|
|
replacements.push([
|
|
found,
|
|
pattern.length,
|
|
keepContextBytes({
|
|
originalLines,
|
|
matchIndex: found,
|
|
patternLength: pattern.length,
|
|
newSlice,
|
|
contextOldIndexes: chunk.contextOldIndexes,
|
|
}),
|
|
]);
|
|
lineIndex = found + pattern.length;
|
|
}
|
|
|
|
replacements.sort((a, b) => a[0] - b[0]);
|
|
return replacements;
|
|
}
|
|
|
|
function keepContextBytes(params: {
|
|
originalLines: string[];
|
|
matchIndex: number;
|
|
patternLength: number;
|
|
newSlice: string[];
|
|
contextOldIndexes: Array<number | undefined>;
|
|
}): string[] {
|
|
const { originalLines, matchIndex, patternLength, newSlice, contextOldIndexes } = params;
|
|
return newSlice.map((line, index) => {
|
|
const oldIndex = contextOldIndexes.at(index);
|
|
if (oldIndex === undefined || oldIndex >= patternLength) {
|
|
return line;
|
|
}
|
|
return originalLines.at(matchIndex + oldIndex) ?? line;
|
|
});
|
|
}
|
|
|
|
function applyReplacements(
|
|
lines: string[],
|
|
replacements: Array<[number, number, string[]]>,
|
|
): string[] {
|
|
const result = [...lines];
|
|
// Apply from the end of the file backward so earlier replacement indexes stay
|
|
// stable while later replacements mutate the array.
|
|
for (const [startIndex, oldLen, newLines] of [...replacements].toReversed()) {
|
|
for (let i = 0; i < oldLen; i += 1) {
|
|
if (startIndex < result.length) {
|
|
result.splice(startIndex, 1);
|
|
}
|
|
}
|
|
for (const [i, line] of newLines.entries()) {
|
|
result.splice(startIndex + i, 0, line);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function seekSequence(
|
|
lines: string[],
|
|
pattern: string[],
|
|
start: number,
|
|
eof: boolean,
|
|
): number | null {
|
|
if (pattern.length === 0) {
|
|
return start;
|
|
}
|
|
if (pattern.length > lines.length) {
|
|
return null;
|
|
}
|
|
|
|
const maxStart = lines.length - pattern.length;
|
|
const searchStart = eof && lines.length >= pattern.length ? maxStart : start;
|
|
if (searchStart > maxStart) {
|
|
return null;
|
|
}
|
|
|
|
// Fall back through increasingly tolerant comparisons. This preserves normal
|
|
// exact matching while accepting whitespace/punctuation differences common in
|
|
// generated patch text.
|
|
const normalizers = [
|
|
(value: string) => value,
|
|
(value: string) => value.trimEnd(),
|
|
(value: string) => value.trim(),
|
|
(value: string) => normalizePunctuation(value.trim()),
|
|
];
|
|
for (const normalize of normalizers) {
|
|
for (let i = searchStart; i <= maxStart; i += 1) {
|
|
if (linesMatch(lines, pattern, i, normalize)) {
|
|
return i;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function linesMatch(
|
|
lines: string[],
|
|
pattern: string[],
|
|
start: number,
|
|
normalize: (value: string) => string,
|
|
): boolean {
|
|
for (let idx = 0; idx < pattern.length; idx += 1) {
|
|
const line = lines.at(start + idx);
|
|
const expected = pattern.at(idx);
|
|
if (line === undefined || expected === undefined || normalize(line) !== normalize(expected)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function normalizePunctuation(value: string): string {
|
|
return value
|
|
.replace(DASH_PUNCTUATION, "-")
|
|
.replace(SINGLE_QUOTE_PUNCTUATION, "'")
|
|
.replace(DOUBLE_QUOTE_PUNCTUATION, '"')
|
|
.replace(SPACE_PUNCTUATION, " ");
|
|
}
|