313 lines
8.3 KiB
TypeScript
313 lines
8.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { ApplyPatchError, applyPatch } from "@oh-my-pi/pi-coding-agent/edit";
|
|
import { removeSyncWithRetries } from "@oh-my-pi/pi-utils";
|
|
|
|
describe("applyPatch adversarial inputs", () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = path.join(os.tmpdir(), `apply-patch-adversarial-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
try {
|
|
removeSyncWithRetries(tempDir);
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
test("rejects rename when it matches path", async () => {
|
|
const filePath = path.join(tempDir, "same.txt");
|
|
await Bun.write(filePath, "foo\n");
|
|
|
|
await expect(
|
|
applyPatch({ path: "same.txt", op: "update", rename: "same.txt", diff: "@@\n-foo\n+bar" }, { cwd: tempDir }),
|
|
).rejects.toThrow(ApplyPatchError);
|
|
|
|
expect(await Bun.file(filePath).text()).toBe("foo\n");
|
|
});
|
|
|
|
test("respects changeContext for pure additions", async () => {
|
|
const filePath = path.join(tempDir, "add-context.ts");
|
|
await Bun.write(filePath, "function foo() {\n return 1;\n}\nfunction bar() {\n return 2;\n}\n");
|
|
|
|
await applyPatch(
|
|
{
|
|
path: "add-context.ts",
|
|
op: "update",
|
|
diff: "@@ function bar\n+ console.log('x');",
|
|
},
|
|
{ cwd: tempDir },
|
|
);
|
|
|
|
expect(await Bun.file(filePath).text()).toBe(
|
|
"function foo() {\n return 1;\n}\nfunction bar() {\n console.log('x');\n return 2;\n}\n",
|
|
);
|
|
});
|
|
|
|
test("rejects multi-file patch markers in a single-file update", async () => {
|
|
const filePath = path.join(tempDir, "single.txt");
|
|
await Bun.write(filePath, "foo\nbar\n");
|
|
|
|
await expect(
|
|
applyPatch(
|
|
{
|
|
path: "single.txt",
|
|
op: "update",
|
|
diff: "*** Begin Patch\n*** Update File: single.txt\n@@\n-foo\n+FOO\n*** Update File: other.txt\n@@\n-bar\n+BAR\n*** End Patch",
|
|
},
|
|
{ cwd: tempDir },
|
|
),
|
|
).rejects.toThrow(ApplyPatchError);
|
|
});
|
|
|
|
test("preserves context lines that look like diff metadata", async () => {
|
|
const filePath = path.join(tempDir, "metadata-context.txt");
|
|
await Bun.write(filePath, "diff --git a b\nalpha\nmid\ndiff --git a b\nalpha\n");
|
|
|
|
await applyPatch(
|
|
{
|
|
path: "metadata-context.txt",
|
|
op: "update",
|
|
diff: "@@\n diff --git a b\n-alpha\n+ALPHA",
|
|
},
|
|
{ cwd: tempDir },
|
|
);
|
|
|
|
expect(await Bun.file(filePath).text()).toBe("diff --git a b\nALPHA\nmid\ndiff --git a b\nalpha\n");
|
|
});
|
|
|
|
test("applies hunks regardless of order", async () => {
|
|
const filePath = path.join(tempDir, "order.txt");
|
|
await Bun.write(filePath, "first\nkeep\nsecond\nkeep\n");
|
|
|
|
await applyPatch(
|
|
{
|
|
path: "order.txt",
|
|
op: "update",
|
|
diff: "@@ second\n-keep\n+KEEP2\n@@ first\n-keep\n+KEEP1",
|
|
},
|
|
{ cwd: tempDir },
|
|
);
|
|
|
|
expect(await Bun.file(filePath).text()).toBe("first\nKEEP1\nsecond\nKEEP2\n");
|
|
});
|
|
|
|
test("rejects ambiguous changeContext matches", async () => {
|
|
const filePath = path.join(tempDir, "ambiguous-context.ts");
|
|
await Bun.write(filePath, "if (a) {\n return foo;\n}\nif (b) {\n return foo;\n}\n");
|
|
|
|
await expect(
|
|
applyPatch(
|
|
{
|
|
path: "ambiguous-context.ts",
|
|
op: "update",
|
|
diff: "@@ return foo;\n- return foo;\n+ return bar;",
|
|
},
|
|
{ cwd: tempDir },
|
|
),
|
|
).rejects.toThrow(ApplyPatchError);
|
|
});
|
|
|
|
test("rejects ambiguous prefix/substring matches", async () => {
|
|
const filePath = path.join(tempDir, "ambiguous-prefix.ts");
|
|
await Bun.write(filePath, "const enabled = true;\nconst enabled = true; // secondary\n");
|
|
|
|
await expect(
|
|
applyPatch(
|
|
{
|
|
path: "ambiguous-prefix.ts",
|
|
op: "update",
|
|
diff: "@@\n-const enabled = true\n+const enabled = false",
|
|
},
|
|
{ cwd: tempDir },
|
|
),
|
|
).rejects.toThrow(ApplyPatchError);
|
|
});
|
|
|
|
test("rejects out-of-range line hints for insertions", async () => {
|
|
const filePath = path.join(tempDir, "line-hint.txt");
|
|
await Bun.write(filePath, "a\nb\n");
|
|
|
|
await expect(
|
|
applyPatch(
|
|
{
|
|
path: "line-hint.txt",
|
|
op: "update",
|
|
diff: "@@ -999,0 +999,1 @@\n+tail",
|
|
},
|
|
{ cwd: tempDir },
|
|
),
|
|
).rejects.toThrow(ApplyPatchError);
|
|
});
|
|
|
|
test("retains trailing blank context lines for disambiguation", async () => {
|
|
const filePath = path.join(tempDir, "blank-context.txt");
|
|
await Bun.write(filePath, "section\nvalue\nx\nsection\nvalue\n\n");
|
|
|
|
await applyPatch(
|
|
{
|
|
path: "blank-context.txt",
|
|
op: "update",
|
|
diff: "@@\n section\n-value\n+VALUE\n ",
|
|
},
|
|
{ cwd: tempDir },
|
|
);
|
|
|
|
expect(await Bun.file(filePath).text()).toBe("section\nvalue\nx\nsection\nVALUE\n\n");
|
|
});
|
|
|
|
test("fuzzy under-indented context can shift indentation", async () => {
|
|
const filePath = path.join(tempDir, "under-indent.ts");
|
|
await Bun.write(
|
|
filePath,
|
|
"function sum(values, offset) {\n" +
|
|
" let total = 0;\n" +
|
|
" for (const value of values) {\n" +
|
|
" total += value + offset;\n" +
|
|
" total += 1;\n" +
|
|
" }\n" +
|
|
" return total + 1;\n" +
|
|
"}\n",
|
|
);
|
|
|
|
await applyPatch(
|
|
{
|
|
path: "under-indent.ts",
|
|
op: "update",
|
|
diff:
|
|
"@@ -1,6 +1,7 @@\n" +
|
|
" function sum(values, offset) {\n" +
|
|
"-let total = 0;\n" +
|
|
"-for (const value of values) {\n" +
|
|
"+let total = 0;\n" +
|
|
'+ const tag = "sum";\n' +
|
|
"+for (const value of values) {\n" +
|
|
" total += value + offset;\n" +
|
|
" total += 1;\n" +
|
|
" }\n",
|
|
},
|
|
{ cwd: tempDir },
|
|
);
|
|
|
|
expect(await Bun.file(filePath).text()).toBe(
|
|
"function sum(values, offset) {\n" +
|
|
" let total = 0;\n" +
|
|
' const tag = "sum";\n' +
|
|
" for (const value of values) {\n" +
|
|
" total += value + offset;\n" +
|
|
" total += 1;\n" +
|
|
" }\n" +
|
|
" return total + 1;\n" +
|
|
"}\n",
|
|
);
|
|
});
|
|
|
|
test("preserves CRLF endings and trailing newline", async () => {
|
|
const filePath = path.join(tempDir, "crlf.txt");
|
|
await Bun.write(filePath, "foo\r\nbar\r\n");
|
|
|
|
await applyPatch({ path: "crlf.txt", op: "update", diff: "@@\n-foo\n+FOO" }, { cwd: tempDir });
|
|
|
|
const content = await Bun.file(filePath).text();
|
|
expect(content).toBe("FOO\r\nbar\r\n");
|
|
});
|
|
|
|
test("preserves UTF-8 BOM and CRLF endings", async () => {
|
|
const filePath = path.join(tempDir, "bom.txt");
|
|
fs.writeFileSync(filePath, "\uFEFFfoo\r\nbar\r\n");
|
|
|
|
await applyPatch({ path: "bom.txt", op: "update", diff: "@@\n-foo\n+FOO" }, { cwd: tempDir });
|
|
|
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
expect(content).toBe("\uFEFFFOO\r\nbar\r\n");
|
|
});
|
|
|
|
test("preserves missing trailing newline", async () => {
|
|
const filePath = path.join(tempDir, "nonewline.txt");
|
|
await Bun.write(filePath, "foo\nbar");
|
|
|
|
await applyPatch({ path: "nonewline.txt", op: "update", diff: "@@\n-bar\n+baz" }, { cwd: tempDir });
|
|
|
|
const content = await Bun.file(filePath).text();
|
|
expect(content).toBe("foo\nbaz");
|
|
});
|
|
|
|
test("normalizes tab-indented diff to space-indented file", async () => {
|
|
const filePath = path.join(tempDir, "tabs-to-spaces.js");
|
|
// File uses 4-space indentation
|
|
await Bun.write(
|
|
filePath,
|
|
`class Foo {
|
|
method() {
|
|
const x = 1;
|
|
return x;
|
|
}
|
|
}
|
|
`,
|
|
);
|
|
|
|
// Diff uses tab indentation (common LLM output)
|
|
await applyPatch(
|
|
{
|
|
path: "tabs-to-spaces.js",
|
|
op: "update",
|
|
diff: "@@ method() {\n\tmethod() {\n\t\tconst x = 1;\n+\t\tconsole.log(x);\n\t\treturn x;",
|
|
},
|
|
{ cwd: tempDir },
|
|
);
|
|
|
|
const content = await Bun.file(filePath).text();
|
|
// Added line should use spaces, not tabs
|
|
expect(content).toBe(`class Foo {
|
|
method() {
|
|
const x = 1;
|
|
console.log(x);
|
|
return x;
|
|
}
|
|
}
|
|
`);
|
|
// Verify no tabs were introduced
|
|
expect(content).not.toContain("\t");
|
|
});
|
|
|
|
test("preserves indentation when trailing context has less indent than additions", async () => {
|
|
const filePath = path.join(tempDir, "dedent-context.js");
|
|
await Bun.write(
|
|
filePath,
|
|
`class Foo {
|
|
method() {
|
|
}
|
|
}
|
|
`,
|
|
);
|
|
|
|
// Trailing context ` }` has less indentation than added lines
|
|
await applyPatch(
|
|
{
|
|
path: "dedent-context.js",
|
|
op: "update",
|
|
diff: "@@\n method() {\n }\n+\n+ other() {\n+ return 1;\n+ }\n }",
|
|
},
|
|
{ cwd: tempDir },
|
|
);
|
|
|
|
const content = await Bun.file(filePath).text();
|
|
// The closing brace of other() should have 4-space indent, not 0
|
|
expect(content).toBe(`class Foo {
|
|
method() {
|
|
}
|
|
|
|
other() {
|
|
return 1;
|
|
}
|
|
}
|
|
`);
|
|
});
|
|
});
|