refactor(core): type patch parse errors

This commit is contained in:
Aiden Cline
2026-07-20 19:03:17 -05:00
parent 9c389780c0
commit f6cbd7901b
3 changed files with 44 additions and 22 deletions
+27 -6
View File
@@ -1,5 +1,26 @@
export * as Patch from "./patch"
import { Result, Schema } from "effect"
export class BoundaryError extends Schema.TaggedErrorClass<BoundaryError>()("Patch.BoundaryError", {
boundary: Schema.Literals(["first", "last"]),
}) {
override get message() {
return `The ${this.boundary} line of the patch must be '${this.boundary === "first" ? "*** Begin Patch" : "*** End Patch"}'`
}
}
export class InvalidHunkError extends Schema.TaggedErrorClass<InvalidHunkError>()("Patch.InvalidHunkError", {
line: Schema.String,
lineNumber: Schema.Number,
}) {
override get message() {
return `Invalid hunk at line ${this.lineNumber}: '${this.line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`
}
}
export type ParseError = BoundaryError | InvalidHunkError
export type Hunk =
| { readonly type: "add"; readonly path: string; readonly contents: string }
| { readonly type: "delete"; readonly path: string }
@@ -22,12 +43,12 @@ export interface FileUpdate {
readonly bom: boolean
}
export function parse(patchText: string): ReadonlyArray<Hunk> {
export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, ParseError> {
const lines = stripHeredoc(patchText.trim()).split("\n")
const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
const end = lines.findIndex((line) => line.trim() === "*** End Patch")
if (begin === -1) throw new Error("The first line of the patch must be '*** Begin Patch'")
if (end === -1 || begin >= end) throw new Error("The last line of the patch must be '*** End Patch'")
if (begin === -1) return Result.fail(new BoundaryError({ boundary: "first" }))
if (end === -1 || begin >= end) return Result.fail(new BoundaryError({ boundary: "last" }))
const hunks: Hunk[] = []
let index = begin + 1
@@ -76,12 +97,12 @@ export function parse(patchText: string): ReadonlyArray<Hunk> {
if (hunks.length === 0) {
const invalid = lines.findIndex((line, index) => index > begin && index < end && line.trim() !== "")
if (invalid !== -1) {
throw new Error(
`Invalid hunk at line ${invalid + 1}: '${lines[invalid]!.trim()}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`,
return Result.fail(
new InvalidHunkError({ line: lines[invalid]!.trim(), lineNumber: invalid + 1 }),
)
}
}
return hunks
return Result.succeed(hunks)
}
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
+5 -7
View File
@@ -97,13 +97,11 @@ export const Plugin = {
callID: context.callID,
}
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.try({
try: () => Patch.parse(input.patchText),
catch: (cause) =>
new ToolFailure({
message: `patch verification failed: ${cause instanceof Error ? cause.message : String(cause)}`,
}),
})
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
),
)
if (hunks.length === 0) {
const normalized = input.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
+12 -9
View File
@@ -1,10 +1,13 @@
import { describe, expect, test } from "bun:test"
import { Patch } from "@opencode-ai/core/patch"
import { Result } from "effect"
const parse = (input: string) => Result.getOrThrow(Patch.parse(input))
describe("Patch", () => {
test("parses add, update, and delete hunks", () => {
expect(
Patch.parse(
parse(
"*** Begin Patch\n*** Add File: add.txt\n+added\n*** Update File: update.txt\n@@ section\n-old\n+new\n*** Delete File: delete.txt\n*** End Patch",
),
).toEqual([
@@ -21,7 +24,7 @@ describe("Patch", () => {
test("parses a file move", () => {
expect(
Patch.parse(
parse(
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch",
),
).toEqual([
@@ -35,22 +38,22 @@ describe("Patch", () => {
})
test("identifies the missing patch boundary", () => {
expect(() => Patch.parse("This is not a valid patch")).toThrow(
expect(() => parse("This is not a valid patch")).toThrow(
"The first line of the patch must be '*** Begin Patch'",
)
expect(() => Patch.parse("*** Begin Patch\n*** Add File: add.txt\n+added")).toThrow(
expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added")).toThrow(
"The last line of the patch must be '*** End Patch'",
)
})
test("strips a heredoc wrapper", () => {
expect(Patch.parse("cat <<'EOF'\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
expect(parse("cat <<'EOF'\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
{ type: "add", path: "add.txt", contents: "added" },
])
})
test("strips a heredoc wrapper without cat", () => {
expect(Patch.parse("<<EOF\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
expect(parse("<<EOF\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
{ type: "add", path: "add.txt", contents: "added" },
])
})
@@ -117,13 +120,13 @@ describe("Patch", () => {
})
test("matches V1 lenient parsing of malformed hunk bodies", () => {
expect(Patch.parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toEqual([
expect(parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toEqual([
{ type: "add", path: "add.txt", contents: "" },
])
expect(Patch.parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toEqual([
expect(parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toEqual([
{ type: "update", path: "update.txt", movePath: undefined, chunks: [] },
])
expect(Patch.parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toEqual([
expect(parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toEqual([
{ type: "delete", path: "delete.txt" },
])
})