@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.
- package/README.md +4 -0
- package/extensions/astro-agents/agents/code-reviewer.md +0 -2
- package/extensions/astro-agents/agents/google-tech-lead.md +0 -2
- package/extensions/astro-agents/agents/spec-writer.md +0 -2
- package/extensions/astro-agents/agents/tester-api.md +0 -2
- package/extensions/astro-agents/agents/tester-ui.md +0 -2
- package/extensions/astro-agents/agents/ui-architect.md +0 -2
- package/extensions/astro-agents/agents/ui-design-system.md +0 -2
- package/extensions/astro-agents/agents/ui-frontend-developer.md +0 -2
- package/extensions/astro-agents/discovery.test.ts +152 -0
- package/extensions/astro-agents/index.test.ts +208 -0
- package/extensions/astro-agents/index.ts +22 -4
- 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,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
|
+
});
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classic edit engine — (path, oldText, newText) triples applied against a
|
|
3
|
+
* Workspace, with positional same-file ordering, curly-quote fallback, and
|
|
4
|
+
* atomic multi-file rollback.
|
|
5
|
+
*
|
|
6
|
+
* The core loop groups edits by their absolute path so all hits against a
|
|
7
|
+
* file happen in one read/mutate/write cycle. Within a group, entries are
|
|
8
|
+
* sorted by the position of their `oldText` in the original content, so a
|
|
9
|
+
* model that lists edits bottom-up still applies them top-down.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isAbsolute, resolve as resolvePath } from "path";
|
|
13
|
+
|
|
14
|
+
import { generateDiffString } from "./diff.ts";
|
|
15
|
+
import type { EditItem, EditResult, Workspace } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Text matching
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const normalizeCurlyQuotes = (s: string): string =>
|
|
22
|
+
s
|
|
23
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
24
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"');
|
|
25
|
+
|
|
26
|
+
const trimTrailingPerLine = (s: string): string =>
|
|
27
|
+
s
|
|
28
|
+
.split("\n")
|
|
29
|
+
.map((l) => l.trimEnd())
|
|
30
|
+
.join("\n");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ordered list of passes `findActualString` tries when matching `oldText`
|
|
34
|
+
* inside file content. Each pass applies a normalizer to *both* `oldText`
|
|
35
|
+
* and `content`; the first one that locates the transformed string wins.
|
|
36
|
+
*
|
|
37
|
+
* The array is the extension point: add a new pass here to gain tolerance
|
|
38
|
+
* for a new class of model/file mismatch (e.g. dash variants, NBSP).
|
|
39
|
+
*/
|
|
40
|
+
const MATCH_PASSES: readonly ((s: string) => string)[] = [
|
|
41
|
+
(s) => s, // exact
|
|
42
|
+
normalizeCurlyQuotes, // curly → straight quotes
|
|
43
|
+
trimTrailingPerLine, // trailing-whitespace tolerance per line
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Locate `oldText` inside `content` starting at `offset`. Falls back through
|
|
48
|
+
* `MATCH_PASSES` when the exact search fails — most commonly when the model
|
|
49
|
+
* wrote curly quotes but the file has straight ASCII.
|
|
50
|
+
*
|
|
51
|
+
* Returns `{ pos, actualOldText }` on match, `undefined` otherwise. Callers
|
|
52
|
+
* must use `actualOldText.length` (not the original oldText length) when
|
|
53
|
+
* splicing, since the matched region may differ from the requested text
|
|
54
|
+
* after normalization.
|
|
55
|
+
*/
|
|
56
|
+
export function findActualString(
|
|
57
|
+
content: string,
|
|
58
|
+
oldText: string,
|
|
59
|
+
offset: number,
|
|
60
|
+
): { pos: number; actualOldText: string } | undefined {
|
|
61
|
+
// Fast path: exact match with no normalization.
|
|
62
|
+
const exact = content.indexOf(oldText, offset);
|
|
63
|
+
if (exact !== -1) return { pos: exact, actualOldText: oldText };
|
|
64
|
+
|
|
65
|
+
// Slower passes: normalize both sides and map the position back to the
|
|
66
|
+
// original content. We search in the normalized content but return the
|
|
67
|
+
// position and length in the *original* so the caller can splice correctly.
|
|
68
|
+
const triedOld = new Set<string>([oldText]);
|
|
69
|
+
const triedContent = new Set<string>([content]);
|
|
70
|
+
|
|
71
|
+
for (let i = 1; i < MATCH_PASSES.length; i++) {
|
|
72
|
+
const transform = MATCH_PASSES[i];
|
|
73
|
+
const normOld = transform(oldText);
|
|
74
|
+
const normContent = transform(content);
|
|
75
|
+
|
|
76
|
+
if (triedOld.has(normOld) && triedContent.has(normContent)) continue;
|
|
77
|
+
triedOld.add(normOld);
|
|
78
|
+
triedContent.add(normContent);
|
|
79
|
+
|
|
80
|
+
const pos = normContent.indexOf(normOld, offset);
|
|
81
|
+
if (pos !== -1) {
|
|
82
|
+
// Map back: the character at `pos` in normalised content corresponds
|
|
83
|
+
// to the same index in the original (our normalizers preserve length
|
|
84
|
+
// for all passes except trimTrailingPerLine). For trimEnd we need the
|
|
85
|
+
// actual substring from original content that matches.
|
|
86
|
+
const actualOld = content.slice(pos, pos + normOld.length);
|
|
87
|
+
// Verify the mapped slice actually normalizes to the same thing.
|
|
88
|
+
if (transform(actualOld) === normOld) {
|
|
89
|
+
return { pos, actualOldText: actualOld };
|
|
90
|
+
}
|
|
91
|
+
// If the lengths shifted (trimEnd can shrink lines), fall back to a
|
|
92
|
+
// line-aligned search: find the lines in the original content.
|
|
93
|
+
const match = findByNormalizedLines(content, oldText, offset, transform);
|
|
94
|
+
if (match) return match;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Line-by-line normalized search. Used when a normalizer changes string
|
|
102
|
+
* length (e.g. trimEnd) so character offsets between original and normalized
|
|
103
|
+
* content no longer align 1:1.
|
|
104
|
+
*/
|
|
105
|
+
function findByNormalizedLines(
|
|
106
|
+
content: string,
|
|
107
|
+
oldText: string,
|
|
108
|
+
offset: number,
|
|
109
|
+
normalize: (s: string) => string,
|
|
110
|
+
): { pos: number; actualOldText: string } | undefined {
|
|
111
|
+
const contentLines = content.split("\n");
|
|
112
|
+
const oldLines = oldText.split("\n");
|
|
113
|
+
if (oldLines.length === 0) return undefined;
|
|
114
|
+
|
|
115
|
+
const normOldLines = oldLines.map((l) => normalize(l));
|
|
116
|
+
|
|
117
|
+
// Character offset → line index.
|
|
118
|
+
let charCount = 0;
|
|
119
|
+
let startLine = 0;
|
|
120
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
121
|
+
if (charCount + contentLines[i].length >= offset) {
|
|
122
|
+
startLine = i;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
charCount += contentLines[i].length + 1; // +1 for \n
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (let i = startLine; i <= contentLines.length - oldLines.length; i++) {
|
|
129
|
+
let match = true;
|
|
130
|
+
for (let j = 0; j < normOldLines.length; j++) {
|
|
131
|
+
if (normalize(contentLines[i + j]) !== normOldLines[j]) {
|
|
132
|
+
match = false;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (match) {
|
|
137
|
+
// Compute character position and actual substring from original.
|
|
138
|
+
let pos = 0;
|
|
139
|
+
for (let k = 0; k < i; k++) pos += contentLines[k].length + 1;
|
|
140
|
+
const endLine = i + oldLines.length - 1;
|
|
141
|
+
let endPos = 0;
|
|
142
|
+
for (let k = 0; k <= endLine; k++) endPos += contentLines[k].length + 1;
|
|
143
|
+
endPos--; // don't include the final \n after last matched line
|
|
144
|
+
// If oldText ended with \n, include it.
|
|
145
|
+
if (oldText.endsWith("\n") && endPos + 1 <= content.length) endPos++;
|
|
146
|
+
const actualOldText = content.slice(pos, endPos);
|
|
147
|
+
return { pos, actualOldText };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Grouping helpers
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
interface IndexedEdit {
|
|
159
|
+
index: number;
|
|
160
|
+
edit: EditItem;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function toAbsolute(path: string, cwd: string): string {
|
|
164
|
+
return isAbsolute(path) ? resolvePath(path) : resolvePath(cwd, path);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Bucket a flat edit list by its resolved absolute path. The returned Map
|
|
169
|
+
* preserves insertion order, which is the order files are processed in the
|
|
170
|
+
* apply loop — making the first-seen file also the first to be mutated on
|
|
171
|
+
* disk.
|
|
172
|
+
*/
|
|
173
|
+
function groupEditsByPath(
|
|
174
|
+
edits: EditItem[],
|
|
175
|
+
cwd: string,
|
|
176
|
+
): Map<string, IndexedEdit[]> {
|
|
177
|
+
const groups = new Map<string, IndexedEdit[]>();
|
|
178
|
+
for (let i = 0; i < edits.length; i++) {
|
|
179
|
+
const abs = toAbsolute(edits[i].path, cwd);
|
|
180
|
+
const bucket = groups.get(abs);
|
|
181
|
+
if (bucket) {
|
|
182
|
+
bucket.push({ index: i, edit: edits[i] });
|
|
183
|
+
} else {
|
|
184
|
+
groups.set(abs, [{ index: i, edit: edits[i] }]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return groups;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Sort same-file edits by the position of their `oldText` inside the
|
|
192
|
+
* original content. Edits whose oldText can't be located slide to the end
|
|
193
|
+
* and surface the error through the regular apply loop.
|
|
194
|
+
*/
|
|
195
|
+
function sortGroupByPosition(
|
|
196
|
+
group: IndexedEdit[],
|
|
197
|
+
originalContent: string,
|
|
198
|
+
): void {
|
|
199
|
+
if (group.length < 2) return;
|
|
200
|
+
const positions = new Map<IndexedEdit, number>();
|
|
201
|
+
for (const entry of group) {
|
|
202
|
+
const match = findActualString(originalContent, entry.edit.oldText, 0);
|
|
203
|
+
positions.set(
|
|
204
|
+
entry,
|
|
205
|
+
match === undefined ? Number.MAX_SAFE_INTEGER : match.pos,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
group.sort((a, b) => positions.get(a)! - positions.get(b)!);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Core apply loop
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
interface ApplyOptions {
|
|
216
|
+
collectDiff?: boolean;
|
|
217
|
+
rollbackOnError?: boolean;
|
|
218
|
+
/** When true, failed edits are recorded but the batch continues with the
|
|
219
|
+
* remaining edits instead of aborting the entire group. */
|
|
220
|
+
continueOnError?: boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Apply a list of classic edits sequentially through a Workspace.
|
|
225
|
+
*
|
|
226
|
+
* Within each file the applier advances a `searchOffset` cursor after every
|
|
227
|
+
* replacement so duplicate oldText snippets are disambiguated positionally.
|
|
228
|
+
* Same-file edits are reordered by the position of their oldText in the
|
|
229
|
+
* original content so the cursor always moves forward.
|
|
230
|
+
*
|
|
231
|
+
* When `rollbackOnError` is set, any file already written in this batch is
|
|
232
|
+
* restored to its pre-edit snapshot if a later file fails — producing an
|
|
233
|
+
* atomic multi-file edit on the real filesystem.
|
|
234
|
+
*/
|
|
235
|
+
export async function applyClassicEdits(
|
|
236
|
+
edits: EditItem[],
|
|
237
|
+
workspace: Workspace,
|
|
238
|
+
cwd: string,
|
|
239
|
+
signal?: AbortSignal,
|
|
240
|
+
options: ApplyOptions = {},
|
|
241
|
+
): Promise<EditResult[]> {
|
|
242
|
+
const {
|
|
243
|
+
collectDiff = false,
|
|
244
|
+
rollbackOnError = false,
|
|
245
|
+
continueOnError = false,
|
|
246
|
+
} = options;
|
|
247
|
+
|
|
248
|
+
const fileGroups = groupEditsByPath(edits, cwd);
|
|
249
|
+
const results: EditResult[] = new Array(edits.length);
|
|
250
|
+
|
|
251
|
+
// Fail fast on any unwritable target so we don't partially mutate the FS.
|
|
252
|
+
await Promise.all(
|
|
253
|
+
Array.from(fileGroups.keys(), (absPath) =>
|
|
254
|
+
workspace.checkWriteAccess(absPath),
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Pre-edit snapshots keyed by absolute path — populated as each file is
|
|
259
|
+
// successfully written, consumed on failure for rollback.
|
|
260
|
+
const snapshots = new Map<string, string>();
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
for (const [absPath, group] of fileGroups) {
|
|
264
|
+
throwIfAborted(signal);
|
|
265
|
+
|
|
266
|
+
const originalContent = await workspace.readText(absPath);
|
|
267
|
+
sortGroupByPosition(group, originalContent);
|
|
268
|
+
|
|
269
|
+
let updatedContent: string;
|
|
270
|
+
try {
|
|
271
|
+
updatedContent = applyGroupToContent(
|
|
272
|
+
group,
|
|
273
|
+
originalContent,
|
|
274
|
+
results,
|
|
275
|
+
edits.length,
|
|
276
|
+
signal,
|
|
277
|
+
continueOnError,
|
|
278
|
+
);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (continueOnError) continue;
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (updatedContent === originalContent) continue;
|
|
285
|
+
|
|
286
|
+
snapshots.set(absPath, originalContent);
|
|
287
|
+
await workspace.writeText(absPath, updatedContent);
|
|
288
|
+
|
|
289
|
+
if (collectDiff) {
|
|
290
|
+
const { diff, firstChangedLine } = generateDiffString(
|
|
291
|
+
originalContent,
|
|
292
|
+
updatedContent,
|
|
293
|
+
);
|
|
294
|
+
const firstSuccessIdx = group.find(
|
|
295
|
+
(e) => results[e.index]?.success,
|
|
296
|
+
)?.index;
|
|
297
|
+
if (firstSuccessIdx !== undefined) {
|
|
298
|
+
results[firstSuccessIdx].diff = diff;
|
|
299
|
+
results[firstSuccessIdx].firstChangedLine = firstChangedLine;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
if (rollbackOnError) {
|
|
305
|
+
await rollbackSnapshots(snapshots, workspace);
|
|
306
|
+
}
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return results;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Apply every edit in a same-file group against `originalContent`, writing
|
|
315
|
+
* per-edit outcomes into the shared `results` slot array. Returns the final
|
|
316
|
+
* mutated content for the file, or throws with a formatted error if a hunk
|
|
317
|
+
* can't be located.
|
|
318
|
+
*/
|
|
319
|
+
function applyGroupToContent(
|
|
320
|
+
group: IndexedEdit[],
|
|
321
|
+
originalContent: string,
|
|
322
|
+
results: EditResult[],
|
|
323
|
+
totalEdits: number,
|
|
324
|
+
signal: AbortSignal | undefined,
|
|
325
|
+
continueOnError = false,
|
|
326
|
+
): string {
|
|
327
|
+
let content = originalContent;
|
|
328
|
+
let searchOffset = 0;
|
|
329
|
+
|
|
330
|
+
// Track which oldText→newText pairs already landed in this file so we
|
|
331
|
+
// can skip a redundant duplicate gracefully instead of failing the batch.
|
|
332
|
+
const appliedPairs = new Set<string>();
|
|
333
|
+
const pairKey = (edit: EditItem) => `${edit.oldText}\0${edit.newText}`;
|
|
334
|
+
|
|
335
|
+
for (const { index, edit } of group) {
|
|
336
|
+
throwIfAborted(signal);
|
|
337
|
+
|
|
338
|
+
const match = findActualString(content, edit.oldText, searchOffset);
|
|
339
|
+
|
|
340
|
+
if (match === undefined) {
|
|
341
|
+
if (appliedPairs.has(pairKey(edit))) {
|
|
342
|
+
results[index] = {
|
|
343
|
+
path: edit.path,
|
|
344
|
+
success: true,
|
|
345
|
+
message: `Skipped redundant edit in ${edit.path} (already replaced all occurrences).`,
|
|
346
|
+
};
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
results[index] = {
|
|
351
|
+
path: edit.path,
|
|
352
|
+
success: false,
|
|
353
|
+
message: `Could not find the exact text in ${edit.path}. The old text must match exactly including all whitespace and newlines.`,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
if (continueOnError) continue;
|
|
357
|
+
|
|
358
|
+
markRemainingSkipped(group, index, results);
|
|
359
|
+
throw new Error(formatResults(results.filter(Boolean), totalEdits));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const { pos, actualOldText } = match;
|
|
363
|
+
content =
|
|
364
|
+
content.slice(0, pos) +
|
|
365
|
+
edit.newText +
|
|
366
|
+
content.slice(pos + actualOldText.length);
|
|
367
|
+
searchOffset = pos + edit.newText.length;
|
|
368
|
+
appliedPairs.add(pairKey(edit));
|
|
369
|
+
|
|
370
|
+
results[index] = {
|
|
371
|
+
path: edit.path,
|
|
372
|
+
success: true,
|
|
373
|
+
message: `Edited ${edit.path}.`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return content;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function markRemainingSkipped(
|
|
381
|
+
group: IndexedEdit[],
|
|
382
|
+
failedIndex: number,
|
|
383
|
+
results: EditResult[],
|
|
384
|
+
): void {
|
|
385
|
+
const failedPos = group.findIndex((e) => e.index === failedIndex);
|
|
386
|
+
for (let i = failedPos + 1; i < group.length; i++) {
|
|
387
|
+
const pending = group[i];
|
|
388
|
+
results[pending.index] = {
|
|
389
|
+
path: pending.edit.path,
|
|
390
|
+
success: false,
|
|
391
|
+
message: `Skipped (earlier edit in ${pending.edit.path} failed).`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
397
|
+
if (signal?.aborted) {
|
|
398
|
+
throw new Error("Operation aborted");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function rollbackSnapshots(
|
|
403
|
+
snapshots: Map<string, string>,
|
|
404
|
+
workspace: Workspace,
|
|
405
|
+
): Promise<void> {
|
|
406
|
+
// Best-effort restore — surface the original failure regardless of per-file
|
|
407
|
+
// rollback failures.
|
|
408
|
+
await Promise.all(
|
|
409
|
+
Array.from(snapshots, ([absPath, original]) =>
|
|
410
|
+
workspace.writeText(absPath, original).catch(() => {}),
|
|
411
|
+
),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function formatResults(
|
|
416
|
+
results: EditResult[],
|
|
417
|
+
totalEdits: number,
|
|
418
|
+
): string {
|
|
419
|
+
const lines: string[] = [];
|
|
420
|
+
|
|
421
|
+
for (let i = 0; i < results.length; i++) {
|
|
422
|
+
const r = results[i];
|
|
423
|
+
const status = r.success ? "✓" : "✗";
|
|
424
|
+
lines.push(
|
|
425
|
+
`${status} Edit ${i + 1}/${totalEdits} (${r.path}): ${r.message}`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const remaining = totalEdits - results.length;
|
|
430
|
+
if (remaining > 0) {
|
|
431
|
+
lines.push(`⊘ ${remaining} remaining edit(s) skipped due to error.`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return lines.join("\n");
|
|
435
|
+
}
|