@yeaft/webchat-agent 1.0.527 → 1.0.528

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.
@@ -1,129 +1,450 @@
1
1
  /**
2
- * apply-patch.js — Apply a unified diff patch to files.
2
+ * apply-patch.js — Validate and apply a unified diff patch to files.
3
3
  *
4
- * Parses unified diff format and applies changes to the target files.
5
- * Supports multiple files in a single patch.
4
+ * The complete patch and every source file are validated before the first
5
+ * write. Filesystem writes are still sequential and therefore are not a
6
+ * cross-file transaction when a runtime I/O error occurs.
6
7
  */
7
8
 
8
9
  import { defineTool } from './types.js';
9
- import { readFile, writeFile, mkdir } from 'fs/promises';
10
- import { existsSync } from 'fs';
11
- import { resolve, dirname } from 'path';
10
+ import { readFile, mkdir, lstat, realpath, open } from 'fs/promises';
11
+ import { existsSync, constants } from 'fs';
12
+ import { dirname, posix, resolve, win32 } from 'path';
12
13
 
13
- /**
14
- * Parse a unified diff into hunks.
15
- * @param {string} patch
16
- * @returns {Array<{ file: string, hunks: Array }>}
17
- */
18
- function parsePatchTarget(line) {
14
+ const NO_NEWLINE_MARKER = '\';
15
+
16
+ function patchError(message, lineNumber) {
17
+ const suffix = lineNumber == null ? '' : ` at patch line ${lineNumber}`;
18
+ return new Error(`${message}${suffix}`);
19
+ }
20
+
21
+ function parsePatchTarget(line, prefix, lineNumber) {
19
22
  const raw = line.slice(4).replace(/\r$/, '');
20
23
  const tabIndex = raw.indexOf('\t');
21
24
  let filePath = (tabIndex === -1 ? raw : raw.slice(0, tabIndex)).trim();
22
- if (!filePath) throw new Error('Patch target path is empty');
23
- if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(filePath)) {
24
- throw new Error('Patch target path contains control characters');
25
+ if (!filePath) throw patchError('Patch target path is empty', lineNumber);
26
+ if (/[\u0000-\u001f\u007f]/.test(filePath)) {
27
+ throw patchError('Patch target path contains control characters', lineNumber);
28
+ }
29
+ if (filePath === '/dev/null') return null;
30
+ if (filePath.startsWith('"') || filePath.endsWith('"')) {
31
+ throw patchError('Quoted patch target paths are not supported', lineNumber);
32
+ }
33
+
34
+ if (filePath.startsWith(`${prefix}/`)) filePath = filePath.slice(2);
35
+ if (!filePath || filePath === '.' || filePath.includes('\\')
36
+ || posix.isAbsolute(filePath) || win32.isAbsolute(filePath)) {
37
+ throw patchError(`Patch target must be a relative path: ${filePath}`, lineNumber);
38
+ }
39
+
40
+ const normalized = posix.normalize(filePath);
41
+ if (normalized === '..' || normalized.startsWith('../')
42
+ || filePath.split('/').includes('..')) {
43
+ throw patchError(`Patch target escapes the working directory: ${filePath}`, lineNumber);
44
+ }
45
+ return normalized;
46
+ }
47
+
48
+ function parseHunkHeader(line, lineNumber) {
49
+ const match = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?\r?$/);
50
+ if (!match) throw patchError('Malformed hunk header', lineNumber);
51
+
52
+ const hunk = {
53
+ oldStart: Number.parseInt(match[1], 10),
54
+ oldCount: match[2] == null ? 1 : Number.parseInt(match[2], 10),
55
+ newStart: Number.parseInt(match[3], 10),
56
+ newCount: match[4] == null ? 1 : Number.parseInt(match[4], 10),
57
+ lines: [],
58
+ };
59
+ if (![hunk.oldStart, hunk.oldCount, hunk.newStart, hunk.newCount].every(Number.isSafeInteger)) {
60
+ throw patchError('Hunk ranges must be safe integers', lineNumber);
61
+ }
62
+ if (hunk.oldStart === 0 && hunk.oldCount !== 0) {
63
+ throw patchError('A non-empty old range must start at line 1 or later', lineNumber);
64
+ }
65
+ if (hunk.newStart === 0 && hunk.newCount !== 0) {
66
+ throw patchError('A non-empty new range must start at line 1 or later', lineNumber);
67
+ }
68
+ return hunk;
69
+ }
70
+
71
+ function oldLineIndex(hunk) {
72
+ return hunk.oldCount === 0 ? hunk.oldStart : hunk.oldStart - 1;
73
+ }
74
+
75
+ function newLineIndex(hunk) {
76
+ return hunk.newCount === 0 ? hunk.newStart : hunk.newStart - 1;
77
+ }
78
+
79
+ function validateParsedFile(fileDiff, lineNumber) {
80
+ if (fileDiff.oldFile == null && fileDiff.newFile == null) {
81
+ throw patchError('Both patch targets cannot be /dev/null', lineNumber);
82
+ }
83
+ if (fileDiff.newFile == null) {
84
+ throw patchError('File deletion patches are not supported', lineNumber);
85
+ }
86
+ if (fileDiff.oldFile != null && fileDiff.oldFile !== fileDiff.newFile) {
87
+ throw patchError('File rename patches are not supported', lineNumber);
88
+ }
89
+ if (fileDiff.hunks.length === 0) {
90
+ throw patchError(`Patch for ${fileDiff.file} has no hunks`, lineNumber);
91
+ }
92
+
93
+ let previousOldEnd = 0;
94
+ let cumulativeDelta = 0;
95
+ for (const hunk of fileDiff.hunks) {
96
+ const oldIndex = oldLineIndex(hunk);
97
+ const newIndex = newLineIndex(hunk);
98
+ if (oldIndex < previousOldEnd) {
99
+ throw patchError(`Patch hunks for ${fileDiff.file} overlap or are out of order`, lineNumber);
100
+ }
101
+ if (newIndex !== oldIndex + cumulativeDelta) {
102
+ throw patchError(`New line range for ${fileDiff.file} is inconsistent with earlier hunks`, lineNumber);
103
+ }
104
+ previousOldEnd = oldIndex + hunk.oldCount;
105
+ cumulativeDelta += hunk.newCount - hunk.oldCount;
25
106
  }
26
- if (filePath.startsWith('b/')) filePath = filePath.slice(2);
27
- return filePath === '/dev/null' ? null : filePath;
28
107
  }
29
108
 
109
+ /**
110
+ * Parse the supported unified diff contract.
111
+ * @param {string} patch
112
+ * @returns {Array<{file: string, oldFile: string|null, newFile: string, hunks: Array}>}
113
+ */
30
114
  export function parsePatch(patch) {
31
- const files = [];
115
+ if (typeof patch !== 'string' || patch.length === 0) {
116
+ throw new Error('Patch is empty');
117
+ }
118
+
32
119
  const lines = patch.split('\n');
33
- let currentFile = null;
34
- let currentHunk = null;
35
-
36
- for (let i = 0; i < lines.length; i++) {
37
- const line = lines[i];
38
-
39
- // File header: --- a/path or +++ b/path
40
- if (line.startsWith('--- ')) {
41
- // Next line should be +++
42
- continue;
43
- }
44
- if (line.startsWith('+++ ')) {
45
- const filePath = parsePatchTarget(line);
46
- if (!filePath) continue;
47
-
48
- currentFile = { file: filePath, hunks: [] };
49
- files.push(currentFile);
50
- continue;
51
- }
52
-
53
- // Hunk header: @@ -start,count +start,count @@
54
- const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
55
- if (hunkMatch) {
56
- if (!currentFile) continue;
57
- currentHunk = {
58
- oldStart: parseInt(hunkMatch[1], 10),
59
- oldCount: hunkMatch[2] ? parseInt(hunkMatch[2], 10) : 1,
60
- newStart: parseInt(hunkMatch[3], 10),
61
- newCount: hunkMatch[4] ? parseInt(hunkMatch[4], 10) : 1,
62
- lines: [],
120
+ if (lines.at(-1) === '') lines.pop();
121
+ const files = [];
122
+ const seenFiles = new Set();
123
+ let i = 0;
124
+
125
+ while (i < lines.length) {
126
+ const line = lines[i].replace(/\r$/, '');
127
+ let gitHeader = null;
128
+ let hasNewFileMode = false;
129
+
130
+ if (line.startsWith('diff --git ')) {
131
+ const match = line.match(/^diff --git (\S+) (\S+)$/);
132
+ if (!match) throw patchError('Malformed or unsupported diff --git header', i + 1);
133
+ gitHeader = {
134
+ oldFile: parsePatchTarget(`--- ${match[1]}`, 'a', i + 1),
135
+ newFile: parsePatchTarget(`+++ ${match[2]}`, 'b', i + 1),
63
136
  };
64
- currentFile.hunks.push(currentHunk);
65
- continue;
137
+ if (gitHeader.oldFile !== gitHeader.newFile) {
138
+ throw patchError('File rename patches are not supported', i + 1);
139
+ }
140
+ i++;
141
+
142
+ let hasIndex = false;
143
+ while (i < lines.length) {
144
+ const metadata = lines[i].replace(/\r$/, '');
145
+ if (/^new file mode \d+$/.test(metadata)) {
146
+ if (metadata !== 'new file mode 100644') {
147
+ throw patchError('Only regular non-executable new files are supported; mode changes are not applied', i + 1);
148
+ }
149
+ if (hasNewFileMode || hasIndex) {
150
+ throw patchError('Misplaced or duplicate new file mode header', i + 1);
151
+ }
152
+ hasNewFileMode = true;
153
+ i++;
154
+ continue;
155
+ }
156
+ if (/^index [0-9a-fA-F]+\.\.[0-9a-fA-F]+(?: \d+)?$/.test(metadata)) {
157
+ if (hasIndex) throw patchError('Duplicate index header', i + 1);
158
+ hasIndex = true;
159
+ i++;
160
+ continue;
161
+ }
162
+ break;
163
+ }
164
+ }
165
+
166
+ if (i >= lines.length || !lines[i].replace(/\r$/, '').startsWith('--- ')) {
167
+ const unexpected = i < lines.length ? lines[i].replace(/\r$/, '') : '<end of patch>';
168
+ throw patchError(`Unsupported or unexpected patch header: ${unexpected}`, i + 1);
66
169
  }
67
170
 
68
- // Diff content lines
69
- if (currentHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ') || line === '')) {
70
- currentHunk.lines.push(line);
171
+ const oldFile = parsePatchTarget(lines[i], 'a', i + 1);
172
+ i++;
173
+ if (i >= lines.length || !lines[i].replace(/\r$/, '').startsWith('+++ ')) {
174
+ throw patchError('A --- file header must be followed by a +++ file header', i + 1);
175
+ }
176
+ const newFile = parsePatchTarget(lines[i], 'b', i + 1);
177
+ if (i + 1 < lines.length && lines[i + 1].replace(/\r$/, '').startsWith('+++ ')) {
178
+ throw patchError('Unexpected duplicate +++ file header', i + 2);
71
179
  }
180
+ if (gitHeader && (oldFile ?? gitHeader.oldFile) !== gitHeader.oldFile) {
181
+ throw patchError('--- target does not match diff --git header', i);
182
+ }
183
+ if (gitHeader && newFile !== gitHeader.newFile) {
184
+ throw patchError('+++ target does not match diff --git header', i + 1);
185
+ }
186
+ if (hasNewFileMode && oldFile != null) {
187
+ throw patchError('new file mode requires --- /dev/null', i);
188
+ }
189
+ const file = newFile || oldFile;
190
+ const fileDiff = { file, oldFile, newFile, hunks: [] };
191
+ i++;
192
+
193
+ if (seenFiles.has(file)) {
194
+ throw patchError(`Patch contains duplicate file target: ${file}`, i);
195
+ }
196
+ seenFiles.add(file);
197
+
198
+ while (i < lines.length) {
199
+ const current = lines[i].replace(/\r$/, '');
200
+ if (current === '' || current.startsWith('--- ')
201
+ || /^diff --git /.test(current)) break;
202
+ if (!current.startsWith('@@ ')) {
203
+ throw patchError(`Unsupported or unexpected patch content: ${current}`, i + 1);
204
+ }
205
+
206
+ const hunk = parseHunkHeader(current, i + 1);
207
+ i++;
208
+ let oldCount = 0;
209
+ let newCount = 0;
210
+ let changed = false;
211
+
212
+ while (oldCount < hunk.oldCount || newCount < hunk.newCount) {
213
+ if (i >= lines.length) throw patchError('Hunk ended before its declared ranges', i + 1);
214
+ const rawDiffLine = lines[i].replace(/\r$/, '');
215
+ const type = rawDiffLine[0];
216
+ if (![' ', '+', '-'].includes(type)) {
217
+ throw patchError('Hunk ended before its declared ranges', i + 1);
218
+ }
219
+
220
+ const entry = { type, text: rawDiffLine.slice(1), noNewline: false };
221
+ if (type !== '+') oldCount++;
222
+ if (type !== '-') newCount++;
223
+ if (type !== ' ') changed = true;
224
+ if (oldCount > hunk.oldCount || newCount > hunk.newCount) {
225
+ throw patchError('Hunk body exceeds its declared ranges', i + 1);
226
+ }
227
+ hunk.lines.push(entry);
228
+ i++;
229
+
230
+ if (i < lines.length && lines[i].replace(/\r$/, '') === NO_NEWLINE_MARKER) {
231
+ entry.noNewline = true;
232
+ i++;
233
+ }
234
+ }
235
+
236
+ if (!changed) throw patchError('Hunk contains no additions or deletions', i);
237
+ const oldEntries = hunk.lines.filter(entry => entry.type !== '+');
238
+ const newEntries = hunk.lines.filter(entry => entry.type !== '-');
239
+ const markedEntries = hunk.lines.filter(entry => entry.noNewline);
240
+ for (const entry of markedEntries) {
241
+ const lastOldEntry = entry.type === '+' || oldEntries.at(-1) === entry;
242
+ const lastNewEntry = entry.type === '-' || newEntries.at(-1) === entry;
243
+ if (!lastOldEntry || !lastNewEntry) {
244
+ throw patchError('No-newline marker must describe the final line of its range', i);
245
+ }
246
+ }
247
+ hunk.oldNoNewline = oldEntries.at(-1)?.noNewline === true;
248
+ hunk.newNoNewline = newEntries.at(-1)?.noNewline === true;
249
+ hunk.oldLines = oldEntries.map(entry => entry.text);
250
+ hunk.newLines = newEntries.map(entry => entry.text);
251
+ fileDiff.hunks.push(hunk);
252
+ }
253
+
254
+ validateParsedFile(fileDiff, i + 1);
255
+ files.push(fileDiff);
72
256
  }
73
257
 
258
+ if (files.length === 0) throw new Error('No valid file diffs found in patch');
74
259
  return files;
75
260
  }
76
261
 
77
- /**
78
- * Apply hunks to file content.
79
- */
80
- function applyHunks(content, hunks) {
81
- const lines = content.split('\n');
82
- let offset = 0; // tracks line number shifts from previous hunks
83
-
84
- for (const hunk of hunks) {
85
- const startLine = hunk.oldStart - 1 + offset; // 0-based
86
- const newLines = [];
87
- let removedCount = 0;
88
-
89
- for (const diffLine of hunk.lines) {
90
- if (diffLine.startsWith('+')) {
91
- newLines.push(diffLine.slice(1));
92
- } else if (diffLine.startsWith('-')) {
93
- removedCount++;
94
- } else if (diffLine.startsWith(' ') || diffLine === '') {
95
- newLines.push(diffLine.startsWith(' ') ? diffLine.slice(1) : diffLine);
262
+ function splitFileContent(content) {
263
+ const lines = [];
264
+ const terminators = [];
265
+ const pattern = /([^\n]*)(\n|$)/g;
266
+ for (const match of content.matchAll(pattern)) {
267
+ if (!match[0]) break;
268
+ const crlf = match[2] && match[1].endsWith('\r');
269
+ lines.push(crlf ? match[1].slice(0, -1) : match[1]);
270
+ terminators.push(crlf ? '\r\n' : match[2]);
271
+ }
272
+ return { lines, terminators, endsWithNewline: content.endsWith('\n'),
273
+ eol: terminators.find(Boolean) || '\n' };
274
+ }
275
+
276
+ function applyHunks(content, fileDiff) {
277
+ const source = splitFileContent(content);
278
+ const output = [];
279
+ let cursor = 0;
280
+
281
+ for (const hunk of fileDiff.hunks) {
282
+ const start = oldLineIndex(hunk);
283
+ if (start > source.lines.length || start + hunk.oldCount > source.lines.length) {
284
+ throw new Error(`Hunk source range is outside ${fileDiff.file}`);
285
+ }
286
+
287
+ if (hunk.oldCount === 0 && start > 0 && !source.terminators[start - 1]) {
288
+ throw new Error(`Insertion after an unterminated line requires replacing that line in ${fileDiff.file}`);
289
+ }
290
+ const actual = source.lines.slice(start, start + hunk.oldCount);
291
+ for (let index = 0; index < hunk.oldLines.length; index++) {
292
+ if (actual[index] !== hunk.oldLines[index]) {
293
+ throw new Error(
294
+ `Hunk context mismatch in ${fileDiff.file} at line ${start + index + 1}: `
295
+ + `expected ${JSON.stringify(hunk.oldLines[index])}, found ${JSON.stringify(actual[index])}`,
296
+ );
96
297
  }
97
298
  }
98
299
 
99
- // Replace the old lines with new lines
100
- lines.splice(startLine, removedCount + (hunk.oldCount - removedCount > 0 ? hunk.oldCount - removedCount : 0), ...newLines);
101
- offset += newLines.length - hunk.oldCount;
300
+ const oldTouchesEof = start + hunk.oldCount === source.lines.length;
301
+ if (hunk.oldNoNewline && (!oldTouchesEof || source.endsWithNewline)) {
302
+ throw new Error(`Old no-newline marker does not match ${fileDiff.file}`);
303
+ }
304
+ if (oldTouchesEof && hunk.oldCount > 0 && !hunk.oldNoNewline && !source.endsWithNewline) {
305
+ throw new Error(`Expected a newline at end of ${fileDiff.file}`);
306
+ }
307
+
308
+ if (hunk.newNoNewline && (!oldTouchesEof || hunk !== fileDiff.hunks.at(-1))) {
309
+ throw new Error(`New no-newline marker must describe the final output line in ${fileDiff.file}`);
310
+ }
311
+ // Preserve every untouched line's terminator, including mixed LF/CRLF.
312
+ for (let index = cursor; index < start; index++) {
313
+ output.push(source.lines[index] + source.terminators[index]);
314
+ }
315
+ let oldCursor = start;
316
+ for (const entry of hunk.lines) {
317
+ if (entry.type === ' ') {
318
+ output.push(source.lines[oldCursor] + source.terminators[oldCursor]);
319
+ oldCursor++;
320
+ } else if (entry.type === '-') {
321
+ oldCursor++;
322
+ } else {
323
+ const eol = source.terminators[Math.max(start, oldCursor - 1)]
324
+ || source.terminators[oldCursor] || source.eol;
325
+ output.push(entry.text + (entry.noNewline ? '' : eol));
326
+ }
327
+ }
328
+ cursor = start + hunk.oldCount;
329
+ }
330
+
331
+ for (let index = cursor; index < source.lines.length; index++) {
332
+ output.push(source.lines[index] + source.terminators[index]);
333
+ }
334
+ return output.join('');
335
+ }
336
+
337
+ // Lexical containment is checked by parsePatch. Reject symlinks at every
338
+ // existing component, and remember identities to detect changes before I/O.
339
+ // These checks are not an OS sandbox against hostile concurrent directory swaps.
340
+ async function inspectTarget(cwd, file) {
341
+ const parts = file.split('/');
342
+ const identities = [];
343
+ let current = cwd;
344
+ for (let index = -1; index < parts.length; index++) {
345
+ if (index >= 0) current = resolve(current, parts[index]);
346
+ let info;
347
+ try { info = await lstat(current); }
348
+ catch (error) { if (error.code === 'ENOENT') break; throw error; }
349
+ if (info.isSymbolicLink()) throw new Error(`Symbolic link target is not supported: ${file}`);
350
+ if (index < parts.length - 1 && !info.isDirectory()) {
351
+ throw new Error(`Patch parent is not a directory: ${file}`);
352
+ }
353
+ if (index === parts.length - 1 && !info.isFile()) throw new Error(`Patch target is not a regular file: ${file}`);
354
+ identities.push({ path: current, dev: info.dev, ino: info.ino });
355
+ }
356
+ return identities;
357
+ }
358
+
359
+ async function verifyTarget(cwd, plan) {
360
+ const current = await inspectTarget(cwd, plan.file);
361
+ for (const old of plan.identities) {
362
+ if (!current.some(now => now.path === old.path && now.dev === old.dev && now.ino === old.ino)) {
363
+ throw new Error(`Patch target changed after validation: ${plan.file}`);
364
+ }
102
365
  }
366
+ return current;
367
+ }
103
368
 
104
- return lines.join('\n');
369
+ async function writePlan(cwd, plan, signal) {
370
+ if (signal?.aborted) throw new Error('Patch cancelled before write');
371
+ await verifyTarget(cwd, plan);
372
+ await mkdir(dirname(plan.absPath), { recursive: true });
373
+ await verifyTarget(cwd, plan);
374
+ const flags = plan.oldFile == null ? constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL
375
+ : constants.O_RDWR | (constants.O_NOFOLLOW || 0);
376
+ const handle = await open(plan.absPath, flags, 0o666);
377
+ try {
378
+ const identities = await verifyTarget(cwd, plan);
379
+ const opened = await handle.stat();
380
+ const target = identities.at(-1);
381
+ if (!opened.isFile() || target?.dev !== opened.dev || target?.ino !== opened.ino) {
382
+ throw new Error(`Patch target replaced during open: ${plan.file}`);
383
+ }
384
+ if (plan.oldFile != null && await handle.readFile('utf8') !== plan.original) {
385
+ throw new Error(`Patch content changed after validation: ${plan.file}`);
386
+ }
387
+ await verifyTarget(cwd, plan);
388
+ if (signal?.aborted) throw new Error('Patch cancelled before write');
389
+ const bytes = Buffer.from(plan.content, 'utf8');
390
+ let offset = 0;
391
+ while (offset < bytes.length) {
392
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset, offset);
393
+ if (!bytesWritten) throw new Error('Patch write made no progress');
394
+ offset += bytesWritten;
395
+ }
396
+ await handle.truncate(bytes.length);
397
+ } finally { await handle.close(); }
398
+ }
399
+
400
+ async function preparePatch(cwd, fileDiffs) {
401
+ const plans = [];
402
+ for (const fileDiff of fileDiffs) {
403
+ const absPath = resolve(cwd, fileDiff.file);
404
+ const identities = await inspectTarget(cwd, fileDiff.file);
405
+ const exists = existsSync(absPath);
406
+ if (fileDiff.oldFile == null && exists) {
407
+ throw new Error(`New file already exists: ${fileDiff.file}`);
408
+ }
409
+ if (fileDiff.oldFile != null && !exists) {
410
+ throw new Error(`File not found: ${fileDiff.file}`);
411
+ }
412
+
413
+ const content = exists ? await readFile(absPath, 'utf8') : '';
414
+ plans.push({
415
+ ...fileDiff,
416
+ absPath,
417
+ identities,
418
+ original: content,
419
+ content: applyHunks(content, fileDiff),
420
+ });
421
+ }
422
+ return plans;
105
423
  }
106
424
 
107
425
  export default defineTool({
108
426
  name: 'ApplyPatch',
109
427
  description: {
110
- en: `Apply a unified diff patch to files.
428
+ en: `Apply a standard unified diff patch to one or more files.
111
429
 
112
- Parses unified diff format (like git diff output) and applies changes.
113
- Supports patching multiple files in a single diff.
430
+ The complete patch is parsed and all original context/deletion lines are matched before any file is written. Multiple hunks per file and multiple files are supported; new files use the standard /dev/null header. Targets must be relative paths that stay within the working directory.
114
431
 
115
432
  Guidelines:
116
433
  - Provide standard unified diff format (--- a/file, +++ b/file, @@ hunks)
117
- - Ensure the diff matches the current file content exactly
118
- - New files are created automatically with parent directories`,
119
- zh: `将 unified diff 补丁应用到文件。
434
+ - Include exact current context; stale or malformed patches make no changes
435
+ - Use one file header with multiple ordered hunks for several edits in a file
436
+ - Symlink targets/ancestors are rejected and checked again before writing; this is not a sandbox against hostile concurrent filesystem mutation
437
+ - Validation is all-or-nothing, but filesystem writes are not transactional: a runtime I/O failure can leave earlier files changed and the failed file partially written`,
438
+ zh: `将标准 unified diff 补丁应用到一个或多个文件。
120
439
 
121
- 解析 unified diff 格式(如 git diff 输出)并应用更改。支持单个 diff 修补多个文件。
440
+ 写入任何文件前,会完整解析补丁并匹配全部原上下文和删除行。支持同文件多个块和跨文件补丁;新文件使用标准 /dev/null 头。目标必须是工作目录内的相对路径。
122
441
 
123
442
  使用指南:
124
- - 提供标准 unified diff 格式(--- a/file, +++ b/file, @@ 块)
125
- - 确保 diff 与当前文件内容精确匹配
126
- - 新文件自动创建并创建父目录`
443
+ - 提供标准 unified diff 格式(--- a/file、+++ b/file、@@ 块)
444
+ - 上下文必须与当前内容精确一致;过时或畸形补丁不会产生修改
445
+ - 同一文件的多处编辑使用一个文件头和多个有序块
446
+ - 拒绝符号链接目标和祖先,写入前再次检查;这不等同于防御恶意并发文件系统修改的 sandbox
447
+ - 验证是全有或全无的,但文件系统写入不是事务:运行时 I/O 失败可能保留之前已写入的文件,失败文件也可能只写入一部分`
127
448
  },
128
449
  parameters: {
129
450
  type: 'object',
@@ -131,8 +452,8 @@ Guidelines:
131
452
  patch: {
132
453
  type: 'string',
133
454
  description: {
134
- en: 'The unified diff patch content',
135
- zh: 'unified diff 补丁内容',
455
+ en: 'The standard unified diff patch content',
456
+ zh: '标准 unified diff 补丁内容',
136
457
  },
137
458
  },
138
459
  },
@@ -143,56 +464,43 @@ Guidelines:
143
464
  isDestructive: () => false,
144
465
  async execute(input, ctx) {
145
466
  const { patch } = input;
146
- if (!patch) return JSON.stringify({ error: 'patch is required' });
147
-
148
- const cwd = ctx?.cwd || process.cwd();
467
+ if (!patch) return JSON.stringify({ errorEffect: 'none', error: 'patch is required' });
149
468
 
469
+ let cwd;
470
+ let plans;
150
471
  try {
151
- const fileDiffs = parsePatch(patch);
152
-
153
- if (fileDiffs.length === 0) {
154
- return JSON.stringify({ error: 'No valid file diffs found in patch' });
155
- }
156
-
157
- const results = [];
158
-
159
- for (const fileDiff of fileDiffs) {
160
- const absPath = resolve(cwd, fileDiff.file);
161
-
162
- try {
163
- let content;
164
- if (existsSync(absPath)) {
165
- content = await readFile(absPath, 'utf-8');
166
- } else {
167
- // New file
168
- await mkdir(dirname(absPath), { recursive: true });
169
- content = '';
170
- }
472
+ const parsed = parsePatch(patch);
473
+ cwd = await realpath(ctx?.cwd || process.cwd());
474
+ plans = await preparePatch(cwd, parsed);
475
+ } catch (err) {
476
+ return JSON.stringify({
477
+ errorEffect: 'none',
478
+ error: `Patch validation failed: ${err.message}`,
479
+ });
480
+ }
171
481
 
172
- const newContent = applyHunks(content, fileDiff.hunks);
173
- await writeFile(absPath, newContent, 'utf-8');
174
-
175
- results.push({
176
- file: fileDiff.file,
177
- success: true,
178
- hunks: fileDiff.hunks.length,
179
- });
180
- } catch (err) {
181
- results.push({
182
- file: fileDiff.file,
183
- success: false,
184
- error: err.message,
185
- });
186
- }
482
+ const results = [];
483
+ for (let index = 0; index < plans.length; index++) {
484
+ const plan = plans[index];
485
+ try {
486
+ await writePlan(cwd, plan, ctx?.signal);
487
+ results.push({ file: plan.file, success: true, hunks: plan.hunks.length });
488
+ } catch (err) {
489
+ return JSON.stringify({
490
+ errorEffect: 'unknown',
491
+ error: `Patch write failed for ${plan.file}: ${err.message}`,
492
+ results,
493
+ failedFile: plan.file,
494
+ remainingFiles: plans.slice(index + 1).map(item => item.file),
495
+ warning: 'The patch was fully validated, but filesystem writes are not transactional; successful files listed in results may already be changed, and the failed file may be partially written.',
496
+ }, null, 2);
187
497
  }
188
-
189
- const successCount = results.filter(r => r.success).length;
190
- return JSON.stringify({
191
- results,
192
- summary: `Applied patch to ${successCount}/${results.length} files`,
193
- }, null, 2);
194
- } catch (err) {
195
- return JSON.stringify({ error: `Failed to apply patch: ${err.message}` });
196
498
  }
499
+
500
+ return JSON.stringify({
501
+ success: true,
502
+ results,
503
+ summary: `Applied ${results.reduce((sum, result) => sum + result.hunks, 0)} hunk(s) to ${results.length} file(s)`,
504
+ }, null, 2);
197
505
  },
198
506
  });