Files
oh-my-pi/packages/coding-agent/test/core/apply-patch-regression.test.ts
T
can1357 c014c309d6 refactor(coding-agent/core): restructured edit tool into modular patch architecture
- Refactored edit tool implementation with modular patch architecture.
- Moved edit tool implementation from edit/ to patch/ module.
- Updated import paths for EditToolDetails across core modules.
- Enhanced patch parsing with unified diff and Codex-style patch support.
- Improved fuzzy matching algorithms for more robust text location.
2026-01-19 16:16:53 +01:00

739 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Regression tests for apply-patch behaviors.
*
* These tests verify that the edit/ module correctly implements features
* that were identified as missing or regressed in other implementations.
* Each test corresponds to a specific scenario from patchv2/TODO.md.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { applyPatch, findContextLine, seekSequence } from "../../src/core/tools/patch";
describe("regression: indentation adjustment for line-based replacements (2B)", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `regression-2b-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("line-based patch adjusts indentation when fuzzy matching at different indent level", async () => {
const filePath = join(tempDir, "indent.ts");
// File has 4-space indentation
await Bun.write(
filePath,
`class Example {
constructor() {
this.value = 1;
this.name = "test";
}
}
`,
);
// Patch uses 0 indentation - should be adjusted to match the 8-space indent in file
await applyPatch(
{
path: "indent.ts",
operation: "update",
diff: `@@ constructor() {
-this.value = 1;
-this.name = "test";
+this.value = 42;
+this.name = "updated";`,
},
{ cwd: tempDir },
);
const result = readFileSync(filePath, "utf-8");
expect(result).toContain(" this.value = 42;");
expect(result).toContain(' this.name = "updated";');
});
test("multi-hunk patch adjusts indentation independently per hunk", async () => {
const filePath = join(tempDir, "multi-indent.ts");
await Bun.write(
filePath,
`function outer() {
function inner1() {
return 1;
}
function inner2() {
return 2;
}
}
`,
);
// Different indentation levels in file - each hunk should adjust independently
await applyPatch(
{
path: "multi-indent.ts",
operation: "update",
diff: `@@ function inner1() {
-return 1;
+return 10;
@@ function inner2() {
-return 2;
+return 20;`,
},
{ cwd: tempDir },
);
const result = readFileSync(filePath, "utf-8");
expect(result).toContain(" return 10;"); // 4 spaces for inner1
expect(result).toContain(" return 20;"); // 6 spaces for inner2
});
});
describe("regression: ambiguity detection for context-less hunks (2C)", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `regression-2c-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("single-hunk simple diff rejects multiple occurrences", async () => {
const filePath = join(tempDir, "dupe.txt");
await Bun.write(filePath, "foo\nbar\nfoo\nbaz\n");
await expect(
applyPatch(
{
path: "dupe.txt",
operation: "update",
diff: "-foo\n+FOO",
},
{ cwd: tempDir },
),
).rejects.toThrow(/2 occurrences/);
});
test("multi-hunk context-less diff rejects ambiguous patterns", async () => {
const filePath = join(tempDir, "multi-dupe.txt");
// Each pattern appears twice
await Bun.write(filePath, "aaa\nbbb\naaa\nccc\nbbb\nddd\n");
// First hunk for "aaa" is ambiguous (appears at lines 1 and 3)
await expect(
applyPatch(
{
path: "multi-dupe.txt",
operation: "update",
diff: "@@\n-aaa\n+AAA\n@@\n-ccc\n+CCC",
},
{ cwd: tempDir },
),
).rejects.toThrow(/2 occurrences/);
});
test("context lines disambiguate otherwise ambiguous patterns", async () => {
const filePath = join(tempDir, "context-disambig.txt");
await Bun.write(filePath, "header\nfoo\nbar\nmiddle\nfoo\nbaz\nfooter\n");
// Context line "middle" disambiguates which "foo" to change
await applyPatch(
{
path: "context-disambig.txt",
operation: "update",
diff: "@@\n middle\n-foo\n+FOO",
},
{ cwd: tempDir },
);
expect(readFileSync(filePath, "utf-8")).toBe("header\nfoo\nbar\nmiddle\nFOO\nbaz\nfooter\n");
});
});
describe("regression: context search uses line hints (2D)", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `regression-2d-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("unified diff line numbers help locate correct position", async () => {
const filePath = join(tempDir, "hints.txt");
// File with repeated function definitions
await Bun.write(
filePath,
`function process() {
return 1;
}
function process() {
return 2;
}
function process() {
return 3;
}
`,
);
// Use unified diff format with line hint to target the second process()
await applyPatch(
{
path: "hints.txt",
operation: "update",
diff: `@@ -5,3 +5,3 @@ function process() {
function process() {
- return 2;
+ return 200;
}`,
},
{ cwd: tempDir },
);
const result = readFileSync(filePath, "utf-8");
expect(result).toContain("return 1;"); // First unchanged
expect(result).toContain("return 200;"); // Second changed
expect(result).toContain("return 3;"); // Third unchanged
});
test("line hint overrides context-only search when appropriate", async () => {
const filePath = join(tempDir, "hint-priority.txt");
await Bun.write(
filePath,
`# Section A
def helper():
pass
# Section B
def helper():
pass
`,
);
// Line hint points to Section B's helper (line 6)
await applyPatch(
{
path: "hint-priority.txt",
operation: "update",
diff: `@@ -6,2 +6,2 @@ def helper():
def helper():
- pass
+ return True`,
},
{ cwd: tempDir },
);
const result = readFileSync(filePath, "utf-8");
const lines = result.split("\n");
expect(lines[2]).toBe(" pass"); // Section A unchanged
expect(lines[6]).toBe(" return True"); // Section B changed
});
});
describe("regression: insertion uses newStartLine fallback (2E)", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `regression-2e-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("pure addition with context uses context to find insertion point", async () => {
const filePath = join(tempDir, "insert.txt");
await Bun.write(filePath, "line1\nline2\nline3\n");
// Insert after line1 using context
await applyPatch(
{
path: "insert.txt",
operation: "update",
diff: `@@
line1
+inserted`,
},
{ cwd: tempDir },
);
expect(readFileSync(filePath, "utf-8")).toBe("line1\ninserted\nline2\nline3\n");
});
test("pure addition with line hint inserts at correct position", async () => {
const filePath = join(tempDir, "insert-hint.txt");
await Bun.write(filePath, "aaa\nbbb\nccc\n");
// Use unified diff format line hints to insert at specific location
await applyPatch(
{
path: "insert-hint.txt",
operation: "update",
diff: `@@ -2,1 +2,2 @@
bbb
+inserted after bbb`,
},
{ cwd: tempDir },
);
expect(readFileSync(filePath, "utf-8")).toBe("aaa\nbbb\ninserted after bbb\nccc\n");
});
test("insertion at end of file works correctly", async () => {
const filePath = join(tempDir, "append.txt");
await Bun.write(filePath, "first\nsecond\n");
await applyPatch(
{
path: "append.txt",
operation: "update",
diff: `@@
+appended line
*** End of File`,
},
{ cwd: tempDir },
);
expect(readFileSync(filePath, "utf-8")).toBe("first\nsecond\nappended line\n");
});
});
describe("regression: seekSequence character-based fallback (2F)", () => {
test("seekSequence falls back to character-based matching when line-based fails", () => {
// Lines with subtle differences that line-based fuzzy matching might miss
const lines = [
"function calculateTotal(items) {",
" let sum = 0;",
" for (const item of items) {",
" sum += item.price * item.quantity;",
" }",
" return sum;",
"}",
];
// Pattern has minor differences: extra space, different quote style
const pattern = [
" for (const item of items) {", // extra space before {
" sum += item.price*item.quantity;", // no spaces around *
];
const result = seekSequence(lines, pattern, 0, false);
expect(result.index).toBe(2);
expect(result.confidence).toBeGreaterThan(0.9);
});
test("seekSequence handles normalized unicode matching", () => {
const lines = ['const message = "Hello – World";', "console.log(message);"];
// Pattern uses ASCII dash instead of en-dash
const pattern = ['const message = "Hello - World";'];
const result = seekSequence(lines, pattern, 0, false);
expect(result.index).toBe(0);
});
test("seekSequence finds pattern with whitespace differences", () => {
const lines = [" function foo() {", " return 42;", " }"];
// Pattern has normalized whitespace
const pattern = ["function foo() {", "return 42;"];
const result = seekSequence(lines, pattern, 0, false);
expect(result.index).toBe(0);
});
});
describe("regression: findContextLine progressive matching (2D related)", () => {
test("finds exact context line", () => {
const lines = ["function foo() {", " return 1;", "}"];
const result = findContextLine(lines, "function foo() {", 0);
expect(result.index).toBe(0);
expect(result.confidence).toBe(1.0);
});
test("finds context line with whitespace differences", () => {
const lines = [" function foo() {", " return 1;", "}"];
const result = findContextLine(lines, "function foo() {", 0);
expect(result.index).toBe(0);
expect(result.confidence).toBeGreaterThan(0.9);
});
test("finds context line with unicode normalization", () => {
const lines = ['const msg = "Hello – World";', "return msg;"];
// ASCII dash in pattern, en-dash in content
const result = findContextLine(lines, 'const msg = "Hello - World";', 0);
expect(result.index).toBe(0);
});
test("finds context line as prefix match", () => {
const lines = ["function calculateTotalWithTax(items, taxRate) {", " return 0;", "}"];
// Partial function name matches as prefix
const result = findContextLine(lines, "function calculateTotalWithTax(items", 0);
expect(result.index).toBe(0);
expect(result.confidence).toBeGreaterThan(0.9);
});
test("finds context line as substring match", () => {
// Substring must be at least 6 chars and 30% of line length
const lines = ["// comment: calculateTotal here", "function foo() {}"];
const result = findContextLine(lines, "calculateTotal", 0);
expect(result.index).toBe(0);
expect(result.confidence).toBeGreaterThan(0.9);
});
test("falls back to fuzzy match for similar lines", () => {
const lines = ["functoin calclateTotal(itms) {", " return 0;", "}"];
// Typos in content, correct in pattern
const result = findContextLine(lines, "function calculateTotal(items) {", 0);
expect(result.index).toBe(0);
expect(result.confidence).toBeGreaterThan(0.8);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Plan: Make `@@` Context Matching Robust - Expected Behaviors
// These tests document expected behaviors from the plan. Some may fail if
// the feature is not yet implemented.
// ═══════════════════════════════════════════════════════════════════════════
describe("plan: partial line matching for @@ context", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `plan-partial-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("@@ context matches when actual line contains it as substring", async () => {
const filePath = join(tempDir, "imports.ts");
// Actual line has more content than the @@ context
await Bun.write(
filePath,
'import { mkdirSync, unlinkSync } from "node:fs";\n\nfunction cleanup() {\n unlinkSync("temp");\n}\n',
);
// @@ context is a partial match (substring of actual line)
await applyPatch(
{
path: "imports.ts",
operation: "update",
diff: `@@ import { mkdirSync, unlinkSync }
function cleanup() {
- unlinkSync("temp");
+ rmSync("temp", { recursive: true });`,
},
{ cwd: tempDir },
);
const result = readFileSync(filePath, "utf-8");
expect(result).toContain('rmSync("temp", { recursive: true });');
});
test("@@ context matches function signature even with trailing content", async () => {
const filePath = join(tempDir, "funcs.ts");
await Bun.write(
filePath,
`function processItems(items: Item[], options?: Options): Result {
return items.map(i => i.value);
}
`,
);
// @@ has partial function signature
await applyPatch(
{
path: "funcs.ts",
operation: "update",
diff: `@@ function processItems(items
- return items.map(i => i.value);
+ return items.filter(i => i.valid).map(i => i.value);`,
},
{ cwd: tempDir },
);
expect(readFileSync(filePath, "utf-8")).toContain("filter(i => i.valid)");
});
});
describe("plan: unified diff format line numbers", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `plan-unified-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("@@ -10,6 +10,7 @@ is parsed as line numbers not literal text", async () => {
const filePath = join(tempDir, "lines.txt");
// Create file with 15 lines
const lines = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`);
await Bun.write(filePath, `${lines.join("\n")}\n`);
// Use unified diff format to target line 10
await applyPatch(
{
path: "lines.txt",
operation: "update",
diff: `@@ -10,3 +10,3 @@
line 10
-line 11
+LINE ELEVEN
line 12`,
},
{ cwd: tempDir },
);
const result = readFileSync(filePath, "utf-8");
expect(result).toContain("LINE ELEVEN");
expect(result).toContain("line 10"); // unchanged
expect(result).toContain("line 12"); // unchanged
});
test("unified diff line numbers take precedence over context search", async () => {
const filePath = join(tempDir, "repeat.txt");
// Same pattern appears at lines 3 and 8
await Bun.write(
filePath,
`header
line 2
target line
line 4
line 5
line 6
line 7
target line
line 9
`,
);
// Line hint says line 8, should change second "target line"
await applyPatch(
{
path: "repeat.txt",
operation: "update",
diff: `@@ -8,1 +8,1 @@
-target line
+MODIFIED TARGET`,
},
{ cwd: tempDir },
);
const result = readFileSync(filePath, "utf-8");
const lines = result.split("\n");
expect(lines[2]).toBe("target line"); // First unchanged
expect(lines[7]).toBe("MODIFIED TARGET"); // Second changed
});
});
describe("plan: Codex-style wrapped patches", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `plan-codex-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("strips *** Begin Patch / *** End Patch wrapper", async () => {
const filePath = join(tempDir, "wrapped.txt");
await Bun.write(filePath, "old content\n");
// Full Codex-style wrapper - the diff inside should be extracted
await applyPatch(
{
path: "wrapped.txt",
operation: "update",
diff: `*** Begin Patch
@@
-old content
+new content
*** End Patch`,
},
{ cwd: tempDir },
);
expect(readFileSync(filePath, "utf-8")).toBe("new content\n");
});
test("strips partial wrapper (only *** End Patch)", async () => {
const filePath = join(tempDir, "partial.txt");
await Bun.write(filePath, "original\n");
// Only end marker present
await applyPatch(
{
path: "partial.txt",
operation: "update",
diff: `@@
-original
+modified
*** End Patch`,
},
{ cwd: tempDir },
);
expect(readFileSync(filePath, "utf-8")).toBe("modified\n");
});
test("strips unified diff metadata lines", async () => {
const filePath = join(tempDir, "unified-meta.txt");
await Bun.write(filePath, "first\nsecond\nthird\n");
// Full unified diff format with metadata
await applyPatch(
{
path: "unified-meta.txt",
operation: "update",
diff: `diff --git a/unified-meta.txt b/unified-meta.txt
index abc123..def456 100644
--- a/unified-meta.txt
+++ b/unified-meta.txt
@@ -1,3 +1,3 @@
first
-second
+SECOND
third`,
},
{ cwd: tempDir },
);
expect(readFileSync(filePath, "utf-8")).toBe("first\nSECOND\nthird\n");
});
});
describe("plan: strip + prefix from file creation", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `plan-create-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("create file strips + prefix when all lines have it", async () => {
await applyPatch(
{
path: "newfile.txt",
operation: "create",
diff: `+line one
+line two
+line three`,
},
{ cwd: tempDir },
);
expect(readFileSync(join(tempDir, "newfile.txt"), "utf-8")).toBe("line one\nline two\nline three\n");
});
test("create file strips + space prefix", async () => {
await applyPatch(
{
path: "spaced.txt",
operation: "create",
diff: `+ first line
+ second line`,
},
{ cwd: tempDir },
);
expect(readFileSync(join(tempDir, "spaced.txt"), "utf-8")).toBe("first line\nsecond line\n");
});
test("create file preserves content when not all lines have + prefix", async () => {
await applyPatch(
{
path: "mixed.txt",
operation: "create",
diff: `+line one
regular line
+line three`,
},
{ cwd: tempDir },
);
// Should preserve as-is since not all lines have +
expect(readFileSync(join(tempDir, "mixed.txt"), "utf-8")).toBe("+line one\nregular line\n+line three\n");
});
});
describe("regression: *** End of File marker handling (2A/2G)", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `regression-eof-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("*** End of File marker is preserved in hunk parsing", async () => {
const filePath = join(tempDir, "eof.txt");
await Bun.write(filePath, "line1\nline2\nlast line\n");
await applyPatch(
{
path: "eof.txt",
operation: "update",
diff: `@@
-last line
+modified last line
*** End of File`,
},
{ cwd: tempDir },
);
expect(readFileSync(filePath, "utf-8")).toBe("line1\nline2\nmodified last line\n");
});
test("EOF marker targets end of file for pattern matching", async () => {
const filePath = join(tempDir, "eof-target.txt");
// Pattern appears twice - EOF should target the last one
await Bun.write(filePath, "item\nmore content\nitem\n");
await applyPatch(
{
path: "eof-target.txt",
operation: "update",
diff: `@@
-item
+FINAL ITEM
*** End of File`,
},
{ cwd: tempDir },
);
const result = readFileSync(filePath, "utf-8");
expect(result).toBe("item\nmore content\nFINAL ITEM\n");
});
});