@astrofoundry/pi-astro 0.5.1 → 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.
@@ -0,0 +1,244 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // Mock node:child_process BEFORE importing the module under test.
5
+ vi.mock("node:child_process", () => {
6
+ const mod = {
7
+ execFileSync: vi.fn(),
8
+ spawn: vi.fn(),
9
+ };
10
+ return mod;
11
+ });
12
+
13
+ type SpawnFactory = () => { proc: EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: (sig: string) => void }; emitClose: (code: number) => void; emitError: (err: Error) => void; emitData: (where: "stdout" | "stderr", chunk: string) => void };
14
+
15
+ function makeFakeProc(): ReturnType<SpawnFactory> {
16
+ const proc = Object.assign(new EventEmitter(), {
17
+ stdout: new EventEmitter(),
18
+ stderr: new EventEmitter(),
19
+ kill: vi.fn(),
20
+ });
21
+ return {
22
+ proc,
23
+ emitClose: (code: number) => proc.emit("close", code),
24
+ emitError: (err: Error) => proc.emit("error", err),
25
+ emitData: (where, chunk) => proc[where].emit("data", Buffer.from(chunk)),
26
+ };
27
+ }
28
+
29
+ interface CapturedTool {
30
+ name: string;
31
+ description: string;
32
+ promptGuidelines?: string[];
33
+ execute: (id: string, input: { query: string; source?: string; top?: number }, signal?: AbortSignal, onUpdate?: unknown, ctx?: unknown) => Promise<unknown>;
34
+ }
35
+
36
+ interface CapturedPi {
37
+ registerTool: (tool: CapturedTool) => void;
38
+ tools: CapturedTool[];
39
+ }
40
+
41
+ function makePi(): CapturedPi {
42
+ const tools: CapturedTool[] = [];
43
+ return {
44
+ tools,
45
+ registerTool: (tool) => {
46
+ tools.push(tool);
47
+ },
48
+ };
49
+ }
50
+
51
+ function makeCtx(): { ui: { notify: ReturnType<typeof vi.fn> }; notifications: Array<[string, string | undefined]> } {
52
+ const notifications: Array<[string, string | undefined]> = [];
53
+ return {
54
+ notifications,
55
+ ui: {
56
+ notify: vi.fn((msg: string, type?: string) => {
57
+ notifications.push([msg, type]);
58
+ }),
59
+ },
60
+ };
61
+ }
62
+
63
+ describe("grimoire extension", () => {
64
+ beforeEach(() => {
65
+ vi.resetModules();
66
+ vi.clearAllMocks();
67
+ });
68
+
69
+ afterEach(() => {
70
+ vi.clearAllMocks();
71
+ });
72
+
73
+ it("loads --help into promptGuidelines when grimoire is available", async () => {
74
+ const { execFileSync } = await import("node:child_process");
75
+ vi.mocked(execFileSync).mockReturnValue("FAKE HELP OUTPUT" as unknown as ReturnType<typeof execFileSync>);
76
+
77
+ const mod = await import("./index.ts");
78
+ const pi = makePi();
79
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
80
+
81
+ const tool = pi.tools[0];
82
+ expect(tool.name).toBe("grimoire");
83
+ expect(tool.promptGuidelines?.some((g) => g.includes("FAKE HELP OUTPUT"))).toBe(true);
84
+ });
85
+
86
+ it("falls back to 'not available' promptGuidelines when grimoire CLI is missing", async () => {
87
+ const { execFileSync } = await import("node:child_process");
88
+ vi.mocked(execFileSync).mockImplementation(() => {
89
+ throw new Error("ENOENT");
90
+ });
91
+
92
+ const mod = await import("./index.ts");
93
+ const pi = makePi();
94
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
95
+
96
+ expect(pi.tools[0].promptGuidelines?.[0]).toMatch(/NOT available/i);
97
+ });
98
+
99
+ it("execute throws when CLI was not available at load time", async () => {
100
+ const { execFileSync } = await import("node:child_process");
101
+ vi.mocked(execFileSync).mockImplementation(() => {
102
+ throw new Error("ENOENT");
103
+ });
104
+ const mod = await import("./index.ts");
105
+ const pi = makePi();
106
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
107
+ const ctx = makeCtx();
108
+ await expect(
109
+ pi.tools[0].execute("t1", { query: "x" }, undefined, undefined, ctx),
110
+ ).rejects.toThrow(/not installed/i);
111
+ expect(ctx.notifications.some((n) => n[1] === "error")).toBe(true);
112
+ });
113
+
114
+ it("execute returns stdout when grimoire exits 0 with results", async () => {
115
+ const { execFileSync, spawn } = await import("node:child_process");
116
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
117
+ const fake = makeFakeProc();
118
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
119
+ const mod = await import("./index.ts");
120
+ const pi = makePi();
121
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
122
+ const ctx = makeCtx();
123
+ const promise = pi.tools[0].execute("t1", { query: "react hooks" }, undefined, undefined, ctx);
124
+ fake.emitData("stdout", "result line 1\nresult line 2\n");
125
+ fake.emitClose(0);
126
+ const result = (await promise) as { content: Array<{ text: string }> };
127
+ expect(result.content[0].text).toContain("result line 1");
128
+ });
129
+
130
+ it("execute with source + top passes them through as CLI args", async () => {
131
+ const { execFileSync, spawn } = await import("node:child_process");
132
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
133
+ const fake = makeFakeProc();
134
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
135
+ const mod = await import("./index.ts");
136
+ const pi = makePi();
137
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
138
+ const ctx = makeCtx();
139
+ const promise = pi.tools[0].execute("t", { query: "q", source: "react", top: 3 }, undefined, undefined, ctx);
140
+ fake.emitData("stdout", "out");
141
+ fake.emitClose(0);
142
+ await promise;
143
+ const callArgs = vi.mocked(spawn).mock.calls[0][1] as string[];
144
+ expect(callArgs).toContain("--source");
145
+ expect(callArgs).toContain("react");
146
+ expect(callArgs).toContain("--top");
147
+ expect(callArgs).toContain("3");
148
+ });
149
+
150
+ it("execute notifies warning and returns soft result on no-results", async () => {
151
+ const { execFileSync, spawn } = await import("node:child_process");
152
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
153
+ const fake = makeFakeProc();
154
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
155
+ const mod = await import("./index.ts");
156
+ const pi = makePi();
157
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
158
+ const ctx = makeCtx();
159
+ const promise = pi.tools[0].execute("t", { query: "zzz" }, undefined, undefined, ctx);
160
+ fake.emitData("stdout", "No results found.\n");
161
+ fake.emitClose(0);
162
+ const result = (await promise) as { content: Array<{ text: string }> };
163
+ expect(result.content[0].text).toMatch(/No results found/);
164
+ expect(ctx.notifications.some((n) => n[1] === "warning")).toBe(true);
165
+ });
166
+
167
+ it("execute also treats empty stdout as no-results and suggests widening scope when source given", async () => {
168
+ const { execFileSync, spawn } = await import("node:child_process");
169
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
170
+ const fake = makeFakeProc();
171
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
172
+ const mod = await import("./index.ts");
173
+ const pi = makePi();
174
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
175
+ const ctx = makeCtx();
176
+ const promise = pi.tools[0].execute("t", { query: "zzz", source: "react" }, undefined, undefined, ctx);
177
+ fake.emitData("stdout", "");
178
+ fake.emitClose(0);
179
+ const result = (await promise) as { content: Array<{ text: string }> };
180
+ expect(result.content[0].text).toMatch(/widening scope/);
181
+ });
182
+
183
+ it("execute rejects on non-zero exit code", async () => {
184
+ const { execFileSync, spawn } = await import("node:child_process");
185
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
186
+ const fake = makeFakeProc();
187
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
188
+ const mod = await import("./index.ts");
189
+ const pi = makePi();
190
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
191
+ const ctx = makeCtx();
192
+ const promise = pi.tools[0].execute("t", { query: "q" }, undefined, undefined, ctx);
193
+ fake.emitData("stderr", "bad source");
194
+ fake.emitClose(1);
195
+ await expect(promise).rejects.toThrow(/exited with code 1/);
196
+ });
197
+
198
+ it("execute rejects on ENOENT at spawn time with install guidance + notify", async () => {
199
+ const { execFileSync, spawn } = await import("node:child_process");
200
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
201
+ const fake = makeFakeProc();
202
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
203
+ const mod = await import("./index.ts");
204
+ const pi = makePi();
205
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
206
+ const ctx = makeCtx();
207
+ const promise = pi.tools[0].execute("t", { query: "q" }, undefined, undefined, ctx);
208
+ const err = Object.assign(new Error("ENOENT"), { code: "ENOENT" });
209
+ fake.emitError(err);
210
+ await expect(promise).rejects.toThrow(/not installed/i);
211
+ expect(ctx.notifications.some((n) => n[1] === "error")).toBe(true);
212
+ });
213
+
214
+ it("execute rejects on non-ENOENT spawn error", async () => {
215
+ const { execFileSync, spawn } = await import("node:child_process");
216
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
217
+ const fake = makeFakeProc();
218
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
219
+ const mod = await import("./index.ts");
220
+ const pi = makePi();
221
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
222
+ const ctx = makeCtx();
223
+ const promise = pi.tools[0].execute("t", { query: "q" }, undefined, undefined, ctx);
224
+ fake.emitError(new Error("mystery"));
225
+ await expect(promise).rejects.toThrow(/failed to run/i);
226
+ });
227
+
228
+ it("execute kills process on abort signal", async () => {
229
+ const { execFileSync, spawn } = await import("node:child_process");
230
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
231
+ const fake = makeFakeProc();
232
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
233
+ const mod = await import("./index.ts");
234
+ const pi = makePi();
235
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
236
+ const ctx = makeCtx();
237
+ const ac = new AbortController();
238
+ const promise = pi.tools[0].execute("t", { query: "q" }, ac.signal, undefined, ctx);
239
+ ac.abort();
240
+ expect(fake.proc.kill).toHaveBeenCalledWith("SIGTERM");
241
+ fake.emitClose(0);
242
+ await expect(promise).resolves.toBeDefined();
243
+ });
244
+ });
@@ -0,0 +1,274 @@
1
+ import { chmodSync, 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 { applyClassicEdits, findActualString, formatResults } from "./classic.ts";
6
+ import { createRealWorkspace, createVirtualWorkspace } from "./workspace.ts";
7
+
8
+ const piStub = { events: { emit: () => {} } } as unknown as Parameters<typeof createRealWorkspace>[0];
9
+
10
+ describe("findActualString", () => {
11
+ it("returns exact match when content contains oldText", () => {
12
+ const r = findActualString("hello world", "world", 0);
13
+ expect(r).toEqual({ pos: 6, actualOldText: "world" });
14
+ });
15
+
16
+ it("returns undefined when no match possible", () => {
17
+ expect(findActualString("abc", "xyz", 0)).toBeUndefined();
18
+ });
19
+
20
+ it("honors offset — match before offset is skipped", () => {
21
+ const r = findActualString("foo foo", "foo", 1);
22
+ expect(r?.pos).toBe(4);
23
+ });
24
+
25
+ it("falls back to curly-quote normalization (straight query, curly content)", () => {
26
+ const content = "const x = “hello”;"; // curly double quotes in content
27
+ const r = findActualString(content, 'const x = "hello";', 0);
28
+ expect(r).toBeDefined();
29
+ expect(r?.pos).toBe(0);
30
+ });
31
+
32
+ it("falls back to trimEnd per-line tolerance", () => {
33
+ const content = "line one \nline two \n";
34
+ const r = findActualString(content, "line one\nline two", 0);
35
+ expect(r).toBeDefined();
36
+ });
37
+ });
38
+
39
+ describe("applyClassicEdits", () => {
40
+ let root: string;
41
+
42
+ beforeEach(() => {
43
+ root = mkdtempSync(join(tmpdir(), "classic-"));
44
+ });
45
+
46
+ afterEach(() => {
47
+ rmSync(root, { recursive: true, force: true });
48
+ });
49
+
50
+ it("applies a single edit on a real file", async () => {
51
+ const file = join(root, "a.ts");
52
+ writeFileSync(file, "before\n", "utf-8");
53
+ const ws = createRealWorkspace(piStub);
54
+ const res = await applyClassicEdits(
55
+ [{ path: file, oldText: "before", newText: "after" }],
56
+ ws,
57
+ root,
58
+ undefined,
59
+ { collectDiff: true },
60
+ );
61
+ expect(res[0].success).toBe(true);
62
+ expect(readFileSync(file, "utf-8")).toBe("after\n");
63
+ });
64
+
65
+ it("applies multiple edits in same file, top-to-bottom regardless of input order", async () => {
66
+ const file = join(root, "b.ts");
67
+ writeFileSync(file, "alpha\nbravo\ncharlie\n", "utf-8");
68
+ const ws = createRealWorkspace(piStub);
69
+ // provide them in REVERSE order
70
+ await applyClassicEdits(
71
+ [
72
+ { path: file, oldText: "charlie", newText: "CHARLIE" },
73
+ { path: file, oldText: "alpha", newText: "ALPHA" },
74
+ ],
75
+ ws,
76
+ root,
77
+ undefined,
78
+ { collectDiff: false },
79
+ );
80
+ expect(readFileSync(file, "utf-8")).toBe("ALPHA\nbravo\nCHARLIE\n");
81
+ });
82
+
83
+ it("throws when oldText not found (single edit)", async () => {
84
+ const file = join(root, "c.ts");
85
+ writeFileSync(file, "x", "utf-8");
86
+ const ws = createRealWorkspace(piStub);
87
+ await expect(
88
+ applyClassicEdits(
89
+ [{ path: file, oldText: "NOPE", newText: "N" }],
90
+ ws,
91
+ root,
92
+ undefined,
93
+ { collectDiff: false },
94
+ ),
95
+ ).rejects.toThrow();
96
+ });
97
+
98
+ it("throws when file missing", async () => {
99
+ const ws = createRealWorkspace(piStub);
100
+ await expect(
101
+ applyClassicEdits(
102
+ [{ path: join(root, "missing.ts"), oldText: "x", newText: "y" }],
103
+ ws,
104
+ root,
105
+ undefined,
106
+ { collectDiff: false },
107
+ ),
108
+ ).rejects.toThrow();
109
+ });
110
+
111
+ it("preflight on virtual workspace does NOT touch real files", async () => {
112
+ const file = join(root, "d.ts");
113
+ writeFileSync(file, "x", "utf-8");
114
+ const vws = createVirtualWorkspace(root);
115
+ await applyClassicEdits(
116
+ [{ path: file, oldText: "x", newText: "y" }],
117
+ vws,
118
+ root,
119
+ undefined,
120
+ { collectDiff: false },
121
+ );
122
+ expect(readFileSync(file, "utf-8")).toBe("x"); // unchanged on disk
123
+ });
124
+
125
+ it("rejects when the same oldText runs out of occurrences for duplicate edits", async () => {
126
+ const file = join(root, "e.ts");
127
+ writeFileSync(file, "one\n", "utf-8");
128
+ const ws = createRealWorkspace(piStub);
129
+ await expect(
130
+ applyClassicEdits(
131
+ [
132
+ { path: file, oldText: "one", newText: "two" },
133
+ { path: file, oldText: "one", newText: "three" }, // second occurrence doesn't exist
134
+ ],
135
+ ws,
136
+ root,
137
+ undefined,
138
+ { collectDiff: false },
139
+ ),
140
+ ).rejects.toThrow();
141
+ });
142
+
143
+ it("continueOnError applies successful edits even when siblings fail", async () => {
144
+ const f1 = join(root, "f1.ts");
145
+ const f2 = join(root, "f2.ts");
146
+ writeFileSync(f1, "X", "utf-8");
147
+ writeFileSync(f2, "Y", "utf-8");
148
+ const ws = createRealWorkspace(piStub);
149
+ const res = await applyClassicEdits(
150
+ [
151
+ { path: f1, oldText: "X", newText: "Xnew" },
152
+ { path: f2, oldText: "NOPE", newText: "N" }, // will fail
153
+ ],
154
+ ws,
155
+ root,
156
+ undefined,
157
+ { collectDiff: false, continueOnError: true },
158
+ );
159
+ expect(res[0].success).toBe(true);
160
+ expect(res[1].success).toBe(false);
161
+ expect(readFileSync(f1, "utf-8")).toBe("Xnew");
162
+ });
163
+
164
+ it("rollbackOnError restores already-written files when later file errors", async () => {
165
+ const f1 = join(root, "g1.ts");
166
+ writeFileSync(f1, "original", "utf-8");
167
+ const ws = createRealWorkspace(piStub);
168
+ // Simulate: f1 succeeds (writes "new"), then f2 edit can't match -> rollback f1
169
+ await expect(
170
+ applyClassicEdits(
171
+ [
172
+ { path: f1, oldText: "original", newText: "modified" },
173
+ { path: join(root, "missing.ts"), oldText: "x", newText: "y" },
174
+ ],
175
+ ws,
176
+ root,
177
+ undefined,
178
+ { collectDiff: false, rollbackOnError: true },
179
+ ),
180
+ ).rejects.toThrow();
181
+ expect(readFileSync(f1, "utf-8")).toBe("original");
182
+ });
183
+
184
+ it("signal abort stops mid-batch", async () => {
185
+ const f1 = join(root, "h.ts");
186
+ writeFileSync(f1, "x", "utf-8");
187
+ const ws = createRealWorkspace(piStub);
188
+ const ac = new AbortController();
189
+ ac.abort();
190
+ await expect(
191
+ applyClassicEdits(
192
+ [{ path: f1, oldText: "x", newText: "y" }],
193
+ ws,
194
+ root,
195
+ ac.signal,
196
+ { collectDiff: false },
197
+ ),
198
+ ).rejects.toThrow();
199
+ });
200
+
201
+ it("normalizes curly quotes from model input", async () => {
202
+ const file = join(root, "i.ts");
203
+ writeFileSync(file, 'const s = "hello";\n', "utf-8");
204
+ const ws = createRealWorkspace(piStub);
205
+ const res = await applyClassicEdits(
206
+ [{ path: file, oldText: "const s = “hello”;", newText: 'const s = "HI";' }],
207
+ ws,
208
+ root,
209
+ undefined,
210
+ { collectDiff: false },
211
+ );
212
+ expect(res[0].success).toBe(true);
213
+ });
214
+
215
+ it("no-op edit where newText equals oldText is treated as success", async () => {
216
+ const file = join(root, "j.ts");
217
+ writeFileSync(file, "same", "utf-8");
218
+ const ws = createRealWorkspace(piStub);
219
+ const res = await applyClassicEdits(
220
+ [{ path: file, oldText: "same", newText: "same" }],
221
+ ws,
222
+ root,
223
+ undefined,
224
+ { collectDiff: true },
225
+ );
226
+ expect(res[0].success).toBe(true);
227
+ });
228
+
229
+ it("resolves relative paths against cwd", async () => {
230
+ const file = join(root, "sub.ts");
231
+ writeFileSync(file, "x", "utf-8");
232
+ const ws = createRealWorkspace(piStub);
233
+ const res = await applyClassicEdits(
234
+ [{ path: "sub.ts", oldText: "x", newText: "y" }],
235
+ ws,
236
+ root,
237
+ undefined,
238
+ { collectDiff: false },
239
+ );
240
+ expect(res[0].success).toBe(true);
241
+ expect(readFileSync(file, "utf-8")).toBe("y");
242
+ });
243
+
244
+ it("preflight catches read-only file before real write", async () => {
245
+ const file = join(root, "ro.ts");
246
+ writeFileSync(file, "x", "utf-8");
247
+ chmodSync(file, 0o444);
248
+ const vws = createVirtualWorkspace(root);
249
+ await expect(
250
+ applyClassicEdits(
251
+ [{ path: file, oldText: "x", newText: "y" }],
252
+ vws,
253
+ root,
254
+ undefined,
255
+ { collectDiff: false },
256
+ ),
257
+ ).rejects.toThrow();
258
+ chmodSync(file, 0o644);
259
+ });
260
+ });
261
+
262
+ describe("formatResults", () => {
263
+ it("joins result messages", () => {
264
+ const out = formatResults(
265
+ [
266
+ { path: "a", success: true, message: "ok a" },
267
+ { path: "b", success: false, message: "fail b" },
268
+ ],
269
+ 2,
270
+ );
271
+ expect(out).toContain("ok a");
272
+ expect(out).toContain("fail b");
273
+ });
274
+ });