char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somcode: support exact text replacements alongside hashline edits0ad1bcc

main
15.0 KiB398 linesraw
1import { assert, assertEquals, assertMatch, assertRejects } from "@std/assert";
2import { decodeBase64 } from "@std/encoding/base64";
3import { PNG, tempDir } from "@berry/util";
4import { readFile, stat, symlink, writeFile } from "node:fs/promises";
5import { join } from "node:path";
6import { localEnvironment } from "@berry/api/environment.ts";
7import { testExtension } from "@berry/api/testing.ts";
8import code from "./mod.ts";
9
10type Fixture = {
11  cwd: string;
12  path: string;
13  call(name: string, args?: Record<string, unknown>, signal?: AbortSignal): Promise<string>;
14};
15
16function test(name: string, run: (f: Fixture) => Promise<void>, content: string = "") {
17  Deno.test(name, async () => {
18    await using dir = await tempDir();
19    const path = join(dir.path, "file.txt");
20    await writeFile(path, content);
21    const extension = testExtension(code, { env: localEnvironment(dir.path) });
22    await run({
23      cwd: dir.path,
24      path,
25      call: async (name, args = {}, signal) =>
26        await extension.call(name, { path: "file.txt", ...args } as never, { signal }) as string,
27    });
28  });
29}
30
31const anchors = (output: string) =>
32  output.split("\n").filter((row) => row.includes("│")).map((row) => row.split("│")[0]);
33const unanchored = (output: string) =>
34  output.replace(/^([+-])[A-Za-z]{4}│/gm, "$1").split("\n").slice(2);
35
36test("diffs carry the new lines' hashes and keep replacement text literal", async (f) => {
37  const [old] = anchors(await f.call("read"));
38  assertMatch(old, /^[A-Za-z]{4}$/);
39  const output = await f.call("edit", { edits: [{ old: [old], new: ["ABCD│literal"] }] });
40  assert(output.includes(`-${old}│original`));
41  assertMatch(output, /^\+[A-Za-z]{4}│ABCD│literal$/m);
42}, "original\n");
43
44test(
45  "batched diffs merge nearby context without duplicating it and separate distant hunks",
46  async (f) => {
47    const old = anchors(await f.call("read"));
48    const output = await f.call("edit", {
49      edits: [
50        { old: [old[16]], new: ["last"] },
51        { old: [old[2]], new: ["first", "extra"] },
52        { old: [old[5]], new: [] },
53      ],
54    });
55    assertEquals(unanchored(output), [
56      "@@ original line 1 @@",
57      " line 0",
58      " line 1",
59      "-line 2",
60      "+first",
61      "+extra",
62      " line 3",
63      " line 4",
64      "-line 5",
65      " line 6",
66      " line 7",
67      " line 8",
68      "@@ original line 14 @@",
69      " line 13",
70      " line 14",
71      " line 15",
72      "-line 16",
73      "+last",
74      " line 17",
75      " line 18",
76      " line 19",
77    ]);
78  },
79  Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n"),
80);
81
82test("references survive external line movement", async (f) => {
83  const [first, target, last] = anchors(await f.call("read"));
84  await writeFile(f.path, "inserted\nlast\nfirst\ntarget\n");
85  assertEquals(anchors(await f.call("read")).slice(1), [last, first, target]);
86  await f.call("edit", { edits: [{ old: [target], new: ["changed"] }] });
87  assertEquals(await readFile(f.path, "utf8"), "inserted\nlast\nfirst\nchanged\n");
88}, "first\ntarget\nlast\n");
89
90test("duplicate lines need unique adjacent context, which is preserved", async (f) => {
91  const original = "left\nsame\nright\nleft\nsame\nother\n";
92  const [left, same, right, leftAgain, sameAgain] = anchors(await f.call("read"));
93  assertEquals(left, leftAgain);
94  assertEquals(same, sameAgain);
95  await assertRejects(
96    () => f.call("edit", { edits: [{ old: [same], new: ["changed"] }] }),
97    Error,
98    "ambiguous",
99  );
100  await assertRejects(
101    () => f.call("edit", { edits: [{ before: [left], old: [same], new: ["changed"] }] }),
102    Error,
103    "ambiguous",
104  );
105  assertEquals(await readFile(f.path, "utf8"), original);
106  await f.call("edit", {
107    edits: [{ before: [left], old: [same], after: [right], new: ["changed"] }],
108  });
109  assertEquals(await readFile(f.path, "utf8"), "left\nchanged\nright\nleft\nsame\nother\n");
110}, "left\nsame\nright\nleft\nsame\nother\n");
111
112test(
113  "batches resolve against the original, regardless of order or line count changes",
114  async (f) => {
115    const [one, two, three, four] = anchors(await f.call("read"));
116    await f.call("edit", {
117      edits: [{ old: [four], new: ["last"] }, { old: [one], new: ["first", "extra"] }],
118    });
119    assertEquals(await readFile(f.path, "utf8"), "first\nextra\ntwo\nthree\nlast\n");
120    assertEquals(anchors(await f.call("read")).slice(2, 4), [two, three]);
121  },
122  "one\ntwo\nthree\nfour\n",
123);
124
125test("a stale interior line refuses the entire batch without writing", async (f) => {
126  const [one, two, three, four] = anchors(await f.call("read"));
127  const external = "one\nexternal\nthree\nfour\n";
128  await writeFile(f.path, external);
129  await assertRejects(
130    () =>
131      f.call("edit", {
132        edits: [{ old: [four], new: ["would change"] }, {
133          old: [one, two, three],
134          new: ["replacement"],
135        }],
136      }),
137    Error,
138    "stale",
139  );
140  assertEquals(await readFile(f.path, "utf8"), external);
141}, "one\ntwo\nthree\nfour\n");
142
143test("hashes include trailing whitespace and the entire line", async (f) => {
144  const long = "x".repeat(1000);
145  const original = `text\ntext \n${long}a\n${long}b\n`;
146  await writeFile(f.path, original);
147  const hashes = anchors(await f.call("read"));
148  assertEquals(new Set(hashes).size, 4);
149  await writeFile(f.path, original.replace("text \n", "text  \n"));
150  await assertRejects(
151    () => f.call("edit", { edits: [{ old: [hashes[1]], new: ["changed"] }] }),
152    Error,
153    "stale",
154  );
155});
156
157test("overlapping ranges and same-position insertions are refused", async (f) => {
158  const original = "one\ntwo\nthree\n";
159  const [one, two, three] = anchors(await f.call("read"));
160  for (
161    const edits of [
162      [{ old: [one, two], new: [] }, { old: [two, three], new: [] }],
163      [{ before: [one], old: [], new: ["x"] }, { after: [two], old: [], new: ["y"] }],
164    ]
165  ) {
166    await assertRejects(() => f.call("edit", { edits }), Error, "Overlapping");
167    assertEquals(await readFile(f.path, "utf8"), original);
168  }
169}, "one\ntwo\nthree\n");
170
171test("context may overlap other edits, but is always checked against the original", async (f) => {
172  const [one, two, three] = anchors(await f.call("read"));
173  await f.call("edit", {
174    edits: [
175      { old: [one], after: [two], new: ["first"] },
176      { before: [one], old: [two], after: [three], new: ["second"] },
177    ],
178  });
179  assertEquals(await readFile(f.path, "utf8"), "first\nsecond\nthree\n");
180}, "one\ntwo\nthree\n");
181
182test("insert before/after, delete, and reuse hashes returned by edit", async (f) => {
183  const [middle] = anchors(await f.call("read"));
184  const diff = await f.call("edit", {
185    edits: [{ old: [], after: [middle], new: ["first"] }, {
186      before: [middle],
187      old: [],
188      new: ["last"],
189    }],
190  });
191  assertEquals(await readFile(f.path, "utf8"), "first\nmiddle\nlast\n");
192  const [first, last] = anchors(diff).map((anchor) => anchor.slice(1));
193  await f.call("edit", { edits: [{ old: [first, middle, last], new: [] }] });
194  assertEquals(await readFile(f.path, "utf8"), "");
195}, "middle\n");
196
197test("empty files can be seeded, but nonempty files require a selector", async (f) => {
198  assertMatch(await f.call("read"), /Empty file/);
199  await f.call("edit", { edits: [{ old: [], new: ["seed"] }] });
200  assertEquals(await readFile(f.path, "utf8"), "seed\n");
201  await assertRejects(
202    () => f.call("edit", { edits: [{ old: [], new: ["unsafe"] }] }),
203    Error,
204    "context",
205  );
206});
207
208test("BOM, mixed line endings, and missing final newline survive edits", async (f) => {
209  const [one, two, three] = anchors(await f.call("read"));
210  await f.call("edit", { edits: [{ old: [one], new: ["first"] }] });
211  assertEquals(await readFile(f.path, "utf8"), "\uFEFFfirst\r\ntwo\nthree");
212  await f.call("edit", { edits: [{ before: [three], old: [], new: ["last"] }] });
213  assertEquals(await readFile(f.path, "utf8"), "\uFEFFfirst\r\ntwo\nthree\r\nlast");
214  assertEquals(anchors(await f.call("read"))[1], two);
215}, "\uFEFFone\r\ntwo\nthree");
216
217test("blank replacement lines are not deletion, including at unterminated EOF", async (f) => {
218  const [, two] = anchors(await f.call("read"));
219  await f.call("edit", { edits: [{ old: [two], new: [""] }] });
220  assertEquals(await readFile(f.path, "utf8"), "one\n\n");
221}, "one\ntwo");
222
223test("no-op edits do not write, and replacement text is literal", async (f) => {
224  const [one] = anchors(await f.call("read"));
225  const before = await stat(f.path);
226  assertMatch(await f.call("edit", { edits: [{ old: [one], new: ["one"] }] }), /No changes/);
227  assertEquals((await stat(f.path)).mtimeMs, before.mtimeMs);
228  await f.call("edit", { edits: [{ old: [one], new: [`${one}│one`, "one", "one"] }] });
229  assertEquals(await readFile(f.path, "utf8"), `${one}│one\none\none\n`);
230}, "one\n");
231
232test("invalid text and embedded newlines are refused without changing bytes", async (f) => {
233  for (
234    const content of [
235      new Uint8Array([0xff, 0xfe, 65, 0]),
236      new Uint8Array([97, 0, 98]),
237      new Uint8Array([0xff]),
238    ]
239  ) {
240    await writeFile(f.path, content);
241    await assertRejects(() => f.call("read"), Error, "UTF-8");
242    await assertRejects(
243      () => f.call("edit", { edits: [{ old: [], new: ["unsafe"] }] }),
244      Error,
245      "UTF-8",
246    );
247    assertEquals(new Uint8Array(await readFile(f.path)), content);
248  }
249  await writeFile(f.path, "one\n");
250  const [one] = anchors(await f.call("read"));
251  for (const line of ["two\nthree", "two\rthree", "two\0three"]) {
252    await assertRejects(() => f.call("edit", { edits: [{ old: [one], new: [line] }] }));
253    assertEquals(await readFile(f.path, "utf8"), "one\n");
254  }
255});
256
257test("paged reads provide the same anchors and respect line and byte limits", async (f) => {
258  const page = await f.call("read");
259  assertEquals(anchors(page).length, 2000);
260  assertMatch(page, /offset=2001/);
261  const small = await f.call("read", { offset: 2, limit: 2 });
262  assertEquals(anchors(small), anchors(page).slice(1, 3));
263  assertMatch(small, /offset=4/);
264  assertEquals(anchors(await f.call("read", { offset: 2001 })).length, 5);
265  await assertRejects(() => f.call("read", { offset: 2006 }), Error, "beyond");
266
267  await writeFile(f.path, ("x".repeat(30_000) + "\n").repeat(3));
268  const bytes = await f.call("read");
269  assertEquals(anchors(bytes).length, 1);
270  assertMatch(bytes, /offset=2/);
271  await writeFile(f.path, "x".repeat(60_000));
272  await assertRejects(() => f.call("read"), Error, "exceeds");
273}, Array.from({ length: 2005 }, (_, i) => i.toString(36)).join("\n"));
274
275test("parallel edits through symlink aliases do not overwrite each other", async (f) => {
276  const alias = f.path + ".link";
277  await symlink(f.path, alias);
278  const [one, two] = anchors(await f.call("read"));
279  await Promise.all([
280    f.call("edit", { edits: [{ old: [one], new: ["first"] }] }),
281    f.call("edit", { path: alias, edits: [{ old: [two], new: ["second"] }] }),
282  ]);
283  assertEquals(await readFile(f.path, "utf8"), "first\nsecond\n");
284}, "one\ntwo\n");
285
286test("images resolve to an image block", async (f) => {
287  await writeFile(join(f.cwd, "pixel.png"), decodeBase64(PNG));
288  assertEquals(await f.call("read", { path: "pixel.png" }), {
289    type: "image",
290    data: PNG,
291    mimeType: "image/png",
292  } as unknown as string);
293});
294
295test("aborted calls do not write", async (f) => {
296  const [one] = anchors(await f.call("read"));
297  await assertRejects(() =>
298    f.call("edit", { edits: [{ old: [one], new: ["changed"] }] }, AbortSignal.abort())
299  );
300  assertEquals(await readFile(f.path, "utf8"), "one\n");
301}, "one\n");
302
303test("text edits replace exact substrings and return reusable line hashes", async (f) => {
304  const output = await f.call("edit", {
305    edits: [{ oldText: "const x = 1;\nconst y", newText: 'const x = "$&";\nconst z' }],
306  });
307  assertEquals(await readFile(f.path, "utf8"), 'const x = "$&";\nconst z = 2;\n');
308  const added = anchors(output).filter((anchor) => anchor.startsWith("+")).map((a) => a.slice(1));
309  await f.call("edit", { edits: [{ old: [added[1]], new: ["done"] }] });
310  assertEquals(await readFile(f.path, "utf8"), 'const x = "$&";\ndone\n');
311}, "const x = 1;\nconst y = 2;\n");
312
313test("text and hash edits share original coordinates even within one line", async (f) => {
314  const [heading] = anchors(await f.call("read"));
315  await f.call("edit", {
316    edits: [
317      { oldText: "right", newText: "R" },
318      { old: [heading], new: ["new heading", "extra"] },
319      { oldText: "left", newText: "longer left" },
320    ],
321  });
322  assertEquals(await readFile(f.path, "utf8"), "new heading\nextra\nlonger left + R\n");
323}, "heading\nleft + right\n");
324
325test("ambiguous text includes overlapping occurrences and can be disambiguated", async (f) => {
326  await assertRejects(
327    () => f.call("edit", { edits: [{ oldText: "aa", newText: "x" }] }),
328    Error,
329    "ambiguous",
330  );
331  assertEquals(await readFile(f.path, "utf8"), "aaa\n");
332  await f.call("edit", { edits: [{ oldText: "aa\n", newText: "x\n" }] });
333  assertEquals(await readFile(f.path, "utf8"), "ax\n");
334}, "aaa\n");
335
336test(
337  "missing text refuses the entire batch rather than matching earlier replacements",
338  async (f) => {
339    await assertRejects(
340      () =>
341        f.call("edit", {
342          edits: [{ oldText: "one", newText: "two" }, { oldText: "two", newText: "three" }],
343        }),
344      Error,
345      "not found",
346    );
347    assertEquals(await readFile(f.path, "utf8"), "one\n");
348  },
349  "one\n",
350);
351
352test("overlap between text and hash ranges refuses the entire batch", async (f) => {
353  const [line] = anchors(await f.call("read"));
354  await assertRejects(
355    () =>
356      f.call("edit", {
357        edits: [{ old: [line], new: ["whole"] }, { oldText: "part", newText: "piece" }],
358      }),
359    Error,
360    "Overlapping",
361  );
362  assertEquals(await readFile(f.path, "utf8"), "a part of a line\n");
363}, "a part of a line\n");
364
365test("empty oldText seeds only empty files and each entry must choose one form", async (f) => {
366  await f.call("edit", { edits: [{ oldText: "", newText: "seed" }] });
367  assertEquals(await readFile(f.path, "utf8"), "seed");
368  await assertRejects(() => f.call("edit", { edits: [{ oldText: "", newText: "unsafe" }] }));
369  await assertRejects(() =>
370    f.call("edit", { edits: [{ oldText: "seed", newText: "text", old: [], new: ["hash"] }] })
371  );
372  assertEquals(await readFile(f.path, "utf8"), "seed");
373});
374
375test(
376  "text edits preserve BOM and literal line endings, including deleting through EOF",
377  async (f) => {
378    const before = await stat(f.path);
379    assertMatch(
380      await f.call("edit", { edits: [{ oldText: "one", newText: "one" }] }),
381      /No changes/,
382    );
383    assertEquals((await stat(f.path)).mtimeMs, before.mtimeMs);
384    await f.call("edit", {
385      edits: [{ oldText: "one\r\ntwo\n", newText: "first\nsecond\r\n" }],
386    });
387    assertEquals(await readFile(f.path, "utf8"), "\uFEFFfirst\nsecond\r\nthree");
388    await f.call("edit", { edits: [{ oldText: "\r\nthree", newText: "" }] });
389    assertEquals(await readFile(f.path, "utf8"), "\uFEFFfirst\nsecond");
390  },
391  "\uFEFFone\r\ntwo\nthree",
392);
393
394test("hash replacement can keep an unterminated line and add a following line", async (f) => {
395  const [line] = anchors(await f.call("read"));
396  await f.call("edit", { edits: [{ old: [line], new: ["one", "two"] }] });
397  assertEquals(await readFile(f.path, "utf8"), "one\ntwo");
398}, "one");