diff --git a/packages/core/src/patch.ts b/packages/core/src/patch.ts index d4c4ab5cf2..9fa4791a59 100644 --- a/packages/core/src/patch.ts +++ b/packages/core/src/patch.ts @@ -1,5 +1,26 @@ export * as Patch from "./patch" +import { Result, Schema } from "effect" + +export class BoundaryError extends Schema.TaggedErrorClass()("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()("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 { +export function parse(patchText: string): Result.Result, 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 { 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, original: string): FileUpdate { diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index e4f47739a9..02574f000a 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -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") { diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts index c8124c0377..a39a4d067d 100644 --- a/packages/core/test/patch.test.ts +++ b/packages/core/test/patch.test.ts @@ -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("< { }) 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" }, ]) })