@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.
- package/README.md +4 -0
- package/extensions/astro-agents/discovery.test.ts +152 -0
- package/extensions/astro-agents/index.test.ts +208 -0
- package/extensions/astro-agents/spawn.test.ts +218 -0
- package/extensions/claude-globals/index.test.ts +77 -0
- package/extensions/gemini-image/credentials.test.ts +130 -0
- package/extensions/gemini-image/credentials.ts +53 -0
- package/extensions/gemini-image/index.test.ts +369 -0
- package/extensions/gemini-image/index.ts +313 -0
- package/extensions/gemini-image/models.test.ts +45 -0
- package/extensions/gemini-image/models.ts +50 -0
- package/extensions/gemini-image/pricing.test.ts +95 -0
- package/extensions/gemini-image/pricing.ts +102 -0
- package/extensions/grimoire/index.test.ts +244 -0
- package/extensions/multi-edit/classic.test.ts +274 -0
- package/extensions/multi-edit/classic.ts +435 -0
- package/extensions/multi-edit/diff.test.ts +65 -0
- package/extensions/multi-edit/diff.ts +143 -0
- package/extensions/multi-edit/index.test.ts +170 -0
- package/extensions/multi-edit/index.ts +267 -0
- package/extensions/multi-edit/patch.test.ts +242 -0
- package/extensions/multi-edit/patch.ts +463 -0
- package/extensions/multi-edit/types.ts +53 -0
- package/extensions/multi-edit/workspace.test.ts +165 -0
- package/extensions/multi-edit/workspace.ts +85 -0
- package/package.json +9 -3
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { 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 extensionDefault from "./index.ts";
|
|
6
|
+
|
|
7
|
+
interface ToolDef {
|
|
8
|
+
name: string;
|
|
9
|
+
execute: (
|
|
10
|
+
id: string,
|
|
11
|
+
params: Record<string, unknown>,
|
|
12
|
+
signal: AbortSignal | undefined,
|
|
13
|
+
onUpdate: unknown,
|
|
14
|
+
ctx: { cwd: string; events?: { emit: (...a: unknown[]) => void } },
|
|
15
|
+
) => Promise<{ content: Array<{ text: string }>; details: { diff?: string; firstChangedLine?: number } }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makePi(): { tools: ToolDef[]; registerTool: (t: ToolDef) => void; events: { emit: () => void } } {
|
|
19
|
+
const tools: ToolDef[] = [];
|
|
20
|
+
return {
|
|
21
|
+
tools,
|
|
22
|
+
registerTool: (t) => tools.push(t),
|
|
23
|
+
events: { emit: () => {} },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeCtx(cwd: string): { cwd: string; events: { emit: () => void } } {
|
|
28
|
+
return { cwd, events: { emit: () => {} } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("multi-edit extension (edit tool)", () => {
|
|
32
|
+
let root: string;
|
|
33
|
+
const pi = makePi();
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
root = mkdtempSync(join(tmpdir(), "medit-idx-"));
|
|
37
|
+
// Extension registers once — only call default on first iteration.
|
|
38
|
+
if (pi.tools.length === 0) {
|
|
39
|
+
extensionDefault(pi as unknown as Parameters<typeof extensionDefault>[0]);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
rmSync(root, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("registers a tool named 'edit'", () => {
|
|
48
|
+
expect(pi.tools[0].name).toBe("edit");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("single edit via top-level path/oldText/newText", async () => {
|
|
52
|
+
const f = join(root, "a.ts");
|
|
53
|
+
writeFileSync(f, "hi\n", "utf-8");
|
|
54
|
+
const res = await pi.tools[0].execute(
|
|
55
|
+
"t",
|
|
56
|
+
{ path: f, oldText: "hi", newText: "HI" },
|
|
57
|
+
undefined,
|
|
58
|
+
undefined,
|
|
59
|
+
makeCtx(root),
|
|
60
|
+
);
|
|
61
|
+
expect(res.content[0].text).toMatch(/.+/);
|
|
62
|
+
expect(readFileSync(f, "utf-8")).toContain("HI");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("multi edit array applies to one file", async () => {
|
|
66
|
+
const f = join(root, "b.ts");
|
|
67
|
+
writeFileSync(f, "a\nb\n", "utf-8");
|
|
68
|
+
await pi.tools[0].execute(
|
|
69
|
+
"t",
|
|
70
|
+
{
|
|
71
|
+
multi: [
|
|
72
|
+
{ path: f, oldText: "a", newText: "A" },
|
|
73
|
+
{ path: f, oldText: "b", newText: "B" },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
undefined,
|
|
77
|
+
undefined,
|
|
78
|
+
makeCtx(root),
|
|
79
|
+
);
|
|
80
|
+
expect(readFileSync(f, "utf-8")).toBe("A\nB\n");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("multi inherits top-level path when item omits it", async () => {
|
|
84
|
+
const f = join(root, "c.ts");
|
|
85
|
+
writeFileSync(f, "1\n2\n", "utf-8");
|
|
86
|
+
await pi.tools[0].execute(
|
|
87
|
+
"t",
|
|
88
|
+
{
|
|
89
|
+
path: f,
|
|
90
|
+
multi: [
|
|
91
|
+
{ oldText: "1", newText: "ONE" },
|
|
92
|
+
{ oldText: "2", newText: "TWO" },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
undefined,
|
|
96
|
+
undefined,
|
|
97
|
+
makeCtx(root),
|
|
98
|
+
);
|
|
99
|
+
expect(readFileSync(f, "utf-8")).toBe("ONE\nTWO\n");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("patch mode creates a new file", async () => {
|
|
103
|
+
const patch = "*** Begin Patch\n*** Add File: new.ts\n+fresh\n*** End Patch";
|
|
104
|
+
await pi.tools[0].execute(
|
|
105
|
+
"t",
|
|
106
|
+
{ patch },
|
|
107
|
+
undefined,
|
|
108
|
+
undefined,
|
|
109
|
+
makeCtx(root),
|
|
110
|
+
);
|
|
111
|
+
expect(readFileSync(join(root, "new.ts"), "utf-8")).toBe("fresh\n");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("patch + classic params together is an error", async () => {
|
|
115
|
+
await expect(
|
|
116
|
+
pi.tools[0].execute(
|
|
117
|
+
"t",
|
|
118
|
+
{ patch: "*** Begin Patch\n*** End Patch", path: "x" },
|
|
119
|
+
undefined,
|
|
120
|
+
undefined,
|
|
121
|
+
makeCtx(root),
|
|
122
|
+
),
|
|
123
|
+
).rejects.toThrow(/mutually exclusive/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("incomplete top-level (path + oldText without newText) is an error", async () => {
|
|
127
|
+
await expect(
|
|
128
|
+
pi.tools[0].execute(
|
|
129
|
+
"t",
|
|
130
|
+
{ path: "x", oldText: "y" },
|
|
131
|
+
undefined,
|
|
132
|
+
undefined,
|
|
133
|
+
makeCtx(root),
|
|
134
|
+
),
|
|
135
|
+
).rejects.toThrow(/Incomplete top-level/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("no params at all is an error", async () => {
|
|
139
|
+
await expect(
|
|
140
|
+
pi.tools[0].execute("t", {}, undefined, undefined, makeCtx(root)),
|
|
141
|
+
).rejects.toThrow(/No edits provided/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("multi item missing path and no top-level path is an error", async () => {
|
|
145
|
+
await expect(
|
|
146
|
+
pi.tools[0].execute(
|
|
147
|
+
"t",
|
|
148
|
+
{ multi: [{ oldText: "x", newText: "y" }] },
|
|
149
|
+
undefined,
|
|
150
|
+
undefined,
|
|
151
|
+
makeCtx(root),
|
|
152
|
+
),
|
|
153
|
+
).rejects.toThrow(/missing a path/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("preflight error prevents real writes", async () => {
|
|
157
|
+
const f = join(root, "d.ts");
|
|
158
|
+
writeFileSync(f, "actual", "utf-8");
|
|
159
|
+
await expect(
|
|
160
|
+
pi.tools[0].execute(
|
|
161
|
+
"t",
|
|
162
|
+
{ path: f, oldText: "missing-text", newText: "x" },
|
|
163
|
+
undefined,
|
|
164
|
+
undefined,
|
|
165
|
+
makeCtx(root),
|
|
166
|
+
),
|
|
167
|
+
).rejects.toThrow(/Preflight failed/);
|
|
168
|
+
expect(readFileSync(f, "utf-8")).toBe("actual");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Edit Extension — replaces the built-in `edit` tool.
|
|
3
|
+
*
|
|
4
|
+
* Supports all original parameters (path, oldText, newText) plus:
|
|
5
|
+
* - `multi`: array of {path, oldText, newText} edits applied in sequence
|
|
6
|
+
* - `patch`: Codex-style apply_patch payload
|
|
7
|
+
*
|
|
8
|
+
* When both top-level params and `multi` are provided, the top-level edit
|
|
9
|
+
* is treated as an implicit first item prepended to the multi list.
|
|
10
|
+
*
|
|
11
|
+
* A preflight pass is performed before mutating files:
|
|
12
|
+
* - multi/top-level mode: preflight via virtualized built-in edit tool
|
|
13
|
+
* - patch mode: preflight by applying patch operations on a virtual filesystem
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
|
|
19
|
+
import { applyClassicEdits } from "./classic.ts";
|
|
20
|
+
import { applyPatchOperations, parsePatch } from "./patch.ts";
|
|
21
|
+
import type { EditItem } from "./types.ts";
|
|
22
|
+
import { createRealWorkspace, createVirtualWorkspace } from "./workspace.ts";
|
|
23
|
+
|
|
24
|
+
const editItemSchema = Type.Object({
|
|
25
|
+
path: Type.Optional(
|
|
26
|
+
Type.String({
|
|
27
|
+
description:
|
|
28
|
+
"Path to the file to edit (relative or absolute). Inherits from top-level path if omitted.",
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
oldText: Type.String({
|
|
32
|
+
description: "Exact text to find and replace (must match exactly)",
|
|
33
|
+
}),
|
|
34
|
+
newText: Type.String({
|
|
35
|
+
description: "New text to replace the old text with",
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const multiEditSchema = Type.Object({
|
|
40
|
+
path: Type.Optional(
|
|
41
|
+
Type.String({
|
|
42
|
+
description: "Path to the file to edit (relative or absolute)",
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
oldText: Type.Optional(
|
|
46
|
+
Type.String({
|
|
47
|
+
description: "Exact text to find and replace (must match exactly)",
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
newText: Type.Optional(
|
|
51
|
+
Type.String({ description: "New text to replace the old text with" }),
|
|
52
|
+
),
|
|
53
|
+
multi: Type.Optional(
|
|
54
|
+
Type.Array(editItemSchema, {
|
|
55
|
+
description:
|
|
56
|
+
"Multiple edits to apply in sequence. Each item has path, oldText, and newText.",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
patch: Type.Optional(
|
|
60
|
+
Type.String({
|
|
61
|
+
description:
|
|
62
|
+
"Codex-style apply_patch payload (*** Begin Patch ... *** End Patch). Mutually exclusive with path/oldText/newText/multi.",
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export default function (pi: ExtensionAPI) {
|
|
68
|
+
pi.registerTool({
|
|
69
|
+
name: "edit",
|
|
70
|
+
label: "edit",
|
|
71
|
+
description:
|
|
72
|
+
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits. Supports a `multi` parameter for batch edits across one or more files, and a `patch` parameter for Codex-style patches.",
|
|
73
|
+
promptSnippet:
|
|
74
|
+
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
|
|
75
|
+
promptGuidelines: [
|
|
76
|
+
"Use edit for precise changes (old text must match exactly)",
|
|
77
|
+
"Use the `multi` parameter to apply multiple edits in a single tool call",
|
|
78
|
+
"Use the `patch` parameter for Codex-style multi-file / hunk-based edits",
|
|
79
|
+
],
|
|
80
|
+
parameters: multiEditSchema,
|
|
81
|
+
|
|
82
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
83
|
+
const { path, oldText, newText, multi, patch } = params;
|
|
84
|
+
|
|
85
|
+
const hasAnyClassicParam =
|
|
86
|
+
path !== undefined ||
|
|
87
|
+
oldText !== undefined ||
|
|
88
|
+
newText !== undefined ||
|
|
89
|
+
multi !== undefined;
|
|
90
|
+
if (patch !== undefined && hasAnyClassicParam) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
"The `patch` parameter is mutually exclusive with path/oldText/newText/multi.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (patch !== undefined) {
|
|
97
|
+
const ops = parsePatch(patch);
|
|
98
|
+
|
|
99
|
+
// Preflight on virtual filesystem before mutating real files.
|
|
100
|
+
await applyPatchOperations(
|
|
101
|
+
ops,
|
|
102
|
+
createVirtualWorkspace(ctx.cwd),
|
|
103
|
+
ctx.cwd,
|
|
104
|
+
signal,
|
|
105
|
+
{ collectDiff: false },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Apply for real.
|
|
109
|
+
const applied = await applyPatchOperations(
|
|
110
|
+
ops,
|
|
111
|
+
createRealWorkspace(pi),
|
|
112
|
+
ctx.cwd,
|
|
113
|
+
signal,
|
|
114
|
+
{ collectDiff: true },
|
|
115
|
+
);
|
|
116
|
+
const summary = applied
|
|
117
|
+
.map((r, i) => `${i + 1}. ${r.message}`)
|
|
118
|
+
.join("\n");
|
|
119
|
+
const combinedDiff = applied
|
|
120
|
+
.filter((r) => r.diff)
|
|
121
|
+
.map((r) => `File: ${r.path}\n${r.diff}`)
|
|
122
|
+
.join("\n\n");
|
|
123
|
+
const firstChangedLine = applied.find(
|
|
124
|
+
(r) => r.firstChangedLine !== undefined,
|
|
125
|
+
)?.firstChangedLine;
|
|
126
|
+
return {
|
|
127
|
+
content: [
|
|
128
|
+
{
|
|
129
|
+
type: "text" as const,
|
|
130
|
+
text: `Applied patch with ${applied.length} operation(s).\n${summary}`,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
details: {
|
|
134
|
+
diff: combinedDiff,
|
|
135
|
+
firstChangedLine,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Build classic edit list.
|
|
141
|
+
const edits: EditItem[] = [];
|
|
142
|
+
const hasTopLevel =
|
|
143
|
+
path !== undefined && oldText !== undefined && newText !== undefined;
|
|
144
|
+
|
|
145
|
+
if (hasTopLevel) {
|
|
146
|
+
edits.push({ path: path!, oldText: oldText!, newText: newText! });
|
|
147
|
+
} else if (
|
|
148
|
+
path !== undefined ||
|
|
149
|
+
oldText !== undefined ||
|
|
150
|
+
newText !== undefined
|
|
151
|
+
) {
|
|
152
|
+
// When multi is present, only a bare top-level `path` (for inheritance) is allowed.
|
|
153
|
+
// Any other partial combination (e.g. path+oldText, oldText+newText) is an error.
|
|
154
|
+
const hasOnlyPath =
|
|
155
|
+
path !== undefined && oldText === undefined && newText === undefined;
|
|
156
|
+
if (!hasOnlyPath || multi === undefined) {
|
|
157
|
+
const missing: string[] = [];
|
|
158
|
+
if (path === undefined) missing.push("path");
|
|
159
|
+
if (oldText === undefined) missing.push("oldText");
|
|
160
|
+
if (newText === undefined) missing.push("newText");
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Incomplete top-level edit: missing ${missing.join(", ")}. Provide all three (path, oldText, newText) or use only the multi parameter.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
// path-only top-level with multi is fine — path is inherited below.
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (multi) {
|
|
169
|
+
for (const item of multi) {
|
|
170
|
+
edits.push({
|
|
171
|
+
path: item.path ?? path ?? "",
|
|
172
|
+
oldText: item.oldText,
|
|
173
|
+
newText: item.newText,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (edits.length === 0) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
"No edits provided. Supply path/oldText/newText, a multi array, or a patch.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Validate that every edit has a path.
|
|
185
|
+
for (let i = 0; i < edits.length; i++) {
|
|
186
|
+
if (!edits[i].path) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Edit ${i + 1} is missing a path. Provide a path on each multi item or set a top-level path to inherit.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Preflight pass on virtual workspace before mutating real files.
|
|
194
|
+
// Uses sequential occurrence matching so same-file edits are resolved
|
|
195
|
+
// in file order (positional ordering).
|
|
196
|
+
try {
|
|
197
|
+
await applyClassicEdits(
|
|
198
|
+
edits,
|
|
199
|
+
createVirtualWorkspace(ctx.cwd),
|
|
200
|
+
ctx.cwd,
|
|
201
|
+
signal,
|
|
202
|
+
{ collectDiff: false },
|
|
203
|
+
);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
206
|
+
throw new Error(`Preflight failed before mutating files.\n${message}`, {
|
|
207
|
+
cause: err,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Apply for real. `continueOnError` lets successful edits land even
|
|
212
|
+
// when a sibling fails; `rollbackOnError` restores files if an
|
|
213
|
+
// unexpected error (I/O, abort) breaks the batch mid-write.
|
|
214
|
+
const isBatch = edits.length > 1;
|
|
215
|
+
const results = await applyClassicEdits(
|
|
216
|
+
edits,
|
|
217
|
+
createRealWorkspace(pi),
|
|
218
|
+
ctx.cwd,
|
|
219
|
+
signal,
|
|
220
|
+
{
|
|
221
|
+
collectDiff: true,
|
|
222
|
+
rollbackOnError: true,
|
|
223
|
+
continueOnError: isBatch,
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const succeeded = results.filter((r) => r?.success);
|
|
228
|
+
const failed = results.filter((r) => r && !r.success);
|
|
229
|
+
|
|
230
|
+
if (results.length === 1) {
|
|
231
|
+
const r = results[0];
|
|
232
|
+
return {
|
|
233
|
+
content: [{ type: "text" as const, text: r.message }],
|
|
234
|
+
details: {
|
|
235
|
+
diff: r.diff ?? "",
|
|
236
|
+
firstChangedLine: r.firstChangedLine,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const combinedDiff = results
|
|
242
|
+
.filter((r) => r?.diff)
|
|
243
|
+
.map((r) => r.diff)
|
|
244
|
+
.join("\n");
|
|
245
|
+
|
|
246
|
+
const firstChanged = results.find(
|
|
247
|
+
(r) => r?.firstChangedLine !== undefined,
|
|
248
|
+
)?.firstChangedLine;
|
|
249
|
+
const summary = results
|
|
250
|
+
.map((r, i) => `${i + 1}. ${r.message}`)
|
|
251
|
+
.join("\n");
|
|
252
|
+
|
|
253
|
+
const statusLine =
|
|
254
|
+
failed.length > 0
|
|
255
|
+
? `Applied ${succeeded.length}/${results.length} edit(s). ${failed.length} failed:\n${summary}`
|
|
256
|
+
: `Applied ${results.length} edit(s) successfully.\n${summary}`;
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
content: [{ type: "text" as const, text: statusLine }],
|
|
260
|
+
details: {
|
|
261
|
+
diff: combinedDiff,
|
|
262
|
+
firstChangedLine: firstChanged,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
@@ -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
|
+
});
|