@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.
@@ -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
- * Uses replace.ts's conservative matcher for oldText newText matching.
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 { replaceForPatch } from "./replace.js";
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
- // Atomic file write
133
+ // Path and filesystem safety
12
134
  // ---------------------------------------------------------------------------
13
- async function atomicWriteFile(filePath, content, mode) {
14
- const dir = path.dirname(filePath);
15
- const tmp = path.join(dir, `.${path.basename(filePath)}.pi-apply-patch.${process.pid}.tmp`);
16
- await fs.promises.writeFile(tmp, content, { encoding: "utf8", mode });
17
- await fs.promises.rename(tmp, filePath);
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
- async function readRegularFile(filePath, label) {
20
- let stats;
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
- stats = await fs.promises.lstat(filePath);
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(`${label} not found: ${filePath}`);
189
+ catch (error) {
190
+ throw new Error(`workspace could not be resolved: ${formatFilesystemError(error)}`);
26
191
  }
27
- if (stats.isSymbolicLink())
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 (!stats.isFile())
222
+ if (!initialStats.isFile())
30
223
  throw new Error(`${label} must be a regular file: ${filePath}`);
31
- return { content: await fs.promises.readFile(filePath, "utf8"), mode: stats.mode };
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 pathExists(filePath) {
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.lstat(filePath);
36
- return true;
281
+ await fs.promises.mkdir(directory, { recursive: true });
37
282
  }
38
- catch {
39
- return false;
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 prepareAdd(change) {
43
- if (await pathExists(change.path))
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 fs.promises.mkdir(path.dirname(change.path), { recursive: true });
52
- await atomicWriteFile(change.path, final);
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(change.path);
380
+ await fs.promises.unlink(targetPath);
381
+ await removeCreatedDirectories(createdDirectories);
56
382
  },
57
383
  };
58
384
  }
59
- async function prepareUpdate(change) {
60
- if (!change.oldText)
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
- if (change.oldText === change.newText)
63
- throw new Error("oldText and newText are identical — no change");
64
- const original = await readRegularFile(change.path, "update target");
65
- const result = replaceForPatch(original.content, change.oldText, change.newText ?? "");
66
- if (!result.changed)
67
- throw new Error(`oldText not found in ${change.path}`);
68
- const newContent = result.content;
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.content, newContent),
448
+ diff: generateDiff(change.path, original.rawContent, newContent),
75
449
  bytes: Buffer.byteLength(newContent, "utf8"),
76
- oldContent: original.content,
450
+ oldContent: original.rawContent,
77
451
  newContent,
78
452
  },
79
453
  async commit() {
80
- await atomicWriteFile(change.path, newContent, original.mode);
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(change.path, original.content, original.mode);
458
+ await atomicWriteFile(targetPath, original.bytes, original.mode);
84
459
  },
85
460
  };
86
461
  }
87
- async function prepareDelete(change) {
88
- const original = await readRegularFile(change.path, "delete target");
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.content },
467
+ applied: { path: change.path, action: "delete", oldContent: original.rawContent },
92
468
  async commit() {
93
- await fs.promises.unlink(change.path);
469
+ await assertUnchanged(targetPath, original, "delete target");
470
+ await fs.promises.unlink(targetPath);
94
471
  },
95
472
  async rollback() {
96
- await atomicWriteFile(change.path, original.content, original.mode);
473
+ await atomicWriteFile(targetPath, original.bytes, original.mode, false);
97
474
  },
98
475
  };
99
476
  }
100
- async function prepareMove(change) {
101
- const movePath = change.movePath;
102
- if (!movePath)
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(change.path, "move source");
105
- if (await pathExists(movePath))
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 fs.promises.mkdir(path.dirname(movePath), { recursive: true });
112
- await fs.promises.rename(change.path, movePath);
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(movePath, change.path);
502
+ await fs.promises.rename(destinationPath, sourcePath);
503
+ await removeCreatedDirectories(createdDirectories);
116
504
  },
117
505
  };
118
506
  }
119
- async function prepareChange(change) {
120
- switch (change.action) {
507
+ async function prepareChange(resolved) {
508
+ switch (resolved.change.action) {
121
509
  case "add":
122
- return prepareAdd(change);
510
+ return prepareAdd(resolved);
123
511
  case "update":
124
- return prepareUpdate(change);
512
+ return prepareUpdate(resolved);
125
513
  case "delete":
126
- return prepareDelete(change);
514
+ return prepareDelete(resolved);
127
515
  case "move":
128
- return prepareMove(change);
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 (simple, for UI feedback)
522
+ // Diff generation (for result/UI feedback)
135
523
  // ---------------------------------------------------------------------------
136
- function generateDiff(_filePath, oldContent, newContent) {
524
+ function generateDiff(filePath, oldContent, newContent) {
137
525
  if (oldContent === newContent)
138
526
  return undefined;
139
- const oldLines = oldContent.split("\n");
140
- const newLines = newContent.split("\n");
141
- const maxLen = Math.max(oldLines.length, newLines.length);
142
- let diff = "";
143
- let hasChanges = false;
144
- for (let i = 0; i < maxLen; i++) {
145
- const oldLine = oldLines[i] ?? "";
146
- const newLine = newLines[i] ?? "";
147
- if (oldLine !== newLine) {
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
- const prepared = [];
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
- const paths = [change.path, ...(change.action === "move" && change.movePath ? [change.movePath] : [])].map((filePath) => path.resolve(filePath));
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
- try {
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 committed = [];
193
- try {
194
- for (const change of prepared) {
195
- await change.commit();
196
- committed.push(change);
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.rollback();
602
+ prepared.push(await prepareChange(change));
203
603
  }
204
- catch (rollbackError) {
604
+ catch (error) {
205
605
  errors.push({
206
606
  path: change.change.path,
207
607
  action: change.change.action,
208
- error: `rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
608
+ error: formatFilesystemError(error),
209
609
  });
210
610
  }
211
611
  }
212
- errors.unshift({
213
- path: prepared[committed.length]?.change.path ?? "",
214
- action: prepared[committed.length]?.change.action ?? "commit",
215
- error: err instanceof Error ? err.message : String(err),
216
- });
217
- return { ok: false, applied: [], errors };
218
- }
219
- return { ok: true, applied: prepared.map((change) => change.applied), errors: [] };
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