fix(core): enforce patch EOF anchors

This commit is contained in:
Aiden Cline
2026-07-20 23:47:39 -05:00
parent e770415fd5
commit 7b1bffd9d7
3 changed files with 58 additions and 7 deletions
+12 -4
View File
@@ -157,6 +157,10 @@ function parseUpdate(lines: ReadonlyArray<string>, start: number, end: number) {
else if (line.startsWith("+")) newLines.push(line.slice(1))
index++
}
if (lines[index]?.trim() === "*** End of File") {
endOfFile = true
index++
}
chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined })
}
return { chunks, next: index }
@@ -192,11 +196,15 @@ function computeReplacements(lines: ReadonlyArray<string>, path: string, chunks:
function seek(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, start: number, eof = false) {
if (pattern.length === 0) return -1
for (const compare of [exact, rstrip, trim, normalized]) {
if (eof) {
const offset = lines.length - pattern.length
if (offset >= start && matches(lines, pattern, offset, compare)) return offset
if (eof) {
const offset = lines.length - pattern.length
if (offset < start) return -1
for (const compare of [exact, rstrip, trim, normalized]) {
if (matches(lines, pattern, offset, compare)) return offset
}
return -1
}
for (const compare of [exact, rstrip, trim, normalized]) {
for (let offset = start; offset <= lines.length - pattern.length; offset++) {
if (matches(lines, pattern, offset, compare)) return offset
}
+25
View File
@@ -113,6 +113,21 @@ describe("Patch", () => {
])
})
test("preserves the end-of-file marker", () => {
expect(
parse(
"*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch",
),
).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: [], newLines: ["quux"], changeContext: undefined, endOfFile: true }],
},
])
})
test("derives fuzzy line updates while preserving BOM", () => {
const update = Patch.derive("update.txt", [{ oldLines: [" old "], newLines: ["new"] }], "\uFEFFold\n")
expect(update).toEqual({ content: "new\n", bom: true })
@@ -174,6 +189,16 @@ describe("Patch", () => {
).toBe("marker\nmiddle\nmarker changed\nend\n")
})
test("does not fall back to a non-EOF match", () => {
expect(() =>
Patch.derive(
"update.txt",
[{ oldLines: ["marker", "end"], newLines: ["changed", "end"], endOfFile: true }],
"marker\nend\nmiddle\n",
),
).toThrow("Failed to find expected lines")
})
test("matches V1 lenient parsing of malformed hunk bodies", () => {
expect(parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toEqual([
{ type: "add", path: "add.txt", contents: "" },
+21 -3
View File
@@ -392,14 +392,32 @@ describe("PatchTool", () => {
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "tail.txt")
yield* Effect.promise(() => fs.writeFile(target, "alpha\nlast\n"))
yield* Effect.promise(() => fs.writeFile(target, "first\nsecond"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch",
"*** Begin Patch\n*** Update File: tail.txt\n@@\n first\n-second\n+second updated\n*** End of File\n*** End Patch",
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("alpha\nend\n")
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first\nsecond updated\n")
}),
),
)
it.live("applies an end-of-file chunk to the final duplicate", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "duplicates.txt")
yield* Effect.promise(() => fs.writeFile(target, "marker\nend\nmiddle\nmarker\nend\n"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: duplicates.txt\n@@\n-marker\n-end\n+marker changed\n+end\n*** End of File\n*** End Patch",
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(
"marker\nend\nmiddle\nmarker changed\nend\n",
)
}),
),
)