52da3674a4
- Added auto-rebasing for stale atom and hashline anchors within ±2 lines, with warning diagnostics. - Removed atom range-locator support and dropped `between` ops, updating docs/tests for `sub` over `set` nudges. - Changed no-op handling to track unchanged hashline edits and emit contextual hints for unchanged ranges. - Expanded Anthropic strict error handling to retry on schema-too-complex and compiled-grammar-too-large errors. - Added anchor-retargeting warning checks and updated edit tests to expect locator and range-locator rejections. - Updated python tool-call fixtures by adding `title` to executed-cell payloads and adjusting related test expectations.
415 lines
13 KiB
TypeScript
415 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
adjustIndentation,
|
|
computeChunkDiff,
|
|
computeEditDiff,
|
|
computeHashlineDiff,
|
|
DEFAULT_FUZZY_THRESHOLD,
|
|
findMatch,
|
|
loadChunkSource,
|
|
parseChunkEditPath,
|
|
parseChunkReadPath,
|
|
} from "@oh-my-pi/pi-coding-agent/edit";
|
|
|
|
describe("findMatch", () => {
|
|
describe("exact matching", () => {
|
|
test("finds exact match", () => {
|
|
const content = "line1\nline2\nline3";
|
|
const target = "line2";
|
|
const result = findMatch(content, target, { allowFuzzy: false });
|
|
expect(result.match).toBeDefined();
|
|
expect(result.match!.confidence).toBe(1);
|
|
expect(result.match!.startLine).toBe(2);
|
|
});
|
|
|
|
test("reports multiple occurrences", () => {
|
|
const content = "foo\nbar\nfoo";
|
|
const target = "foo";
|
|
const result = findMatch(content, target, { allowFuzzy: false });
|
|
expect(result.match).toBeUndefined();
|
|
expect(result.occurrences).toBe(2);
|
|
});
|
|
|
|
test("returns empty for no match", () => {
|
|
const content = "line1\nline2";
|
|
const target = "notfound";
|
|
const result = findMatch(content, target, { allowFuzzy: false });
|
|
expect(result.match).toBeUndefined();
|
|
expect(result.occurrences).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("tab/space normalization", () => {
|
|
test("matches tabs in file with spaces in target", () => {
|
|
const content = "\tfoo\n\t\tbar\n\tbaz";
|
|
const target = " foo\n bar\n baz";
|
|
const result = findMatch(content, target, { allowFuzzy: true });
|
|
expect(result.match).toBeDefined();
|
|
expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD);
|
|
});
|
|
|
|
test("matches spaces in file with tabs in target", () => {
|
|
const content = " foo\n bar\n baz";
|
|
const target = "\tfoo\n\t\tbar\n\tbaz";
|
|
const result = findMatch(content, target, { allowFuzzy: true });
|
|
expect(result.match).toBeDefined();
|
|
expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD);
|
|
});
|
|
|
|
test("matches different space counts with same relative structure", () => {
|
|
const content = " foo\n bar\n baz";
|
|
const target = " foo\n bar\n baz";
|
|
const result = findMatch(content, target, { allowFuzzy: true });
|
|
expect(result.match).toBeDefined();
|
|
expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD);
|
|
});
|
|
|
|
test("matches single line with different indentation", () => {
|
|
const content = 'prefix\n\t\t\t"value",\nsuffix';
|
|
const target = ' "value",';
|
|
const result = findMatch(content, target, { allowFuzzy: true });
|
|
expect(result.match).toBeDefined();
|
|
expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD);
|
|
});
|
|
});
|
|
|
|
describe("fallback for inconsistent indentation", () => {
|
|
test("matches despite one line with wrong indentation in file", () => {
|
|
const content = "\t\t\tline1\n\t\t\tline2\n\t\tline3\n\t\t\tline4";
|
|
const target = " line1\n line2\n line3\n line4";
|
|
const result = findMatch(content, target, { allowFuzzy: true });
|
|
expect(result.match).toBeDefined();
|
|
expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD);
|
|
});
|
|
|
|
test("matches when target has consistent indent but file varies", () => {
|
|
const content = " a\n b\n c\n d";
|
|
const target = " a\n b\n c\n d";
|
|
const result = findMatch(content, target, { allowFuzzy: true });
|
|
expect(result.match).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("content matching", () => {
|
|
test("collapses internal whitespace", () => {
|
|
const content = "foo bar baz";
|
|
const target = "foo bar baz";
|
|
const result = findMatch(content, target, { allowFuzzy: true });
|
|
expect(result.match).toBeDefined();
|
|
expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD);
|
|
});
|
|
|
|
test("matches with trailing whitespace differences", () => {
|
|
const content = "line1 \nline2\t";
|
|
const target = "line1\nline2";
|
|
const result = findMatch(content, target, { allowFuzzy: true });
|
|
expect(result.match).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("threshold behavior", () => {
|
|
test("respects custom similarity threshold", () => {
|
|
const content = "function foo() {}";
|
|
const target = "function bar() {}";
|
|
const strictResult = findMatch(content, target, {
|
|
allowFuzzy: true,
|
|
threshold: 0.99,
|
|
});
|
|
expect(strictResult.match).toBeUndefined();
|
|
|
|
const lenientResult = findMatch(content, target, {
|
|
allowFuzzy: true,
|
|
threshold: 0.7,
|
|
});
|
|
expect(lenientResult.match).toBeDefined();
|
|
});
|
|
|
|
test("reports fuzzyMatches count when multiple above threshold", () => {
|
|
const content = " item1\n item2\n item3";
|
|
const target = " itemX";
|
|
const result = findMatch(content, target, {
|
|
allowFuzzy: true,
|
|
threshold: 0.7,
|
|
});
|
|
expect(result.fuzzyMatches).toBeGreaterThan(1);
|
|
});
|
|
});
|
|
|
|
describe("edge cases", () => {
|
|
test("handles empty target", () => {
|
|
const content = "some content";
|
|
const result = findMatch(content, "", { allowFuzzy: true });
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
test("handles empty lines in content", () => {
|
|
const content = "line1\n\nline3";
|
|
const target = "line1\n\nline3";
|
|
const result = findMatch(content, target, { allowFuzzy: false });
|
|
expect(result.match).toBeDefined();
|
|
expect(result.match!.confidence).toBe(1);
|
|
});
|
|
|
|
test("handles target longer than content", () => {
|
|
const content = "short";
|
|
const target = "this is much longer than the content";
|
|
const result = findMatch(content, target, { allowFuzzy: true });
|
|
expect(result.match).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("computeChunkDiff", () => {
|
|
let tmpDir: string;
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "compute-chunk-"));
|
|
});
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("returns { error } when chunk selector cannot resolve", async () => {
|
|
const file = path.join(tmpDir, "c.ts");
|
|
await fs.writeFile(file, "export const x = 1;\n");
|
|
const result = await computeChunkDiff(
|
|
{
|
|
path: "c.ts:fn_does_not_exist#ABCD",
|
|
edits: [
|
|
{
|
|
path: "c.ts:fn_does_not_exist#ABCD",
|
|
write: "console.log('replaced')\n",
|
|
},
|
|
],
|
|
},
|
|
tmpDir,
|
|
);
|
|
expect("error" in result).toBe(true);
|
|
});
|
|
|
|
test("returns { error } when path is empty", async () => {
|
|
const result = await computeChunkDiff({ path: "", edits: [{ path: "", write: "x\n" }] }, tmpDir);
|
|
expect("error" in result).toBe(true);
|
|
});
|
|
|
|
test("rejects write:null instead of previewing a delete", async () => {
|
|
const file = path.join(tmpDir, "null-delete.ts");
|
|
await fs.writeFile(file, "export const x = 1;\n");
|
|
const result = await computeChunkDiff(
|
|
{
|
|
path: "null-delete.ts",
|
|
edits: [{ path: "null-delete.ts", write: null }],
|
|
},
|
|
tmpDir,
|
|
);
|
|
expect("error" in result).toBe(true);
|
|
if ("error" in result) {
|
|
expect(result.error).toContain("write:null no longer deletes chunks");
|
|
}
|
|
});
|
|
|
|
test("rejects bare chunk edit entries instead of treating them as deletes", async () => {
|
|
const file = path.join(tmpDir, "bare.ts");
|
|
await fs.writeFile(file, "export const x = 1;\n");
|
|
const result = await computeChunkDiff(
|
|
{
|
|
path: "bare.ts",
|
|
edits: [{ path: "bare.ts" }],
|
|
},
|
|
tmpDir,
|
|
);
|
|
expect("error" in result).toBe(true);
|
|
if ("error" in result) {
|
|
expect(result.error).toContain("no operation specified");
|
|
}
|
|
});
|
|
|
|
test("rejects write empty string instead of previewing a destructive empty replacement", async () => {
|
|
const file = path.join(tmpDir, "empty-write.ts");
|
|
await fs.writeFile(file, "export const x = 1;\n");
|
|
const result = await computeChunkDiff(
|
|
{
|
|
path: "empty-write.ts",
|
|
edits: [{ path: "empty-write.ts", write: "" }],
|
|
},
|
|
tmpDir,
|
|
);
|
|
expect("error" in result).toBe(true);
|
|
if ("error" in result) {
|
|
expect(result.error).toContain('write:"" is a destructive empty replacement');
|
|
}
|
|
});
|
|
|
|
test("aborts when signal fires before compute completes", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const result = await computeChunkDiff(
|
|
{
|
|
path: "d.ts",
|
|
edits: [{ path: "d.ts", write: "foo\n" }],
|
|
},
|
|
tmpDir,
|
|
{ signal: controller.signal },
|
|
);
|
|
expect("error" in result).toBe(true);
|
|
});
|
|
|
|
test("computes diff for a root chunk replacement with valid checksum", async () => {
|
|
const file = path.join(tmpDir, "e.ts");
|
|
await fs.writeFile(file, "export const x = 1;\n");
|
|
// Read the file once via loadChunkSource so the test does not depend on
|
|
// knowing the internal chunk checksum scheme.
|
|
const loaded = await loadChunkSource({ cwd: tmpDir, path: "e.ts" });
|
|
expect(loaded.exists).toBe(true);
|
|
expect(loaded.rawContent).toContain("export const x");
|
|
});
|
|
});
|
|
|
|
describe("chunk path parsing", () => {
|
|
test("splits local plan URLs with chunk selectors after the URL path", () => {
|
|
expect(parseChunkEditPath("local://PLAN.md:sct_0_T#SRJJ")).toEqual({
|
|
filePath: "local://PLAN.md",
|
|
selector: "sct_0_T#SRJJ",
|
|
});
|
|
expect(parseChunkReadPath("local://PLAN.md:sct_6_R.sct_6_u#MZKS")).toEqual({
|
|
filePath: "local://PLAN.md",
|
|
selector: "sct_6_R.sct_6_u#MZKS",
|
|
});
|
|
});
|
|
|
|
test("does not treat the local URL scheme colon as a chunk selector separator", () => {
|
|
expect(parseChunkEditPath("local://PLAN.md")).toEqual({ filePath: "local://PLAN.md" });
|
|
});
|
|
});
|
|
|
|
describe("adjustIndentation", () => {
|
|
test("adds indentation when actualText is more indented than oldText", () => {
|
|
const oldText = "foo\nbar";
|
|
const actualText = " foo\n bar";
|
|
const newText = "foo\nbaz\nbar";
|
|
const result = adjustIndentation(oldText, actualText, newText);
|
|
expect(result).toBe(" foo\n baz\n bar");
|
|
});
|
|
|
|
test("removes indentation when actualText is less indented", () => {
|
|
const oldText = " foo\n bar";
|
|
const actualText = " foo\n bar";
|
|
const newText = " foo\n baz";
|
|
const result = adjustIndentation(oldText, actualText, newText);
|
|
expect(result).toBe(" foo\n baz");
|
|
});
|
|
|
|
test("preserves empty lines", () => {
|
|
const oldText = "foo\n\nbar";
|
|
const actualText = " foo\n\n bar";
|
|
const newText = "foo\n\nbaz";
|
|
const result = adjustIndentation(oldText, actualText, newText);
|
|
expect(result).toBe(" foo\n\n baz");
|
|
});
|
|
|
|
test("returns unchanged when indentation matches", () => {
|
|
const oldText = " foo";
|
|
const actualText = " foo";
|
|
const newText = " bar";
|
|
const result = adjustIndentation(oldText, actualText, newText);
|
|
expect(result).toBe(" bar");
|
|
});
|
|
|
|
test("uses tab from actualText when adding indentation", () => {
|
|
const oldText = "foo";
|
|
const actualText = "\t\tfoo";
|
|
const newText = "bar";
|
|
const result = adjustIndentation(oldText, actualText, newText);
|
|
expect(result).toBe("\t\tbar");
|
|
});
|
|
|
|
test("handles mixed content with different indent levels", () => {
|
|
const oldText = "if (x) {\n return y;\n}";
|
|
const actualText = " if (x) {\n return y;\n }";
|
|
const newText = "if (x) {\n return z;\n}";
|
|
const result = adjustIndentation(oldText, actualText, newText);
|
|
expect(result).toBe(" if (x) {\n return z;\n }");
|
|
});
|
|
|
|
test("does not go negative on removal", () => {
|
|
const oldText = " foo";
|
|
const actualText = "foo";
|
|
const newText = " bar";
|
|
const result = adjustIndentation(oldText, actualText, newText);
|
|
// Should remove up to 4 chars, but line only has 2, so remove 2
|
|
expect(result).toBe("bar");
|
|
});
|
|
});
|
|
|
|
describe("computeHashlineDiff", () => {
|
|
let tempDir = "";
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "edit-diff-hashline-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tempDir) {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("returns no-op error for unchanged content when move is absent", async () => {
|
|
const sourcePath = path.join(tempDir, "source.txt");
|
|
await Bun.write(sourcePath, "unchanged content\n");
|
|
|
|
const result = await computeHashlineDiff({ path: sourcePath, edits: [] }, tempDir);
|
|
expect("error" in result).toBe(true);
|
|
if ("error" in result) {
|
|
expect(result.error).toContain("No changes would be made");
|
|
}
|
|
});
|
|
|
|
test("accepts hashline tool edits without resolved op/lines", async () => {
|
|
const sourcePath = path.join(tempDir, "source.txt");
|
|
await Bun.write(sourcePath, "first\n");
|
|
|
|
const result = await computeHashlineDiff(
|
|
{ path: sourcePath, edits: [{ path: sourcePath, loc: "append", content: ["second"] }] },
|
|
tempDir,
|
|
);
|
|
expect("diff" in result).toBe(true);
|
|
if ("diff" in result) {
|
|
expect(result.diff).toContain("second");
|
|
}
|
|
});
|
|
test("returns a handled error when the source path is a local URL", async () => {
|
|
const result = await computeHashlineDiff({ path: "local://PLAN.md", edits: [] }, tempDir);
|
|
|
|
expect("error" in result).toBe(true);
|
|
if ("error" in result) {
|
|
expect(result.error).toContain('internal scheme "local://"');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("computeEditDiff", () => {
|
|
let tempDir = "";
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "edit-diff-edit-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tempDir) {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("returns a handled error when the source path is a local URL", async () => {
|
|
const result = await computeEditDiff("local:/PLAN.md", "old", "new", tempDir);
|
|
|
|
expect("error" in result).toBe(true);
|
|
if ("error" in result) {
|
|
expect(result.error).toContain('internal scheme "local://"');
|
|
}
|
|
});
|
|
});
|