@astrofoundry/pi-astro 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +4 -0
  2. package/extensions/astro-agents/agents/code-reviewer.md +0 -2
  3. package/extensions/astro-agents/agents/google-tech-lead.md +0 -2
  4. package/extensions/astro-agents/agents/spec-writer.md +0 -2
  5. package/extensions/astro-agents/agents/tester-api.md +0 -2
  6. package/extensions/astro-agents/agents/tester-ui.md +0 -2
  7. package/extensions/astro-agents/agents/ui-architect.md +0 -2
  8. package/extensions/astro-agents/agents/ui-design-system.md +0 -2
  9. package/extensions/astro-agents/agents/ui-frontend-developer.md +0 -2
  10. package/extensions/astro-agents/discovery.test.ts +152 -0
  11. package/extensions/astro-agents/index.test.ts +208 -0
  12. package/extensions/astro-agents/index.ts +22 -4
  13. package/extensions/astro-agents/spawn.test.ts +218 -0
  14. package/extensions/claude-globals/index.test.ts +77 -0
  15. package/extensions/gemini-image/credentials.test.ts +130 -0
  16. package/extensions/gemini-image/credentials.ts +53 -0
  17. package/extensions/gemini-image/index.test.ts +369 -0
  18. package/extensions/gemini-image/index.ts +313 -0
  19. package/extensions/gemini-image/models.test.ts +45 -0
  20. package/extensions/gemini-image/models.ts +50 -0
  21. package/extensions/gemini-image/pricing.test.ts +95 -0
  22. package/extensions/gemini-image/pricing.ts +102 -0
  23. package/extensions/grimoire/index.test.ts +244 -0
  24. package/extensions/multi-edit/classic.test.ts +274 -0
  25. package/extensions/multi-edit/classic.ts +435 -0
  26. package/extensions/multi-edit/diff.test.ts +65 -0
  27. package/extensions/multi-edit/diff.ts +143 -0
  28. package/extensions/multi-edit/index.test.ts +170 -0
  29. package/extensions/multi-edit/index.ts +267 -0
  30. package/extensions/multi-edit/patch.test.ts +242 -0
  31. package/extensions/multi-edit/patch.ts +463 -0
  32. package/extensions/multi-edit/types.ts +53 -0
  33. package/extensions/multi-edit/workspace.test.ts +165 -0
  34. package/extensions/multi-edit/workspace.ts +85 -0
  35. package/package.json +9 -3
@@ -0,0 +1,242 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { applyPatchOperations, parsePatch } from "./patch.ts";
6
+ import { createRealWorkspace, createVirtualWorkspace } from "./workspace.ts";
7
+
8
+ const piStub = {
9
+ events: { emit: () => {} },
10
+ } as unknown as Parameters<typeof createRealWorkspace>[0];
11
+
12
+ describe("parsePatch", () => {
13
+ it("throws on empty patch", () => {
14
+ expect(() => parsePatch("")).toThrow(/empty or invalid/i);
15
+ expect(() => parsePatch(" ")).toThrow(/empty or invalid/i);
16
+ });
17
+
18
+ it("throws when missing Begin directive", () => {
19
+ expect(() => parsePatch("*** End Patch")).toThrow(/first line/i);
20
+ });
21
+
22
+ it("throws when missing End directive", () => {
23
+ expect(() => parsePatch("*** Begin Patch")).toThrow(/last line/i);
24
+ });
25
+
26
+ it("parses Add File with + lines", () => {
27
+ const ops = parsePatch(
28
+ "*** Begin Patch\n*** Add File: new.ts\n+export const x = 1;\n+export const y = 2;\n*** End Patch",
29
+ );
30
+ expect(ops).toHaveLength(1);
31
+ expect(ops[0]).toMatchObject({ kind: "add", path: "new.ts" });
32
+ expect((ops[0] as { kind: "add"; contents: string }).contents).toBe("export const x = 1;\nexport const y = 2;\n");
33
+ });
34
+
35
+ it("parses empty Add File to empty contents", () => {
36
+ const ops = parsePatch("*** Begin Patch\n*** Add File: empty.ts\n*** End Patch");
37
+ expect((ops[0] as { kind: "add"; contents: string }).contents).toBe("");
38
+ });
39
+
40
+ it("rejects Add File line without + prefix", () => {
41
+ expect(() =>
42
+ parsePatch("*** Begin Patch\n*** Add File: bad.ts\nno-plus-prefix\n*** End Patch"),
43
+ ).toThrow(/must start with '\+'/);
44
+ });
45
+
46
+ it("parses Delete File", () => {
47
+ const ops = parsePatch("*** Begin Patch\n*** Delete File: gone.ts\n*** End Patch");
48
+ expect(ops[0]).toEqual({ kind: "delete", path: "gone.ts" });
49
+ });
50
+
51
+ it("parses Update File with a hunk", () => {
52
+ const ops = parsePatch(
53
+ "*** Begin Patch\n*** Update File: x.ts\n@@\n-old\n+new\n*** End Patch",
54
+ );
55
+ expect(ops[0]).toMatchObject({ kind: "update", path: "x.ts" });
56
+ const hunks = (ops[0] as { kind: "update"; hunks: Array<{ oldBlock: string; newBlock: string }> }).hunks;
57
+ expect(hunks).toHaveLength(1);
58
+ expect(hunks[0].oldBlock).toBe("old");
59
+ expect(hunks[0].newBlock).toBe("new");
60
+ });
61
+
62
+ it("rejects Update File with *** Move to:", () => {
63
+ expect(() =>
64
+ parsePatch("*** Begin Patch\n*** Update File: x.ts\n*** Move to: y.ts\n*** End Patch"),
65
+ ).toThrow(/Move operations/i);
66
+ });
67
+
68
+ it("rejects Update File without any hunk", () => {
69
+ expect(() =>
70
+ parsePatch("*** Begin Patch\n*** Update File: x.ts\n*** End Patch"),
71
+ ).toThrow(/empty/);
72
+ });
73
+
74
+ it("rejects hunk without @@ header", () => {
75
+ expect(() =>
76
+ parsePatch("*** Begin Patch\n*** Update File: x.ts\n-old\n+new\n*** End Patch"),
77
+ ).toThrow(/@@/);
78
+ });
79
+
80
+ it("parses hunk with @@ context prefix after header", () => {
81
+ const ops = parsePatch(
82
+ "*** Begin Patch\n*** Update File: x.ts\n@@ function foo() {\n-old\n+new\n*** End Patch",
83
+ );
84
+ const hunk = (ops[0] as { hunks: Array<{ contextPrefix?: string }> }).hunks[0];
85
+ expect(hunk.contextPrefix).toBe("function foo() {");
86
+ });
87
+
88
+ it("parses context lines (starting with space) inside a hunk", () => {
89
+ const ops = parsePatch(
90
+ "*** Begin Patch\n*** Update File: x.ts\n@@\n context\n-old\n+new\n*** End Patch",
91
+ );
92
+ const hunk = (ops[0] as { hunks: Array<{ oldBlock: string; newBlock: string }> }).hunks[0];
93
+ expect(hunk.oldBlock).toContain("context");
94
+ expect(hunk.newBlock).toContain("context");
95
+ });
96
+
97
+ it("handles CRLF patches by normalizing to LF", () => {
98
+ const patch = "*** Begin Patch\r\n*** Delete File: a.ts\r\n*** End Patch";
99
+ expect(parsePatch(patch)).toEqual([{ kind: "delete", path: "a.ts" }]);
100
+ });
101
+
102
+ it("rejects unknown directive", () => {
103
+ expect(() =>
104
+ parsePatch("*** Begin Patch\n*** Frobnicate: x.ts\n*** End Patch"),
105
+ ).toThrow(/valid hunk header/);
106
+ });
107
+
108
+ it("skips blank lines between operations", () => {
109
+ const ops = parsePatch(
110
+ "*** Begin Patch\n\n*** Delete File: a.ts\n\n*** Delete File: b.ts\n*** End Patch",
111
+ );
112
+ expect(ops).toHaveLength(2);
113
+ });
114
+ });
115
+
116
+ describe("applyPatchOperations", () => {
117
+ let root: string;
118
+
119
+ beforeEach(() => {
120
+ root = mkdtempSync(join(tmpdir(), "patch-apply-"));
121
+ });
122
+
123
+ afterEach(() => {
124
+ rmSync(root, { recursive: true, force: true });
125
+ });
126
+
127
+ it("applies Add File on virtual workspace", async () => {
128
+ const ws = createVirtualWorkspace(root);
129
+ const ops = parsePatch("*** Begin Patch\n*** Add File: new.ts\n+hello\n*** End Patch");
130
+ const results = await applyPatchOperations(ops, ws, root, undefined, { collectDiff: false });
131
+ expect(results[0].message).toMatch(/Added|Created/i);
132
+ expect(await ws.readText(join(root, "new.ts"))).toBe("hello\n");
133
+ });
134
+
135
+ it("applies Delete File on virtual workspace", async () => {
136
+ const file = join(root, "gone.ts");
137
+ writeFileSync(file, "x", "utf-8");
138
+ const ws = createVirtualWorkspace(root);
139
+ const ops = parsePatch("*** Begin Patch\n*** Delete File: gone.ts\n*** End Patch");
140
+ const results = await applyPatchOperations(ops, ws, root, undefined, { collectDiff: false });
141
+ expect(results[0].message).toMatch(/Deleted|Removed/i);
142
+ expect(await ws.exists(file)).toBe(false);
143
+ });
144
+
145
+ it("applies Update File hunk on real workspace and writes disk", async () => {
146
+ const file = join(root, "x.ts");
147
+ writeFileSync(file, "alpha\nold\nbeta\n", "utf-8");
148
+ const ws = createRealWorkspace(piStub);
149
+ const ops = parsePatch(
150
+ "*** Begin Patch\n*** Update File: x.ts\n@@\n-old\n+new\n*** End Patch",
151
+ );
152
+ const results = await applyPatchOperations(ops, ws, root, undefined, { collectDiff: true });
153
+ expect(results[0].message).toMatch(/Updated|Modified/i);
154
+ expect(readFileSync(file, "utf-8")).toContain("new");
155
+ });
156
+
157
+ it("rejects Update when file doesn't exist", async () => {
158
+ const ws = createVirtualWorkspace(root);
159
+ const ops = parsePatch(
160
+ "*** Begin Patch\n*** Update File: missing.ts\n@@\n-old\n+new\n*** End Patch",
161
+ );
162
+ await expect(
163
+ applyPatchOperations(ops, ws, root, undefined, { collectDiff: false }),
164
+ ).rejects.toThrow();
165
+ });
166
+
167
+ it("rejects Delete when file doesn't exist", async () => {
168
+ const ws = createVirtualWorkspace(root);
169
+ const ops = parsePatch("*** Begin Patch\n*** Delete File: nope.ts\n*** End Patch");
170
+ await expect(
171
+ applyPatchOperations(ops, ws, root, undefined, { collectDiff: false }),
172
+ ).rejects.toThrow();
173
+ });
174
+
175
+ it("honors abort signal mid-batch", async () => {
176
+ const ws = createVirtualWorkspace(root);
177
+ const file = join(root, "a.ts");
178
+ writeFileSync(file, "x", "utf-8");
179
+ const ops = parsePatch("*** Begin Patch\n*** Delete File: a.ts\n*** End Patch");
180
+ const ac = new AbortController();
181
+ ac.abort();
182
+ await expect(
183
+ applyPatchOperations(ops, ws, root, ac.signal, { collectDiff: false }),
184
+ ).rejects.toThrow();
185
+ });
186
+
187
+ it("finds hunk using contextPrefix to disambiguate", async () => {
188
+ const file = join(root, "x.ts");
189
+ writeFileSync(file, "dup\nfunction A() {\ndup\n}\nfunction B() {\ndup\n}\n", "utf-8");
190
+ const ws = createRealWorkspace(piStub);
191
+ // Update only under function B
192
+ const ops = parsePatch(
193
+ "*** Begin Patch\n*** Update File: x.ts\n@@ function B() {\n-dup\n+changed\n*** End Patch",
194
+ );
195
+ await applyPatchOperations(ops, ws, root, undefined, { collectDiff: false });
196
+ const content = readFileSync(file, "utf-8");
197
+ // Only the one under B should change.
198
+ expect(content.split("changed")).toHaveLength(2);
199
+ });
200
+
201
+ it("rejects hunk whose oldBlock is not found in the file", async () => {
202
+ const file = join(root, "x.ts");
203
+ writeFileSync(file, "completely unrelated\n", "utf-8");
204
+ const ws = createVirtualWorkspace(root);
205
+ const ops = parsePatch(
206
+ "*** Begin Patch\n*** Update File: x.ts\n@@\n-missing-text\n+replacement\n*** End Patch",
207
+ );
208
+ await expect(
209
+ applyPatchOperations(ops, ws, root, undefined, { collectDiff: false }),
210
+ ).rejects.toThrow();
211
+ });
212
+
213
+ it("handles Add that overwrites existing file", async () => {
214
+ const file = join(root, "exists.ts");
215
+ writeFileSync(file, "old content\n", "utf-8");
216
+ const ws = createRealWorkspace(piStub);
217
+ const ops = parsePatch("*** Begin Patch\n*** Add File: exists.ts\n+fresh\n*** End Patch");
218
+ await applyPatchOperations(ops, ws, root, undefined, { collectDiff: false });
219
+ expect(readFileSync(file, "utf-8")).toBe("fresh\n");
220
+ });
221
+
222
+ it("resolves relative paths against cwd", async () => {
223
+ const sub = join(root, "sub");
224
+ mkdirSync(sub);
225
+ const ws = createVirtualWorkspace(root);
226
+ const ops = parsePatch("*** Begin Patch\n*** Add File: sub/new.ts\n+hi\n*** End Patch");
227
+ await applyPatchOperations(ops, ws, root, undefined, { collectDiff: false });
228
+ expect(await ws.exists(join(root, "sub", "new.ts"))).toBe(true);
229
+ });
230
+
231
+ it("produces a diff when collectDiff is true", async () => {
232
+ const file = join(root, "x.ts");
233
+ writeFileSync(file, "old\n", "utf-8");
234
+ const ws = createRealWorkspace(piStub);
235
+ const ops = parsePatch(
236
+ "*** Begin Patch\n*** Update File: x.ts\n@@\n-old\n+new\n*** End Patch",
237
+ );
238
+ const results = await applyPatchOperations(ops, ws, root, undefined, { collectDiff: true });
239
+ expect(results[0].diff).toBeDefined();
240
+ expect(results[0].firstChangedLine).toBeDefined();
241
+ });
242
+ });
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Codex-style apply_patch engine.
3
+ *
4
+ * Accepts payloads bracketed by `*** Begin Patch` / `*** End Patch` and
5
+ * supports three operations: Add File, Delete File, Update File.
6
+ *
7
+ * Design — this is a recursive-descent parser over a line cursor. Each
8
+ * grammar rule owns a small function; there is no shared mutable index
9
+ * bookkeeping or nested-loop state machine. Hunks are stored as raw
10
+ * `oldBlock`/`newBlock` strings so the applier can run `indexOf` directly
11
+ * instead of reconstructing line arrays on each apply.
12
+ *
13
+ * Compatibility notes (vs the original Codex apply_patch format):
14
+ * - Hunks MUST start with a "@@" header. Missing headers are rejected.
15
+ * - Only exact-match hunk anchoring — no 4-pass fuzzy `seekSequence`.
16
+ * - `*** End of File` sentinel hunks are not recognized.
17
+ * - `*** Move to:` is rejected.
18
+ */
19
+
20
+ import { isAbsolute, resolve as resolvePath } from "path";
21
+
22
+ import { generateDiffString } from "./diff.ts";
23
+ import type {
24
+ Hunk,
25
+ PatchOperation,
26
+ PatchOpResult,
27
+ Workspace,
28
+ } from "./types.ts";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Line cursor
32
+ // ---------------------------------------------------------------------------
33
+
34
+ class LineCursor {
35
+ private pos = 0;
36
+ constructor(private readonly lines: readonly string[]) {}
37
+
38
+ peek(): string | undefined {
39
+ return this.lines[this.pos];
40
+ }
41
+
42
+ next(): string | undefined {
43
+ return this.lines[this.pos++];
44
+ }
45
+
46
+ hasMore(): boolean {
47
+ return this.pos < this.lines.length;
48
+ }
49
+
50
+ /** Consume lines while the predicate holds. Returns the number consumed. */
51
+ skipWhile(pred: (line: string) => boolean): number {
52
+ let count = 0;
53
+ while (this.hasMore() && pred(this.peek()!)) {
54
+ this.pos++;
55
+ count++;
56
+ }
57
+ return count;
58
+ }
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Parser
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const DIRECTIVE_BEGIN = "*** Begin Patch";
66
+ const DIRECTIVE_END = "*** End Patch";
67
+ const DIRECTIVE_ADD = "*** Add File: ";
68
+ const DIRECTIVE_DELETE = "*** Delete File: ";
69
+ const DIRECTIVE_UPDATE = "*** Update File: ";
70
+ const DIRECTIVE_MOVE = "*** Move to: ";
71
+
72
+ const isBlank = (line: string): boolean => line.trim() === "";
73
+ const isDirective = (line: string): boolean =>
74
+ line.trimEnd().startsWith("*** ");
75
+
76
+ export function parsePatch(patchText: string): PatchOperation[] {
77
+ const normalized = patchText.replace(/\r\n/g, "\n").trim();
78
+ if (normalized.length === 0) {
79
+ throw new Error("Patch is empty or invalid");
80
+ }
81
+
82
+ const lines = normalized.split("\n");
83
+ if (lines[0].trim() !== DIRECTIVE_BEGIN) {
84
+ throw new Error(`The first line of the patch must be '${DIRECTIVE_BEGIN}'`);
85
+ }
86
+ if (lines[lines.length - 1].trim() !== DIRECTIVE_END) {
87
+ throw new Error(`The last line of the patch must be '${DIRECTIVE_END}'`);
88
+ }
89
+
90
+ // Cursor over the interior (strip Begin and End sentinels).
91
+ const cursor = new LineCursor(lines.slice(1, -1));
92
+ const operations: PatchOperation[] = [];
93
+
94
+ while (cursor.hasMore()) {
95
+ cursor.skipWhile(isBlank);
96
+ if (!cursor.hasMore()) break;
97
+
98
+ const header = cursor.next()!.trimEnd();
99
+
100
+ if (header.startsWith(DIRECTIVE_ADD)) {
101
+ operations.push(parseAddFile(header.slice(DIRECTIVE_ADD.length), cursor));
102
+ continue;
103
+ }
104
+ if (header.startsWith(DIRECTIVE_DELETE)) {
105
+ operations.push({
106
+ kind: "delete",
107
+ path: header.slice(DIRECTIVE_DELETE.length),
108
+ });
109
+ continue;
110
+ }
111
+ if (header.startsWith(DIRECTIVE_UPDATE)) {
112
+ operations.push(
113
+ parseUpdateFile(header.slice(DIRECTIVE_UPDATE.length), cursor),
114
+ );
115
+ continue;
116
+ }
117
+
118
+ throw new Error(
119
+ `'${header}' is not a valid hunk header. Valid headers: '${DIRECTIVE_ADD.trim()}', '${DIRECTIVE_DELETE.trim()}', '${DIRECTIVE_UPDATE.trim()}'`,
120
+ );
121
+ }
122
+
123
+ return operations;
124
+ }
125
+
126
+ function parseAddFile(path: string, cursor: LineCursor): PatchOperation {
127
+ const bodyLines: string[] = [];
128
+
129
+ while (cursor.hasMore()) {
130
+ const line = cursor.peek()!;
131
+ if (isDirective(line)) break;
132
+ cursor.next();
133
+ if (!line.startsWith("+")) {
134
+ throw new Error(
135
+ `Invalid add-file line '${line}'. Add-file lines must start with '+'`,
136
+ );
137
+ }
138
+ bodyLines.push(line.slice(1));
139
+ }
140
+
141
+ const contents = bodyLines.length > 0 ? `${bodyLines.join("\n")}\n` : "";
142
+ return { kind: "add", path, contents };
143
+ }
144
+
145
+ function parseUpdateFile(path: string, cursor: LineCursor): PatchOperation {
146
+ // Move-to is explicitly rejected — we only support in-place updates.
147
+ const lookahead = cursor.peek();
148
+ if (
149
+ lookahead !== undefined &&
150
+ lookahead.trimEnd().startsWith(DIRECTIVE_MOVE)
151
+ ) {
152
+ throw new Error("Patch move operations (*** Move to:) are not supported.");
153
+ }
154
+
155
+ const hunks: Hunk[] = [];
156
+
157
+ while (cursor.hasMore()) {
158
+ cursor.skipWhile(isBlank);
159
+ if (!cursor.hasMore()) break;
160
+
161
+ const line = cursor.peek()!;
162
+ if (isDirective(line)) break;
163
+
164
+ hunks.push(parseHunk(path, cursor));
165
+ }
166
+
167
+ if (hunks.length === 0) {
168
+ throw new Error(`Update file hunk for path '${path}' is empty`);
169
+ }
170
+
171
+ return { kind: "update", path, hunks };
172
+ }
173
+
174
+ function parseHunk(path: string, cursor: LineCursor): Hunk {
175
+ const header = cursor.next();
176
+ if (header === undefined) {
177
+ throw new Error(`Expected @@ hunk header in '${path}', got end of patch`);
178
+ }
179
+
180
+ const trimmed = header.trimEnd();
181
+ let contextPrefix: string | undefined;
182
+ if (trimmed === "@@") {
183
+ contextPrefix = undefined;
184
+ } else if (trimmed.startsWith("@@ ")) {
185
+ contextPrefix = trimmed.slice(3);
186
+ } else {
187
+ throw new Error(
188
+ `Expected update hunk to start with @@ context marker, got: '${header}'`,
189
+ );
190
+ }
191
+
192
+ const oldLines: string[] = [];
193
+ const newLines: string[] = [];
194
+
195
+ while (cursor.hasMore()) {
196
+ const raw = cursor.peek()!;
197
+ const trimEnd = raw.trimEnd();
198
+
199
+ // Any directive or next hunk header ends the current hunk.
200
+ if (trimEnd.startsWith("@@") || isDirective(raw)) break;
201
+
202
+ cursor.next();
203
+
204
+ if (raw.length === 0) {
205
+ // Blank line inside a hunk is treated as an unchanged empty line.
206
+ oldLines.push("");
207
+ newLines.push("");
208
+ continue;
209
+ }
210
+
211
+ const marker = raw[0];
212
+ const body = raw.slice(1);
213
+
214
+ if (marker === " ") {
215
+ oldLines.push(body);
216
+ newLines.push(body);
217
+ } else if (marker === "-") {
218
+ oldLines.push(body);
219
+ } else if (marker === "+") {
220
+ newLines.push(body);
221
+ } else {
222
+ throw new Error(
223
+ `Unexpected line found in update hunk for '${path}': '${raw}'. Every line should start with ' ', '+', or '-'.`,
224
+ );
225
+ }
226
+ }
227
+
228
+ if (oldLines.length === 0 && newLines.length === 0) {
229
+ throw new Error(`Update hunk for '${path}' does not contain any lines`);
230
+ }
231
+
232
+ return {
233
+ contextPrefix,
234
+ oldBlock: oldLines.join("\n"),
235
+ newBlock: newLines.join("\n"),
236
+ };
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Applier
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /**
244
+ * Apply a list of hunks to a file's content. Operates directly on the raw
245
+ * string via `indexOf` — no intermediate line-array reconstruction. A search
246
+ * cursor advances after each hunk so repeated `oldBlock` strings are matched
247
+ * in top-to-bottom order.
248
+ */
249
+ /**
250
+ * Find `needle` in `haystack` starting from `offset`. Tries exact match
251
+ * first; if that fails, retries with per-line trimEnd on both sides.
252
+ * Returns `{ pos, matchLength }` referencing the *original* haystack, or
253
+ * undefined when no match is found in either pass.
254
+ */
255
+ function findBlock(
256
+ haystack: string,
257
+ needle: string,
258
+ offset: number,
259
+ ): { pos: number; matchLength: number } | undefined {
260
+ const exact = haystack.indexOf(needle, offset);
261
+ if (exact !== -1) return { pos: exact, matchLength: needle.length };
262
+
263
+ // trimEnd pass: strip trailing whitespace per line on both sides.
264
+ const trimLine = (s: string) =>
265
+ s
266
+ .split("\n")
267
+ .map((l) => l.trimEnd())
268
+ .join("\n");
269
+
270
+ const normNeedle = trimLine(needle);
271
+ const normHaystack = trimLine(haystack);
272
+ if (normNeedle === needle && normHaystack === haystack) return undefined;
273
+
274
+ const normPos = normHaystack.indexOf(normNeedle, offset);
275
+ if (normPos === -1) return undefined;
276
+
277
+ // Map normalised position back to original haystack. Because trimEnd only
278
+ // removes characters (never adds), character positions can only shift
279
+ // right. Walk original lines to find the real byte offset for the matched
280
+ // line index.
281
+ const normPrefix = normHaystack.slice(0, normPos);
282
+ const startLineIdx = normPrefix.split("\n").length - 1;
283
+
284
+ const origLines = haystack.split("\n");
285
+ let realPos = 0;
286
+ for (let i = 0; i < startLineIdx; i++) realPos += origLines[i].length + 1;
287
+
288
+ // Compute the real length: count original bytes for the matched lines.
289
+ const matchedLineCount = normNeedle.split("\n").length;
290
+ let realEnd = realPos;
291
+ for (let i = startLineIdx; i < startLineIdx + matchedLineCount; i++) {
292
+ realEnd += origLines[i].length + 1;
293
+ }
294
+ realEnd--; // exclude trailing \n after last line
295
+
296
+ // If the needle ended with \n, include it.
297
+ if (needle.endsWith("\n") && realEnd + 1 <= haystack.length) realEnd++;
298
+
299
+ return { pos: realPos, matchLength: realEnd - realPos };
300
+ }
301
+
302
+ function applyHunks(filePath: string, content: string, hunks: Hunk[]): string {
303
+ let result = content;
304
+ let cursor = 0;
305
+
306
+ for (const hunk of hunks) {
307
+ let searchFrom = cursor;
308
+
309
+ if (hunk.contextPrefix !== undefined) {
310
+ const ctxMatch = findBlock(result, hunk.contextPrefix, searchFrom);
311
+ if (ctxMatch === undefined) {
312
+ throw new Error(
313
+ `Failed to find context '${hunk.contextPrefix}' in ${filePath}`,
314
+ );
315
+ }
316
+ searchFrom = ctxMatch.pos + ctxMatch.matchLength;
317
+ }
318
+
319
+ if (hunk.oldBlock === "") {
320
+ // Pure insertion: append newBlock at the anchor (or end-of-file).
321
+ const insertAt =
322
+ hunk.contextPrefix !== undefined ? searchFrom : result.length;
323
+ const needsNewline = insertAt > 0 && result[insertAt - 1] !== "\n";
324
+ const prefix = needsNewline ? "\n" : "";
325
+ result =
326
+ result.slice(0, insertAt) +
327
+ prefix +
328
+ hunk.newBlock +
329
+ result.slice(insertAt);
330
+ cursor = insertAt + prefix.length + hunk.newBlock.length;
331
+ continue;
332
+ }
333
+
334
+ const match = findBlock(result, hunk.oldBlock, searchFrom);
335
+ if (match === undefined) {
336
+ throw new Error(
337
+ `Failed to find expected lines in ${filePath}:\n${hunk.oldBlock}`,
338
+ );
339
+ }
340
+
341
+ result =
342
+ result.slice(0, match.pos) +
343
+ hunk.newBlock +
344
+ result.slice(match.pos + match.matchLength);
345
+ cursor = match.pos + hunk.newBlock.length;
346
+ }
347
+
348
+ // Preserve the "file ends with newline" invariant upstream relies on.
349
+ if (!result.endsWith("\n")) {
350
+ result = `${result}\n`;
351
+ }
352
+
353
+ return result;
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Orchestration
358
+ // ---------------------------------------------------------------------------
359
+
360
+ function resolvePatchPath(cwd: string, filePath: string): string {
361
+ const trimmed = filePath.trim();
362
+ if (!trimmed) {
363
+ throw new Error("Patch path cannot be empty");
364
+ }
365
+ return isAbsolute(trimmed) ? resolvePath(trimmed) : resolvePath(cwd, trimmed);
366
+ }
367
+
368
+ function ensureTrailingNewline(content: string): string {
369
+ return content.endsWith("\n") ? content : `${content}\n`;
370
+ }
371
+
372
+ export async function applyPatchOperations(
373
+ ops: PatchOperation[],
374
+ workspace: Workspace,
375
+ cwd: string,
376
+ signal?: AbortSignal,
377
+ options?: { collectDiff?: boolean },
378
+ ): Promise<PatchOpResult[]> {
379
+ const results: PatchOpResult[] = [];
380
+ const collectDiff = options?.collectDiff ?? false;
381
+
382
+ for (const op of ops) {
383
+ if (signal?.aborted) {
384
+ throw new Error("Operation aborted");
385
+ }
386
+
387
+ switch (op.kind) {
388
+ case "add": {
389
+ const abs = resolvePatchPath(cwd, op.path);
390
+ const oldText =
391
+ collectDiff && (await workspace.exists(abs))
392
+ ? await workspace.readText(abs)
393
+ : "";
394
+ const newText = ensureTrailingNewline(op.contents);
395
+ await workspace.writeText(abs, newText);
396
+ results.push(
397
+ buildOpResult(
398
+ op.path,
399
+ `Added file ${op.path}.`,
400
+ oldText,
401
+ newText,
402
+ collectDiff,
403
+ ),
404
+ );
405
+ break;
406
+ }
407
+
408
+ case "delete": {
409
+ const abs = resolvePatchPath(cwd, op.path);
410
+ if (!(await workspace.exists(abs))) {
411
+ throw new Error(`Failed to delete ${op.path}: file does not exist`);
412
+ }
413
+ const oldText = collectDiff ? await workspace.readText(abs) : "";
414
+ await workspace.deleteFile(abs);
415
+ results.push(
416
+ buildOpResult(
417
+ op.path,
418
+ `Deleted file ${op.path}.`,
419
+ oldText,
420
+ "",
421
+ collectDiff,
422
+ ),
423
+ );
424
+ break;
425
+ }
426
+
427
+ case "update": {
428
+ const abs = resolvePatchPath(cwd, op.path);
429
+ const sourceText = await workspace.readText(abs);
430
+ const updated = applyHunks(op.path, sourceText, op.hunks);
431
+ await workspace.writeText(abs, updated);
432
+ results.push(
433
+ buildOpResult(
434
+ op.path,
435
+ `Updated ${op.path}.`,
436
+ sourceText,
437
+ updated,
438
+ collectDiff,
439
+ ),
440
+ );
441
+ break;
442
+ }
443
+ }
444
+ }
445
+
446
+ return results;
447
+ }
448
+
449
+ function buildOpResult(
450
+ path: string,
451
+ message: string,
452
+ oldText: string,
453
+ newText: string,
454
+ collectDiff: boolean,
455
+ ): PatchOpResult {
456
+ const result: PatchOpResult = { path, message };
457
+ if (collectDiff) {
458
+ const { diff, firstChangedLine } = generateDiffString(oldText, newText);
459
+ result.diff = diff;
460
+ result.firstChangedLine = firstChangedLine;
461
+ }
462
+ return result;
463
+ }