@heyhuynhgiabuu/pi-diff 0.8.2 → 0.9.1
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 +6 -0
- package/dist/core/apply-patch.d.ts +18 -3
- package/dist/core/apply-patch.d.ts.map +1 -1
- package/dist/core/apply-patch.js +539 -116
- package/dist/core/apply-patch.js.map +1 -1
- package/dist/core/replace.d.ts +12 -0
- package/dist/core/replace.d.ts.map +1 -1
- package/dist/core/replace.js +122 -24
- package/dist/core/replace.js.map +1 -1
- package/dist/core/text-encoding.d.ts +4 -2
- package/dist/core/text-encoding.d.ts.map +1 -1
- package/dist/core/text-encoding.js +13 -6
- package/dist/core/text-encoding.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +102 -35
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/core/apply-patch.js
CHANGED
|
@@ -2,170 +2,581 @@
|
|
|
2
2
|
* apply_patch — Multi-file patch engine.
|
|
3
3
|
*
|
|
4
4
|
* One call can add, update, delete, or move multiple files.
|
|
5
|
-
*
|
|
5
|
+
* Updates use a conservative matcher and are committed only after every
|
|
6
|
+
* change has been prepared successfully.
|
|
6
7
|
*/
|
|
8
|
+
import { isUtf8 } from "node:buffer";
|
|
7
9
|
import * as fs from "node:fs";
|
|
8
10
|
import * as path from "node:path";
|
|
9
|
-
import {
|
|
11
|
+
import { structuredPatch } from "diff";
|
|
12
|
+
import { countPatchOccurrences, findPatchReplacement } from "./replace.js";
|
|
13
|
+
import { detectLineEnding, normalizeForLineEnding, restoreLineEndings, stripBom } from "./text-encoding.js";
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function requiredString(value, field, context) {
|
|
18
|
+
if (typeof value !== "string" || value.length === 0)
|
|
19
|
+
throw new Error(`${context}.${field} must be a non-empty string`);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function requiredText(value, field, context) {
|
|
23
|
+
if (typeof value !== "string")
|
|
24
|
+
throw new Error(`${context}.${field} must be a string`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function rejectUnknownKeys(value, allowed, context) {
|
|
28
|
+
const allowedSet = new Set(allowed);
|
|
29
|
+
for (const key of Object.keys(value)) {
|
|
30
|
+
if (!allowedSet.has(key))
|
|
31
|
+
throw new Error(`${context}.${key} is not supported`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function parseUpdateEdits(value, context) {
|
|
35
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
36
|
+
throw new Error(`${context}.edits must contain at least one replacement`);
|
|
37
|
+
return value.map((entry, index) => {
|
|
38
|
+
const editContext = `${context}.edits[${index}]`;
|
|
39
|
+
if (!isRecord(entry))
|
|
40
|
+
throw new Error(`${editContext} must be an object`);
|
|
41
|
+
rejectUnknownKeys(entry, ["oldText", "newText"], editContext);
|
|
42
|
+
return {
|
|
43
|
+
oldText: requiredString(entry.oldText, "oldText", editContext),
|
|
44
|
+
newText: requiredText(entry.newText, "newText", editContext),
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function tryParseJson(value) {
|
|
49
|
+
if (typeof value !== "string")
|
|
50
|
+
return value;
|
|
51
|
+
const trimmed = value.trim();
|
|
52
|
+
if (trimmed === "")
|
|
53
|
+
return value;
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(trimmed);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Tolerate common model serialization shapes before strict validation. */
|
|
62
|
+
function normalizeApplyPatchInput(input) {
|
|
63
|
+
const parsed = tryParseJson(input);
|
|
64
|
+
if (!isRecord(parsed))
|
|
65
|
+
return parsed;
|
|
66
|
+
const record = !("changes" in parsed) && typeof parsed.path === "string" && typeof parsed.action === "string"
|
|
67
|
+
? { changes: [parsed] }
|
|
68
|
+
: parsed;
|
|
69
|
+
const changes = tryParseJson(record.changes);
|
|
70
|
+
if (!Array.isArray(changes))
|
|
71
|
+
return record;
|
|
72
|
+
return {
|
|
73
|
+
...record,
|
|
74
|
+
changes: changes.map((entry) => {
|
|
75
|
+
if (!isRecord(entry))
|
|
76
|
+
return entry;
|
|
77
|
+
const edits = tryParseJson(entry.edits);
|
|
78
|
+
if (Array.isArray(edits))
|
|
79
|
+
return { ...entry, edits };
|
|
80
|
+
if (isRecord(edits) && typeof edits.oldText === "string")
|
|
81
|
+
return { ...entry, edits: [edits] };
|
|
82
|
+
return entry;
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Decode the model-facing tool payload before it reaches the mutation core. */
|
|
87
|
+
export function parseApplyPatchInput(rawInput) {
|
|
88
|
+
const input = normalizeApplyPatchInput(rawInput);
|
|
89
|
+
if (!isRecord(input))
|
|
90
|
+
throw new Error("apply_patch input must be an object");
|
|
91
|
+
rejectUnknownKeys(input, ["changes"], "apply_patch");
|
|
92
|
+
if (!Array.isArray(input.changes) || input.changes.length === 0) {
|
|
93
|
+
throw new Error("apply_patch.changes must contain at least one change");
|
|
94
|
+
}
|
|
95
|
+
return input.changes.map((entry, index) => {
|
|
96
|
+
const context = `apply_patch.changes[${index}]`;
|
|
97
|
+
if (!isRecord(entry))
|
|
98
|
+
throw new Error(`${context} must be an object`);
|
|
99
|
+
const pathValue = requiredString(entry.path, "path", context);
|
|
100
|
+
const action = requiredString(entry.action, "action", context);
|
|
101
|
+
switch (action) {
|
|
102
|
+
case "add":
|
|
103
|
+
rejectUnknownKeys(entry, ["path", "action", "content"], context);
|
|
104
|
+
return { path: pathValue, action, content: requiredText(entry.content, "content", context) };
|
|
105
|
+
case "update": {
|
|
106
|
+
rejectUnknownKeys(entry, ["path", "action", "oldText", "newText", "edits"], context);
|
|
107
|
+
if (entry.edits !== undefined) {
|
|
108
|
+
if (entry.oldText !== undefined || entry.newText !== undefined) {
|
|
109
|
+
throw new Error(`${context} cannot combine edits with oldText/newText`);
|
|
110
|
+
}
|
|
111
|
+
return { path: pathValue, action, edits: parseUpdateEdits(entry.edits, context) };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
path: pathValue,
|
|
115
|
+
action,
|
|
116
|
+
oldText: requiredString(entry.oldText, "oldText", context),
|
|
117
|
+
newText: entry.newText === undefined ? undefined : requiredText(entry.newText, "newText", context),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
case "delete":
|
|
121
|
+
rejectUnknownKeys(entry, ["path", "action"], context);
|
|
122
|
+
return { path: pathValue, action };
|
|
123
|
+
case "move":
|
|
124
|
+
rejectUnknownKeys(entry, ["path", "action", "movePath"], context);
|
|
125
|
+
return { path: pathValue, action, movePath: requiredString(entry.movePath, "movePath", context) };
|
|
126
|
+
default:
|
|
127
|
+
throw new Error(`${context}.action must be add, update, delete, or move`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const pathLocks = new Map();
|
|
10
132
|
// ---------------------------------------------------------------------------
|
|
11
|
-
//
|
|
133
|
+
// Path and filesystem safety
|
|
12
134
|
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
135
|
+
function isMissingPathError(error) {
|
|
136
|
+
return (typeof error === "object" &&
|
|
137
|
+
error !== null &&
|
|
138
|
+
"code" in error &&
|
|
139
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR"));
|
|
18
140
|
}
|
|
19
|
-
|
|
20
|
-
|
|
141
|
+
function formatFilesystemError(error) {
|
|
142
|
+
if (error instanceof Error) {
|
|
143
|
+
const code = "code" in error && typeof error.code === "string" ? `${error.code}: ` : "";
|
|
144
|
+
return `${code}${error.message}`;
|
|
145
|
+
}
|
|
146
|
+
return String(error);
|
|
147
|
+
}
|
|
148
|
+
function isWithin(root, candidate) {
|
|
149
|
+
const relative = path.relative(root, candidate);
|
|
150
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
151
|
+
}
|
|
152
|
+
async function assertSafeAncestors(filePath, root, label) {
|
|
153
|
+
const relative = path.relative(root, filePath);
|
|
154
|
+
if (!isWithin(root, filePath))
|
|
155
|
+
throw new Error(`${label} must stay within workspace root: ${filePath}`);
|
|
156
|
+
if (relative === "")
|
|
157
|
+
return;
|
|
158
|
+
let current = root;
|
|
159
|
+
const segments = relative.split(path.sep).filter(Boolean);
|
|
160
|
+
for (let index = 0; index < segments.length; index++) {
|
|
161
|
+
current = path.join(current, segments[index]);
|
|
162
|
+
let stats;
|
|
163
|
+
try {
|
|
164
|
+
stats = await fs.promises.lstat(current);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (isMissingPathError(error))
|
|
168
|
+
return;
|
|
169
|
+
throw new Error(`${label} could not be inspected: ${formatFilesystemError(error)}`);
|
|
170
|
+
}
|
|
171
|
+
if (stats.isSymbolicLink())
|
|
172
|
+
throw new Error(`${label} must not traverse a symbolic link: ${filePath}`);
|
|
173
|
+
if (index < segments.length - 1 && !stats.isDirectory()) {
|
|
174
|
+
throw new Error(`${label} parent must be a directory: ${filePath}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function createPathPolicy(options) {
|
|
179
|
+
const root = path.resolve(options.root ?? options.cwd ?? process.cwd());
|
|
180
|
+
const cwd = path.resolve(options.cwd ?? root);
|
|
21
181
|
try {
|
|
22
|
-
|
|
182
|
+
const rootStats = await fs.promises.stat(root);
|
|
183
|
+
const cwdStats = await fs.promises.stat(cwd);
|
|
184
|
+
if (!rootStats.isDirectory())
|
|
185
|
+
throw new Error(`${root} is not a directory`);
|
|
186
|
+
if (!cwdStats.isDirectory())
|
|
187
|
+
throw new Error(`${cwd} is not a directory`);
|
|
23
188
|
}
|
|
24
|
-
catch {
|
|
25
|
-
throw new Error(
|
|
189
|
+
catch (error) {
|
|
190
|
+
throw new Error(`workspace could not be resolved: ${formatFilesystemError(error)}`);
|
|
26
191
|
}
|
|
27
|
-
if (
|
|
192
|
+
if (!isWithin(root, cwd))
|
|
193
|
+
throw new Error(`cwd must stay within workspace root: ${cwd}`);
|
|
194
|
+
return {
|
|
195
|
+
async resolve(filePath, label) {
|
|
196
|
+
if (typeof filePath !== "string" || filePath.length === 0)
|
|
197
|
+
throw new Error(`${label} path is required`);
|
|
198
|
+
const resolved = path.resolve(cwd, filePath);
|
|
199
|
+
if (!isWithin(root, resolved))
|
|
200
|
+
throw new Error(`${label} must stay within workspace root: ${filePath}`);
|
|
201
|
+
await assertSafeAncestors(resolved, root, label);
|
|
202
|
+
return resolved;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function lstatIfExists(filePath) {
|
|
207
|
+
try {
|
|
208
|
+
return await fs.promises.lstat(filePath);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (isMissingPathError(error))
|
|
212
|
+
return undefined;
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function readRegularFile(filePath, label) {
|
|
217
|
+
const initialStats = await lstatIfExists(filePath);
|
|
218
|
+
if (!initialStats)
|
|
219
|
+
throw new Error(`${label} not found: ${filePath}`);
|
|
220
|
+
if (initialStats.isSymbolicLink())
|
|
28
221
|
throw new Error(`${label} must not be a symbolic link: ${filePath}`);
|
|
29
|
-
if (!
|
|
222
|
+
if (!initialStats.isFile())
|
|
30
223
|
throw new Error(`${label} must be a regular file: ${filePath}`);
|
|
31
|
-
|
|
224
|
+
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
225
|
+
let handle;
|
|
226
|
+
try {
|
|
227
|
+
handle = await fs.promises.open(filePath, fs.constants.O_RDONLY | noFollow);
|
|
228
|
+
const stats = await handle.stat();
|
|
229
|
+
if (!stats.isFile())
|
|
230
|
+
throw new Error(`${label} must be a regular file: ${filePath}`);
|
|
231
|
+
const bytes = await handle.readFile();
|
|
232
|
+
if (!isUtf8(bytes))
|
|
233
|
+
throw new Error(`${label} must contain valid UTF-8 text: ${filePath}`);
|
|
234
|
+
const rawContent = bytes.toString("utf8");
|
|
235
|
+
return {
|
|
236
|
+
bytes,
|
|
237
|
+
rawContent,
|
|
238
|
+
mode: stats.mode & 0o7777,
|
|
239
|
+
dev: stats.dev,
|
|
240
|
+
ino: stats.ino,
|
|
241
|
+
mtimeMs: stats.mtimeMs,
|
|
242
|
+
size: stats.size,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (error instanceof Error && error.message.includes("valid UTF-8"))
|
|
247
|
+
throw error;
|
|
248
|
+
throw new Error(`${label} could not be read: ${formatFilesystemError(error)}`);
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await handle?.close();
|
|
252
|
+
}
|
|
32
253
|
}
|
|
33
|
-
async function
|
|
254
|
+
async function assertUnchanged(filePath, original, label) {
|
|
255
|
+
const current = await readRegularFile(filePath, label);
|
|
256
|
+
if (current.dev !== original.dev ||
|
|
257
|
+
current.ino !== original.ino ||
|
|
258
|
+
current.size !== original.size ||
|
|
259
|
+
current.mtimeMs !== original.mtimeMs ||
|
|
260
|
+
!current.bytes.equals(original.bytes)) {
|
|
261
|
+
throw new Error(`${label} changed while patch was being prepared: ${filePath}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function createParentDirectories(directory) {
|
|
265
|
+
const missing = [];
|
|
266
|
+
let current = directory;
|
|
267
|
+
while (true) {
|
|
268
|
+
const stats = await lstatIfExists(current);
|
|
269
|
+
if (stats) {
|
|
270
|
+
if (!stats.isDirectory())
|
|
271
|
+
throw new Error(`parent path is not a directory: ${current}`);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
missing.push(current);
|
|
275
|
+
const parent = path.dirname(current);
|
|
276
|
+
if (parent === current)
|
|
277
|
+
throw new Error(`could not find a directory for: ${directory}`);
|
|
278
|
+
current = parent;
|
|
279
|
+
}
|
|
34
280
|
try {
|
|
35
|
-
await fs.promises.
|
|
36
|
-
return true;
|
|
281
|
+
await fs.promises.mkdir(directory, { recursive: true });
|
|
37
282
|
}
|
|
38
|
-
catch {
|
|
39
|
-
|
|
283
|
+
catch (error) {
|
|
284
|
+
await removeCreatedDirectories(missing).catch(() => undefined);
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
return missing;
|
|
288
|
+
}
|
|
289
|
+
async function removeCreatedDirectories(directories) {
|
|
290
|
+
for (const directory of directories) {
|
|
291
|
+
try {
|
|
292
|
+
await fs.promises.rmdir(directory);
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (isMissingPathError(error) ||
|
|
296
|
+
(typeof error === "object" && error !== null && "code" in error && error.code === "ENOTEMPTY")) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
40
301
|
}
|
|
41
302
|
}
|
|
42
|
-
async function
|
|
43
|
-
|
|
303
|
+
async function atomicWriteFile(filePath, content, mode, replace = true) {
|
|
304
|
+
const directory = path.dirname(filePath);
|
|
305
|
+
const temporaryDirectory = await fs.promises.mkdtemp(path.join(directory, `.pi-apply-patch-${process.pid}-`));
|
|
306
|
+
const temporaryPath = path.join(temporaryDirectory, "content");
|
|
307
|
+
const desiredMode = mode ?? 0o666 & ~process.umask();
|
|
308
|
+
let handle;
|
|
309
|
+
try {
|
|
310
|
+
handle = await fs.promises.open(temporaryPath, "wx", 0o600);
|
|
311
|
+
await handle.writeFile(content);
|
|
312
|
+
await handle.chmod(desiredMode);
|
|
313
|
+
await handle.sync();
|
|
314
|
+
await handle.close();
|
|
315
|
+
handle = undefined;
|
|
316
|
+
if (replace) {
|
|
317
|
+
await fs.promises.rename(temporaryPath, filePath);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
await fs.promises.link(temporaryPath, filePath);
|
|
321
|
+
await fs.promises.unlink(temporaryPath);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
await handle?.close().catch(() => undefined);
|
|
326
|
+
await fs.promises.rm(temporaryDirectory, { recursive: true, force: true });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async function withPathLocks(keys, operation) {
|
|
330
|
+
const locks = [];
|
|
331
|
+
for (const key of [...new Set(keys)].sort()) {
|
|
332
|
+
const previous = pathLocks.get(key) ?? Promise.resolve();
|
|
333
|
+
let release;
|
|
334
|
+
const gate = new Promise((resolve) => {
|
|
335
|
+
release = resolve;
|
|
336
|
+
});
|
|
337
|
+
const queued = previous.then(() => gate);
|
|
338
|
+
pathLocks.set(key, queued);
|
|
339
|
+
locks.push({ key, previous, queued, release });
|
|
340
|
+
}
|
|
341
|
+
await Promise.all(locks.map((lock) => lock.previous));
|
|
342
|
+
try {
|
|
343
|
+
return await operation();
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
for (const lock of locks)
|
|
347
|
+
lock.release();
|
|
348
|
+
for (const lock of locks) {
|
|
349
|
+
if (pathLocks.get(lock.key) === lock.queued)
|
|
350
|
+
pathLocks.delete(lock.key);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Preparation
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
async function prepareAdd(resolved) {
|
|
358
|
+
const { change, path: targetPath } = resolved;
|
|
359
|
+
if (await lstatIfExists(targetPath))
|
|
44
360
|
throw new Error(`add target already exists: ${change.path}`);
|
|
45
361
|
const content = change.content ?? "";
|
|
46
362
|
const final = content.endsWith("\n") ? content : `${content}\n`;
|
|
363
|
+
let createdDirectories = [];
|
|
47
364
|
return {
|
|
48
365
|
change,
|
|
49
366
|
applied: { path: change.path, action: "add", bytes: Buffer.byteLength(final, "utf8"), newContent: final },
|
|
50
367
|
async commit() {
|
|
51
|
-
await
|
|
52
|
-
|
|
368
|
+
createdDirectories = await createParentDirectories(path.dirname(targetPath));
|
|
369
|
+
try {
|
|
370
|
+
if (await lstatIfExists(targetPath))
|
|
371
|
+
throw new Error(`add target appeared during patch: ${change.path}`);
|
|
372
|
+
await atomicWriteFile(targetPath, final, undefined, false);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
await removeCreatedDirectories(createdDirectories).catch(() => undefined);
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
53
378
|
},
|
|
54
379
|
async rollback() {
|
|
55
|
-
await fs.promises.unlink(
|
|
380
|
+
await fs.promises.unlink(targetPath);
|
|
381
|
+
await removeCreatedDirectories(createdDirectories);
|
|
56
382
|
},
|
|
57
383
|
};
|
|
58
384
|
}
|
|
59
|
-
|
|
60
|
-
if (
|
|
385
|
+
function getUpdateEdits(change) {
|
|
386
|
+
if (change.edits !== undefined) {
|
|
387
|
+
if (change.oldText !== undefined || change.newText !== undefined) {
|
|
388
|
+
throw new Error("update cannot combine edits with oldText/newText");
|
|
389
|
+
}
|
|
390
|
+
if (!Array.isArray(change.edits) || change.edits.length === 0)
|
|
391
|
+
throw new Error("update requires edits");
|
|
392
|
+
return change.edits;
|
|
393
|
+
}
|
|
394
|
+
if (typeof change.oldText !== "string" || change.oldText.length === 0)
|
|
61
395
|
throw new Error("update requires oldText");
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
396
|
+
return [{ oldText: change.oldText, newText: change.newText ?? "" }];
|
|
397
|
+
}
|
|
398
|
+
/** Explain a failed match so the model can self-correct. */
|
|
399
|
+
function patchFailureMessage(content, oldText, filePath) {
|
|
400
|
+
const occurrences = countPatchOccurrences(content, oldText);
|
|
401
|
+
if (occurrences > 1) {
|
|
402
|
+
return `oldText matches ${occurrences} times in ${filePath}; add surrounding context to make it unique`;
|
|
403
|
+
}
|
|
404
|
+
return `oldText not found in ${filePath}`;
|
|
405
|
+
}
|
|
406
|
+
function applyUpdateEdits(content, edits, filePath) {
|
|
407
|
+
const replacements = edits.map((edit, index) => {
|
|
408
|
+
if (!edit || typeof edit.oldText !== "string" || typeof edit.newText !== "string") {
|
|
409
|
+
throw new Error(`edit ${index + 1} in ${filePath} is invalid`);
|
|
410
|
+
}
|
|
411
|
+
if (edit.oldText.length === 0)
|
|
412
|
+
throw new Error(`edit ${index + 1} in ${filePath} requires oldText`);
|
|
413
|
+
if (edit.oldText === edit.newText)
|
|
414
|
+
throw new Error("oldText and newText are identical — no change");
|
|
415
|
+
const match = findPatchReplacement(content, edit.oldText, edit.newText);
|
|
416
|
+
if (!match)
|
|
417
|
+
throw new Error(patchFailureMessage(content, edit.oldText, filePath));
|
|
418
|
+
return match;
|
|
419
|
+
});
|
|
420
|
+
const ordered = [...replacements].sort((a, b) => a.start - b.start);
|
|
421
|
+
for (let index = 1; index < ordered.length; index++) {
|
|
422
|
+
if (ordered[index - 1].end > ordered[index].start)
|
|
423
|
+
throw new Error(`update edits overlap in ${filePath}`);
|
|
424
|
+
}
|
|
425
|
+
let result = content;
|
|
426
|
+
for (const replacement of [...ordered].reverse()) {
|
|
427
|
+
result = result.slice(0, replacement.start) + replacement.replacement + result.slice(replacement.end);
|
|
428
|
+
}
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
async function prepareUpdate(resolved) {
|
|
432
|
+
const { change, path: targetPath } = resolved;
|
|
433
|
+
const original = await readRegularFile(targetPath, "update target");
|
|
434
|
+
const { bom, text } = stripBom(original.rawContent);
|
|
435
|
+
const ending = detectLineEnding(text);
|
|
436
|
+
const normalizedContent = normalizeForLineEnding(text, ending);
|
|
437
|
+
const normalizedEdits = getUpdateEdits(change).map((edit) => ({
|
|
438
|
+
oldText: normalizeForLineEnding(edit.oldText, ending),
|
|
439
|
+
newText: normalizeForLineEnding(edit.newText, ending),
|
|
440
|
+
}));
|
|
441
|
+
const normalizedNewContent = applyUpdateEdits(normalizedContent, normalizedEdits, change.path);
|
|
442
|
+
const newContent = bom + restoreLineEndings(normalizedNewContent, ending);
|
|
69
443
|
return {
|
|
70
444
|
change,
|
|
71
445
|
applied: {
|
|
72
446
|
path: change.path,
|
|
73
447
|
action: "update",
|
|
74
|
-
diff: generateDiff(change.path, original.
|
|
448
|
+
diff: generateDiff(change.path, original.rawContent, newContent),
|
|
75
449
|
bytes: Buffer.byteLength(newContent, "utf8"),
|
|
76
|
-
oldContent: original.
|
|
450
|
+
oldContent: original.rawContent,
|
|
77
451
|
newContent,
|
|
78
452
|
},
|
|
79
453
|
async commit() {
|
|
80
|
-
await
|
|
454
|
+
await assertUnchanged(targetPath, original, "update target");
|
|
455
|
+
await atomicWriteFile(targetPath, Buffer.from(newContent, "utf8"), original.mode);
|
|
81
456
|
},
|
|
82
457
|
async rollback() {
|
|
83
|
-
await atomicWriteFile(
|
|
458
|
+
await atomicWriteFile(targetPath, original.bytes, original.mode);
|
|
84
459
|
},
|
|
85
460
|
};
|
|
86
461
|
}
|
|
87
|
-
async function prepareDelete(
|
|
88
|
-
const
|
|
462
|
+
async function prepareDelete(resolved) {
|
|
463
|
+
const { change, path: targetPath } = resolved;
|
|
464
|
+
const original = await readRegularFile(targetPath, "delete target");
|
|
89
465
|
return {
|
|
90
466
|
change,
|
|
91
|
-
applied: { path: change.path, action: "delete", oldContent: original.
|
|
467
|
+
applied: { path: change.path, action: "delete", oldContent: original.rawContent },
|
|
92
468
|
async commit() {
|
|
93
|
-
await
|
|
469
|
+
await assertUnchanged(targetPath, original, "delete target");
|
|
470
|
+
await fs.promises.unlink(targetPath);
|
|
94
471
|
},
|
|
95
472
|
async rollback() {
|
|
96
|
-
await atomicWriteFile(
|
|
473
|
+
await atomicWriteFile(targetPath, original.bytes, original.mode, false);
|
|
97
474
|
},
|
|
98
475
|
};
|
|
99
476
|
}
|
|
100
|
-
async function prepareMove(
|
|
101
|
-
const movePath =
|
|
102
|
-
if (!
|
|
477
|
+
async function prepareMove(resolved) {
|
|
478
|
+
const { change, path: sourcePath, movePath: destinationPath } = resolved;
|
|
479
|
+
if (!destinationPath)
|
|
103
480
|
throw new Error("move requires movePath");
|
|
104
|
-
await readRegularFile(
|
|
105
|
-
if (await
|
|
106
|
-
throw new Error(`move destination already exists: ${movePath}`);
|
|
481
|
+
const original = await readRegularFile(sourcePath, "move source");
|
|
482
|
+
if (await lstatIfExists(destinationPath))
|
|
483
|
+
throw new Error(`move destination already exists: ${change.movePath}`);
|
|
484
|
+
let createdDirectories = [];
|
|
107
485
|
return {
|
|
108
486
|
change,
|
|
109
|
-
applied: { path: change.path, action: "move", movePath },
|
|
487
|
+
applied: { path: change.path, action: "move", movePath: change.movePath },
|
|
110
488
|
async commit() {
|
|
111
|
-
await
|
|
112
|
-
await
|
|
489
|
+
await assertUnchanged(sourcePath, original, "move source");
|
|
490
|
+
if (await lstatIfExists(destinationPath))
|
|
491
|
+
throw new Error(`move destination appeared during patch: ${change.movePath}`);
|
|
492
|
+
createdDirectories = await createParentDirectories(path.dirname(destinationPath));
|
|
493
|
+
try {
|
|
494
|
+
await fs.promises.rename(sourcePath, destinationPath);
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
await removeCreatedDirectories(createdDirectories).catch(() => undefined);
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
113
500
|
},
|
|
114
501
|
async rollback() {
|
|
115
|
-
await fs.promises.rename(
|
|
502
|
+
await fs.promises.rename(destinationPath, sourcePath);
|
|
503
|
+
await removeCreatedDirectories(createdDirectories);
|
|
116
504
|
},
|
|
117
505
|
};
|
|
118
506
|
}
|
|
119
|
-
async function prepareChange(
|
|
120
|
-
switch (change.action) {
|
|
507
|
+
async function prepareChange(resolved) {
|
|
508
|
+
switch (resolved.change.action) {
|
|
121
509
|
case "add":
|
|
122
|
-
return prepareAdd(
|
|
510
|
+
return prepareAdd(resolved);
|
|
123
511
|
case "update":
|
|
124
|
-
return prepareUpdate(
|
|
512
|
+
return prepareUpdate(resolved);
|
|
125
513
|
case "delete":
|
|
126
|
-
return prepareDelete(
|
|
514
|
+
return prepareDelete(resolved);
|
|
127
515
|
case "move":
|
|
128
|
-
return prepareMove(
|
|
516
|
+
return prepareMove(resolved);
|
|
129
517
|
default:
|
|
130
|
-
throw new Error(`unknown action: ${change.action}`);
|
|
518
|
+
throw new Error(`unknown action: ${resolved.change.action}`);
|
|
131
519
|
}
|
|
132
520
|
}
|
|
133
521
|
// ---------------------------------------------------------------------------
|
|
134
|
-
// Diff generation (
|
|
522
|
+
// Diff generation (for result/UI feedback)
|
|
135
523
|
// ---------------------------------------------------------------------------
|
|
136
|
-
function generateDiff(
|
|
524
|
+
function generateDiff(filePath, oldContent, newContent) {
|
|
137
525
|
if (oldContent === newContent)
|
|
138
526
|
return undefined;
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (oldLine)
|
|
149
|
-
diff += `- ${oldLine}\n`;
|
|
150
|
-
if (newLine)
|
|
151
|
-
diff += `+ ${newLine}\n`;
|
|
152
|
-
hasChanges = true;
|
|
153
|
-
}
|
|
154
|
-
else if (oldLine) {
|
|
155
|
-
diff += ` ${oldLine}\n`;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return hasChanges ? diff : undefined;
|
|
527
|
+
const patch = structuredPatch(filePath, filePath, oldContent, newContent, "", "", { context: 3 });
|
|
528
|
+
const lines = [];
|
|
529
|
+
for (const hunk of patch.hunks) {
|
|
530
|
+
const oldRange = `${hunk.oldStart},${hunk.oldLines}`;
|
|
531
|
+
const newRange = `${hunk.newStart},${hunk.newLines}`;
|
|
532
|
+
lines.push(`@@ -${oldRange} +${newRange} @@`);
|
|
533
|
+
lines.push(...hunk.lines);
|
|
534
|
+
}
|
|
535
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : undefined;
|
|
159
536
|
}
|
|
160
537
|
// ---------------------------------------------------------------------------
|
|
161
538
|
// Main executor
|
|
162
539
|
// ---------------------------------------------------------------------------
|
|
163
|
-
export async function executeApplyPatch(changes) {
|
|
164
|
-
|
|
540
|
+
export async function executeApplyPatch(changes, options = {}) {
|
|
541
|
+
if (!Array.isArray(changes) || changes.length === 0) {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
applied: [],
|
|
545
|
+
errors: [{ path: "", action: "patch", error: "patch must contain at least one change" }],
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
let policy;
|
|
549
|
+
try {
|
|
550
|
+
policy = await createPathPolicy(options);
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
return {
|
|
554
|
+
ok: false,
|
|
555
|
+
applied: [],
|
|
556
|
+
errors: [{ path: "", action: "patch", error: formatFilesystemError(error) }],
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const resolved = [];
|
|
165
560
|
const errors = [];
|
|
166
561
|
const claimedPaths = new Set();
|
|
167
562
|
for (const change of changes) {
|
|
168
|
-
|
|
563
|
+
let sourcePath;
|
|
564
|
+
let destinationPath;
|
|
565
|
+
try {
|
|
566
|
+
sourcePath = await policy.resolve(change?.path, `${change?.action ?? "change"} target`);
|
|
567
|
+
if (change?.action === "move") {
|
|
568
|
+
destinationPath = await policy.resolve(change.movePath, "move destination");
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
errors.push({
|
|
573
|
+
path: typeof change?.path === "string" ? change.path : "",
|
|
574
|
+
action: typeof change?.action === "string" ? change.action : "change",
|
|
575
|
+
error: formatFilesystemError(error),
|
|
576
|
+
});
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const paths = [sourcePath, ...(destinationPath ? [destinationPath] : [])];
|
|
169
580
|
if (paths.some((filePath) => claimedPaths.has(filePath))) {
|
|
170
581
|
errors.push({
|
|
171
582
|
path: change.path,
|
|
@@ -176,47 +587,59 @@ export async function executeApplyPatch(changes) {
|
|
|
176
587
|
}
|
|
177
588
|
for (const filePath of paths)
|
|
178
589
|
claimedPaths.add(filePath);
|
|
179
|
-
|
|
180
|
-
prepared.push(await prepareChange(change));
|
|
181
|
-
}
|
|
182
|
-
catch (err) {
|
|
183
|
-
errors.push({
|
|
184
|
-
path: change.path,
|
|
185
|
-
action: change.action,
|
|
186
|
-
error: err instanceof Error ? err.message : String(err),
|
|
187
|
-
});
|
|
188
|
-
}
|
|
590
|
+
resolved.push({ change, path: sourcePath, movePath: destinationPath });
|
|
189
591
|
}
|
|
190
592
|
if (errors.length > 0)
|
|
191
593
|
return { ok: false, applied: [], errors };
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
catch (err) {
|
|
200
|
-
for (const change of committed.reverse()) {
|
|
594
|
+
const lockPaths = resolved.flatMap(({ path: sourcePath, movePath: destinationPath }) => {
|
|
595
|
+
const paths = [sourcePath, ...(destinationPath ? [destinationPath] : [])];
|
|
596
|
+
return [...paths, ...paths.map((filePath) => path.dirname(filePath))];
|
|
597
|
+
});
|
|
598
|
+
return withPathLocks(lockPaths, async () => {
|
|
599
|
+
const prepared = [];
|
|
600
|
+
for (const change of resolved) {
|
|
201
601
|
try {
|
|
202
|
-
await change
|
|
602
|
+
prepared.push(await prepareChange(change));
|
|
203
603
|
}
|
|
204
|
-
catch (
|
|
604
|
+
catch (error) {
|
|
205
605
|
errors.push({
|
|
206
606
|
path: change.change.path,
|
|
207
607
|
action: change.change.action,
|
|
208
|
-
error:
|
|
608
|
+
error: formatFilesystemError(error),
|
|
209
609
|
});
|
|
210
610
|
}
|
|
211
611
|
}
|
|
212
|
-
errors.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
612
|
+
if (errors.length > 0)
|
|
613
|
+
return { ok: false, applied: [], errors };
|
|
614
|
+
const committed = [];
|
|
615
|
+
try {
|
|
616
|
+
for (const change of prepared) {
|
|
617
|
+
await change.commit();
|
|
618
|
+
committed.push(change);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
for (const change of committed.reverse()) {
|
|
623
|
+
try {
|
|
624
|
+
await change.rollback();
|
|
625
|
+
}
|
|
626
|
+
catch (rollbackError) {
|
|
627
|
+
errors.push({
|
|
628
|
+
path: change.change.path,
|
|
629
|
+
action: change.change.action,
|
|
630
|
+
error: `rollback failed: ${formatFilesystemError(rollbackError)}`,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
errors.unshift({
|
|
635
|
+
path: prepared[committed.length]?.change.path ?? "",
|
|
636
|
+
action: prepared[committed.length]?.change.action ?? "commit",
|
|
637
|
+
error: formatFilesystemError(error),
|
|
638
|
+
});
|
|
639
|
+
return { ok: false, applied: [], errors };
|
|
640
|
+
}
|
|
641
|
+
return { ok: true, applied: prepared.map((change) => change.applied), errors: [] };
|
|
642
|
+
});
|
|
220
643
|
}
|
|
221
644
|
// ---------------------------------------------------------------------------
|
|
222
645
|
// Format result for tool output
|