From 688badbe5e9435fd2ba44ceaab69bf8f41c57f21 Mon Sep 17 00:00:00 2001 From: Yash Raj Pandey Date: Wed, 2 Sep 2026 12:25:54 -0400 Subject: [PATCH] fix(edit): reject an edit whose strings differ only in line ending style The identity guard runs on the raw input. The tool then converts both strings to the file's line ending 36 lines later. Strings that differ only in line ending style therefore pass the guard and become the same text. The tool reported "Edited file successfully" and "Replacements: 1" with an empty patch, and the file did not change. Run the same check again after the conversion. The v1 tool already does this: packages/opencode/src/tool/edit.ts:683 holds the guard inside replace(), which its caller reaches with both strings already converted. --- packages/core/src/tool/edit.ts | 5 ++++ packages/core/test/tool-edit.test.ts | 35 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index f0bdb488a060..1d588bc4af85 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -162,6 +162,11 @@ const layer = Layer.effectDiscard( const ending = detectLineEnding(source.text) const oldString = convertToLineEnding(input.oldString, ending) const newString = convertToLineEnding(input.newString, ending) + if (oldString === newString) { + return yield* new ToolFailure({ + message: "No changes to apply: oldString and newString are identical.", + }) + } const replacements = countOccurrences(source.text, oldString) if (replacements === 0) { return yield* new ToolFailure({ diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index a9e4cbae4952..56d0c18374ea 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -381,6 +381,41 @@ describe("EditTool", () => { ), ) + it.live("rejects an edit whose strings differ only in line ending style", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const target = path.join(tmp.path, "crlf.txt") + return Effect.promise(() => fs.writeFile(target, "before\r\nrest\r\n")).pipe( + Effect.andThen( + withTool(tmp.path, (registry) => + executeTool( + registry, + call({ path: "crlf.txt", oldString: "before\r\nrest", newString: "before\nrest" }), + ), + ), + ), + Effect.andThen((result) => + Effect.promise(() => fs.readFile(target, "utf8")).pipe( + Effect.tap((content) => + Effect.sync(() => { + expect(result).toEqual({ + type: "error", + value: "No changes to apply: oldString and newString are identical.", + }) + expect(content).toBe("before\r\nrest\r\n") + expect(writes).toHaveLength(0) + }), + ), + ), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("rejects an in-place content change after matching but before conditional commit", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()),