triage-trip 0.1.0 → 0.1.4

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/src/patch.js CHANGED
@@ -1,65 +1,597 @@
1
- // src/patch.js
1
+ import { spawn } from 'child_process';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import { spawn } from 'child_process';
4
+ import { parse } from '@babel/parser';
5
+ import traversePkg from '@babel/traverse';
6
+
7
+ const traverse = traversePkg.default?.default || traversePkg.default || traversePkg;
5
8
 
9
+ /**
10
+ * Extracts unified diff text from LLM response.
11
+ * Handles markdown fences (```diff, ```patch, generic ```), raw diffs, and cleans common noise.
12
+ */
6
13
  export function extractDiff(llmOutput) {
7
- const diffMatch =
8
- llmOutput.match(/```diff\s*([\s\S]*?)```/) ||
9
- llmOutput.match(/(--- [^\n]+\n\+\+\+ [^\n]+[\s\S]*)/);
14
+ if (!llmOutput || typeof llmOutput !== 'string') return null;
15
+
16
+ // 1. Try markdown code block markers
17
+ const fenceMatch =
18
+ llmOutput.match(/```(?:diff|patch)\s*([\s\S]*?)```/i) ||
19
+ llmOutput.match(/```\s*\n(--- [^\n]+\n\+\+\+ [^\n]+[\s\S]*?)```/) ||
20
+ llmOutput.match(/```\s*\n(diff --git [^\n]+[\s\S]*?)```/);
21
+
22
+ let raw = '';
23
+ if (fenceMatch) {
24
+ raw = fenceMatch[1];
25
+ } else {
26
+ // 2. Try raw unified diff pattern
27
+ const rawMatch =
28
+ llmOutput.match(/(diff --git a\/[^\n]+ b\/[^\n]+[\s\S]*)/) ||
29
+ llmOutput.match(/(--- [^\n]+\n\+\+\+ [^\n]+[\s\S]*)/);
30
+ if (rawMatch) {
31
+ raw = rawMatch[1];
32
+ } else {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ // Clean non-breaking spaces and Windows newlines
38
+ raw = raw.replace(/\u00A0/g, ' ').replace(/\r\n/g, '\n');
39
+
40
+ // Strip git index metadata lines if malformed
41
+ raw = raw.replace(/^index [0-9a-f]+\.\.[0-9a-f]+.*$\n?/gm, '');
42
+
43
+ if (!raw.endsWith('\n')) raw += '\n';
44
+
45
+ return raw.trim() + '\n';
46
+ }
10
47
 
11
- if (!diffMatch) return null;
12
- let rawDiff = diffMatch[1] || diffMatch[0];
48
+ /**
49
+ * Normalizes diff headers and recounts hunk lines (@@ -oldStart,oldCount +newStart,newCount @@).
50
+ * LLMs frequently miscount lines in the hunk header, which causes git apply to fail.
51
+ */
52
+ export function normalizeDiff(diffString) {
53
+ if (!diffString) return '';
54
+ const cleaned = diffString.replace(/\u00A0/g, ' ').replace(/\r\n/g, '\n');
55
+ const lines = cleaned.split('\n');
56
+ const result = [];
13
57
 
14
- rawDiff = rawDiff.replace(/\u00A0/g, ' ').replace(/\r\n/g, '\n');
15
- rawDiff = rawDiff.replace(/^index [0-9a-f]+\.\.[0-9a-f]+.*$\n?/m, '');
58
+ let currentHunkIdx = -1;
59
+ let oldCount = 0;
60
+ let newCount = 0;
61
+ let inHunk = false;
62
+ let oldStart = 1;
63
+ let newStart = 1;
64
+ let hunkHeaderExtra = '';
16
65
 
17
- if (!rawDiff.endsWith('\n')) {
18
- rawDiff += '\n';
66
+ function finalizeHunk() {
67
+ if (currentHunkIdx >= 0) {
68
+ result[currentHunkIdx] = `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@${hunkHeaderExtra}`;
69
+ }
19
70
  }
20
71
 
21
- return rawDiff.trim() + '\n';
72
+ for (let i = 0; i < lines.length; i++) {
73
+ const line = lines[i];
74
+
75
+ // Normalize header paths: ensure standard a/ and b/ prefixes
76
+ if (line.startsWith('--- ') && !line.startsWith('--- a/') && !line.startsWith('--- /dev/null')) {
77
+ const p = line.slice(4).trim().replace(/^([ab]\/|\.\/)/, '');
78
+ result.push(`--- a/${p}`);
79
+ continue;
80
+ }
81
+ if (line.startsWith('+++ ') && !line.startsWith('+++ b/') && !line.startsWith('+++ /dev/null')) {
82
+ const p = line.slice(4).trim().replace(/^([ab]\/|\.\/)/, '');
83
+ result.push(`+++ b/${p}`);
84
+ continue;
85
+ }
86
+
87
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
88
+ if (hunkMatch) {
89
+ finalizeHunk();
90
+ oldStart = hunkMatch[1];
91
+ newStart = hunkMatch[2];
92
+ hunkHeaderExtra = hunkMatch[3] || '';
93
+ oldCount = 0;
94
+ newCount = 0;
95
+ inHunk = true;
96
+ currentHunkIdx = result.length;
97
+ result.push(line);
98
+ continue;
99
+ }
100
+
101
+ if (inHunk) {
102
+ if (line.startsWith('--- ') || line.startsWith('diff --git')) {
103
+ finalizeHunk();
104
+ inHunk = false;
105
+ currentHunkIdx = -1;
106
+ result.push(line);
107
+ } else if (line.startsWith('-')) {
108
+ oldCount++;
109
+ result.push(line);
110
+ } else if (line.startsWith('+')) {
111
+ newCount++;
112
+ result.push(line);
113
+ } else if (line.startsWith(' ') || line === '') {
114
+ oldCount++;
115
+ newCount++;
116
+ result.push(line === '' ? ' ' : line);
117
+ } else if (line.startsWith('\\')) {
118
+ // e.g.
119
+ result.push(line);
120
+ } else {
121
+ finalizeHunk();
122
+ inHunk = false;
123
+ currentHunkIdx = -1;
124
+ result.push(line);
125
+ }
126
+ } else {
127
+ result.push(line);
128
+ }
129
+ }
130
+
131
+ finalizeHunk();
132
+ return result.join('\n').trim() + '\n';
22
133
  }
23
134
 
24
- export async function applyDiff(diffString) {
25
- const flags = [
26
- '--recount',
27
- '--ignore-space-change',
28
- '--ignore-whitespace',
29
- '--whitespace=nowarn',
30
- '-C1'
31
- ];
135
+ /**
136
+ * Validates syntax of patched content to prevent breaking the workspace with unparseable code.
137
+ */
138
+ export function validateSyntax(filePath, content) {
139
+ const ext = path.extname(filePath).toLowerCase();
140
+
141
+ if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext)) {
142
+ try {
143
+ parse(content, {
144
+ sourceType: 'module',
145
+ plugins: ['jsx', 'typescript'],
146
+ });
147
+ return { valid: true };
148
+ } catch (err) {
149
+ return { valid: false, error: err.message };
150
+ }
151
+ }
152
+
153
+ if (ext === '.json') {
154
+ try {
155
+ JSON.parse(content);
156
+ return { valid: true };
157
+ } catch (err) {
158
+ return { valid: false, error: err.message };
159
+ }
160
+ }
161
+
162
+ return { valid: true };
163
+ }
164
+
165
+ /**
166
+ * Parses a unified diff string into structured file patches and hunks.
167
+ */
168
+ export function parseUnifiedDiff(diffString) {
169
+ const files = [];
170
+ const lines = diffString.split('\n');
171
+ let currentFile = null;
172
+ let currentHunk = null;
173
+
174
+ for (let i = 0; i < lines.length; i++) {
175
+ const line = lines[i];
176
+
177
+ if (line.startsWith('--- ')) {
178
+ const rawPath = line.slice(4).trim().split('\t')[0];
179
+ const oldPath = rawPath.replace(/^(?:a\/|\.\/)/, '');
180
+ currentFile = {
181
+ oldPath,
182
+ newPath: '',
183
+ isNew: rawPath === '/dev/null' || rawPath === 'dev/null',
184
+ isDeleted: false,
185
+ hunks: [],
186
+ };
187
+ files.push(currentFile);
188
+ currentHunk = null;
189
+ continue;
190
+ }
191
+
192
+ if (line.startsWith('+++ ') && currentFile) {
193
+ const rawPath = line.slice(4).trim().split('\t')[0];
194
+ currentFile.newPath = rawPath.replace(/^(?:b\/|\.\/)/, '');
195
+ currentFile.isDeleted = rawPath === '/dev/null' || rawPath === 'dev/null';
196
+ continue;
197
+ }
198
+
199
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
200
+ if (hunkMatch && currentFile) {
201
+ currentHunk = {
202
+ oldStart: parseInt(hunkMatch[1], 10),
203
+ oldCount: parseInt(hunkMatch[2] || '1', 10),
204
+ newStart: parseInt(hunkMatch[3], 10),
205
+ newCount: parseInt(hunkMatch[4] || '1', 10),
206
+ rawLines: [],
207
+ oldLines: [],
208
+ newLines: [],
209
+ removed: [],
210
+ added: [],
211
+ };
212
+ currentFile.hunks.push(currentHunk);
213
+ continue;
214
+ }
215
+
216
+ if (currentHunk) {
217
+ if (line.startsWith('-')) {
218
+ currentHunk.rawLines.push(line);
219
+ currentHunk.oldLines.push(line.slice(1));
220
+ currentHunk.removed.push(line.slice(1));
221
+ } else if (line.startsWith('+')) {
222
+ currentHunk.rawLines.push(line);
223
+ currentHunk.newLines.push(line.slice(1));
224
+ currentHunk.added.push(line.slice(1));
225
+ } else if (line.startsWith(' ') || line === '') {
226
+ const text = line.startsWith(' ') ? line.slice(1) : line;
227
+ currentHunk.rawLines.push(line);
228
+ currentHunk.oldLines.push(text);
229
+ currentHunk.newLines.push(text);
230
+ }
231
+ }
232
+ }
233
+
234
+ return files;
235
+ }
236
+
237
+ /**
238
+ * Resolves a file path from a diff to an actual file on disk.
239
+ */
240
+ function resolveFilePath(targetRelPath) {
241
+ if (!targetRelPath || targetRelPath === '/dev/null') return null;
242
+
243
+ // 1. Direct path from cwd
244
+ const directPath = path.resolve(process.cwd(), targetRelPath);
245
+ if (fs.existsSync(directPath)) return directPath;
246
+
247
+ // 2. Search workspace for matching basename (avoiding noisy directories)
248
+ const baseName = path.basename(targetRelPath);
249
+ const found = findFileByBasename(process.cwd(), baseName);
250
+ if (found) return found;
251
+
252
+ return directPath; // return unresolved direct path for creation or not-found handling
253
+ }
254
+
255
+ function findFileByBasename(dir, targetName, depth = 0) {
256
+ if (depth > 5) return null;
257
+ const ignored = new Set(['node_modules', '.git', 'dist', 'build', '.cache', 'coverage']);
258
+
259
+ try {
260
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
261
+ for (const ent of entries) {
262
+ if (ent.isDirectory()) {
263
+ if (!ignored.has(ent.name)) {
264
+ const res = findFileByBasename(path.join(dir, ent.name), targetName, depth + 1);
265
+ if (res) return res;
266
+ }
267
+ } else if (ent.isFile() && ent.name === targetName) {
268
+ return path.join(dir, ent.name);
269
+ }
270
+ }
271
+ } catch {}
272
+ return null;
273
+ }
274
+
275
+ /**
276
+ * Computes fast token-based similarity between two lines.
277
+ */
278
+ function lineTokenSimilarity(a, b) {
279
+ const s1 = a.trim().toLowerCase();
280
+ const s2 = b.trim().toLowerCase();
281
+ if (s1 === s2) return 1.0;
282
+ if (!s1 || !s2) return 0.0;
283
+
284
+ const t1 = s1.split(/[^a-z0-9_$]+/i).filter(Boolean);
285
+ const t2 = s2.split(/[^a-z0-9_$]+/i).filter(Boolean);
286
+ if (t1.length === 0 || t2.length === 0) return 0.0;
287
+
288
+ let matches = 0;
289
+ const set2 = new Set(t2);
290
+ for (const t of t1) {
291
+ if (set2.has(t)) matches++;
292
+ }
293
+ return (2 * matches) / (t1.length + t2.length);
294
+ }
295
+
296
+ /**
297
+ * Applies a hunk to an array of file lines using progressive matching tiers.
298
+ * Returns { success: boolean, updatedLines: string[] }.
299
+ */
300
+ function applyHunkToLines(fileLines, hunk, targetFilePath) {
301
+ const oldLines = hunk.oldLines;
302
+ const newLines = hunk.newLines;
303
+ const removed = hunk.removed;
304
+ const added = hunk.added;
305
+
306
+ // --- Tier 1: Exact oldLines (context + removed) match ---
307
+ if (oldLines.length > 0) {
308
+ for (let i = 0; i <= fileLines.length - oldLines.length; i++) {
309
+ let match = true;
310
+ for (let j = 0; j < oldLines.length; j++) {
311
+ if (fileLines[i + j] !== oldLines[j]) {
312
+ match = false;
313
+ break;
314
+ }
315
+ }
316
+ if (match) {
317
+ const updated = [...fileLines.slice(0, i), ...newLines, ...fileLines.slice(i + oldLines.length)];
318
+ return { success: true, updatedLines: updated };
319
+ }
320
+ }
321
+ }
322
+
323
+ // --- Tier 2: Exact removed-only lines match (ignoring context) ---
324
+ if (removed.length > 0) {
325
+ for (let i = 0; i <= fileLines.length - removed.length; i++) {
326
+ let match = true;
327
+ for (let j = 0; j < removed.length; j++) {
328
+ if (fileLines[i + j] !== removed[j]) {
329
+ match = false;
330
+ break;
331
+ }
332
+ }
333
+ if (match) {
334
+ const updated = [...fileLines.slice(0, i), ...added, ...fileLines.slice(i + removed.length)];
335
+ return { success: true, updatedLines: updated };
336
+ }
337
+ }
338
+ }
339
+
340
+ // --- Tier 3: Whitespace & indentation normalized match ---
341
+ if (oldLines.length > 0) {
342
+ const normOld = oldLines.map((l) => l.trim());
343
+ for (let i = 0; i <= fileLines.length - normOld.length; i++) {
344
+ let match = true;
345
+ for (let j = 0; j < normOld.length; j++) {
346
+ if (fileLines[i + j].trim() !== normOld[j]) {
347
+ match = false;
348
+ break;
349
+ }
350
+ }
351
+ if (match) {
352
+ // Adapt indentation from original lines
353
+ const indentMatch = fileLines[i].match(/^\s*/);
354
+ const baseIndent = indentMatch ? indentMatch[0] : '';
355
+ const adaptedNew = newLines.map((l) => {
356
+ if (l.trim().length === 0) return '';
357
+ const diffIndent = l.match(/^\s*/)?.[0] || '';
358
+ return diffIndent ? l : baseIndent + l;
359
+ });
360
+ const updated = [...fileLines.slice(0, i), ...adaptedNew, ...fileLines.slice(i + normOld.length)];
361
+ return { success: true, updatedLines: updated };
362
+ }
363
+ }
364
+ }
365
+
366
+ // --- Tier 4: Case-insensitive & token similarity match ---
367
+ // Covers cases like `profile.userID` vs `profile.userid` vs `profile.userId`
368
+ if (removed.length > 0) {
369
+ let bestIdx = -1;
370
+ let bestScore = 0;
371
+
372
+ for (let i = 0; i <= fileLines.length - removed.length; i++) {
373
+ let totalSim = 0;
374
+ for (let j = 0; j < removed.length; j++) {
375
+ const sim = lineTokenSimilarity(fileLines[i + j], removed[j]);
376
+ totalSim += sim;
377
+ }
378
+ const avgSim = totalSim / removed.length;
379
+ if (avgSim > bestScore) {
380
+ bestScore = avgSim;
381
+ bestIdx = i;
382
+ }
383
+ }
384
+
385
+ if (bestScore >= 0.75 && bestIdx >= 0) {
386
+ const indentMatch = fileLines[bestIdx].match(/^\s*/);
387
+ const baseIndent = indentMatch ? indentMatch[0] : '';
388
+
389
+ const adaptedAdded = added.map((l) => {
390
+ if (l.trim().length === 0) return '';
391
+ const curIndent = l.match(/^\s*/)?.[0] || '';
392
+ return curIndent ? l : baseIndent + l;
393
+ });
394
+
395
+ const updated = [...fileLines.slice(0, bestIdx), ...adaptedAdded, ...fileLines.slice(bestIdx + removed.length)];
396
+ return { success: true, updatedLines: updated };
397
+ }
398
+ }
399
+
400
+ // --- Tier 5: AST-guided MemberExpression / Identifier rename for JS/TS ---
401
+ const ext = path.extname(targetFilePath).toLowerCase();
402
+ if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext) && removed.length === 1 && added.length === 1) {
403
+ const astResult = tryAstMemberRename(fileLines.join('\n'), removed[0], added[0]);
404
+ if (astResult.success) {
405
+ return { success: true, updatedLines: astResult.code.split('\n') };
406
+ }
407
+ }
408
+
409
+ return { success: false };
410
+ }
32
411
 
33
- // 1. Try git apply
34
- const attempt = await runGitApply(diffString, flags);
35
- if (attempt.success) return true;
412
+ /**
413
+ * Uses Babel AST traversal to pinpoint and rename member expressions (e.g. profile.userid -> profile.user_id).
414
+ */
415
+ function tryAstMemberRename(code, removedLine, addedLine) {
416
+ try {
417
+ const oldPropMatch = removedLine.match(/([a-zA-Z0-9_$]+)\.([a-zA-Z0-9_$]+)/);
418
+ const newPropMatch = addedLine.match(/([a-zA-Z0-9_$]+)\.([a-zA-Z0-9_$]+)/);
36
419
 
37
- // 2. Try git apply with 3way
38
- const attempt3Way = await runGitApply(diffString, [...flags, '--3way']);
39
- if (attempt3Way.success) return true;
420
+ if (!oldPropMatch || !newPropMatch) return { success: false };
421
+ const objName = oldPropMatch[1];
422
+ const oldProp = oldPropMatch[2];
423
+ const newProp = newPropMatch[2];
40
424
 
41
- // 3. Fallback: Direct line search & replace on disk
42
- const applied = fallbackManualPatch(diffString);
43
- if (applied) return true;
425
+ const ast = parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] });
426
+ let replaced = false;
427
+ let newCode = code;
44
428
 
45
- throw new Error(`Patch cannot apply cleanly:\n${attempt.stderr}`);
429
+ traverse(ast, {
430
+ MemberExpression(p) {
431
+ if (
432
+ !replaced &&
433
+ p.node.object?.type === 'Identifier' &&
434
+ p.node.object.name === objName &&
435
+ p.node.property?.type === 'Identifier' &&
436
+ p.node.property.name.toLowerCase() === oldProp.toLowerCase()
437
+ ) {
438
+ const start = p.node.property.start;
439
+ const end = p.node.property.end;
440
+ newCode = newCode.slice(0, start) + newProp + newCode.slice(end);
441
+ replaced = true;
442
+ p.stop();
443
+ }
444
+ },
445
+ });
446
+
447
+ return { success: replaced, code: newCode };
448
+ } catch {
449
+ return { success: false };
450
+ }
46
451
  }
47
452
 
453
+ /**
454
+ * Semantic Fallback Patcher: Applies parsed diff directly to files on disk with atomic rollback.
455
+ * Guarantees zero false positives: if no files are changed or any file fails, rolls back completely.
456
+ */
457
+ export async function applySemanticFallback(diffString) {
458
+ const filePatches = parseUnifiedDiff(diffString);
459
+ if (filePatches.length === 0) {
460
+ throw new Error('No valid file hunks could be parsed from the diff.');
461
+ }
462
+
463
+ const backups = new Map(); // filePath -> originalContent
464
+ const createdFiles = new Set();
465
+ const modifiedFiles = [];
466
+
467
+ try {
468
+ for (const patchObj of filePatches) {
469
+ const targetRelPath = patchObj.newPath || patchObj.oldPath;
470
+ const targetPath = resolveFilePath(targetRelPath);
471
+
472
+ // 1. Handle new file creation
473
+ if (patchObj.isNew) {
474
+ const fullNewPath = targetPath || path.resolve(process.cwd(), targetRelPath);
475
+ const dir = path.dirname(fullNewPath);
476
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
477
+
478
+ const newContent = patchObj.hunks.flatMap((h) => h.added).join('\n') + '\n';
479
+ const syntaxCheck = validateSyntax(fullNewPath, newContent);
480
+ if (!syntaxCheck.valid) {
481
+ throw new Error(`Proposed new file '${targetRelPath}' has a syntax error: ${syntaxCheck.error}`);
482
+ }
483
+
484
+ fs.writeFileSync(fullNewPath, newContent, 'utf8');
485
+ createdFiles.add(fullNewPath);
486
+ modifiedFiles.push(targetRelPath);
487
+ continue;
488
+ }
489
+
490
+ // 2. Handle file deletion
491
+ if (patchObj.isDeleted) {
492
+ if (fs.existsSync(targetPath)) {
493
+ backups.set(targetPath, fs.readFileSync(targetPath, 'utf8'));
494
+ fs.unlinkSync(targetPath);
495
+ modifiedFiles.push(targetRelPath);
496
+ }
497
+ continue;
498
+ }
499
+
500
+ // 3. Handle file modification
501
+ if (!targetPath || !fs.existsSync(targetPath)) {
502
+ throw new Error(`Target file not found in repository: ${targetRelPath}`);
503
+ }
504
+
505
+ const originalContent = fs.readFileSync(targetPath, 'utf8');
506
+ backups.set(targetPath, originalContent);
507
+
508
+ const hasCrlf = originalContent.includes('\r\n');
509
+ const normalizedOriginal = originalContent.replace(/\r\n/g, '\n');
510
+ let currentLines = normalizedOriginal.split('\n');
511
+
512
+ let anyHunkApplied = false;
513
+
514
+ for (const hunk of patchObj.hunks) {
515
+ const hunkRes = applyHunkToLines(currentLines, hunk, targetPath);
516
+ if (hunkRes.success) {
517
+ currentLines = hunkRes.updatedLines;
518
+ anyHunkApplied = true;
519
+ } else {
520
+ // Check if already applied
521
+ const alreadyContainsAdded = hunk.added.length > 0 && hunk.added.every((l) => normalizedOriginal.includes(l.trim()));
522
+ if (alreadyContainsAdded) {
523
+ continue;
524
+ }
525
+ throw new Error(
526
+ `Patch hunk at line ${hunk.oldStart} could not be matched to any code in '${targetRelPath}'.`
527
+ );
528
+ }
529
+ }
530
+
531
+ let updatedContent = currentLines.join(hasCrlf ? '\r\n' : '\n');
532
+
533
+ // Verify actual content modification
534
+ if (updatedContent === originalContent) {
535
+ // If content did not change, check if diff was already applied
536
+ const alreadyApplied = patchObj.hunks.some((h) =>
537
+ h.added.length > 0 && h.added.every((l) => originalContent.includes(l.trim()))
538
+ );
539
+ if (alreadyApplied) {
540
+ continue; // File already has the fix
541
+ }
542
+ throw new Error(`Patch resulted in no changes to '${targetRelPath}'.`);
543
+ }
544
+
545
+ // Validate syntax before committing to disk
546
+ const syntaxCheck = validateSyntax(targetPath, updatedContent);
547
+ if (!syntaxCheck.valid) {
548
+ throw new Error(`Proposed patch introduces a syntax error in '${targetRelPath}': ${syntaxCheck.error}`);
549
+ }
550
+
551
+ fs.writeFileSync(targetPath, updatedContent, 'utf8');
552
+ modifiedFiles.push(targetRelPath);
553
+ }
554
+
555
+ if (modifiedFiles.length === 0) {
556
+ throw new Error('Patch was already applied or resulted in no changes to working tree.');
557
+ }
558
+
559
+ return { success: true, method: 'semantic-fallback', modifiedFiles };
560
+ } catch (err) {
561
+ // Atomic Rollback: Restore all modified files from backups
562
+ for (const [filePath, content] of backups.entries()) {
563
+ try {
564
+ fs.writeFileSync(filePath, content, 'utf8');
565
+ } catch {}
566
+ }
567
+ // Remove any created files
568
+ for (const createdPath of createdFiles) {
569
+ try {
570
+ if (fs.existsSync(createdPath)) fs.unlinkSync(createdPath);
571
+ } catch {}
572
+ }
573
+ throw err;
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Spawns git apply with given command-line flags.
579
+ */
48
580
  function runGitApply(diffContent, flags = []) {
49
581
  return new Promise((resolve) => {
50
582
  const git = spawn('git', ['apply', ...flags, '-'], {
51
- stdio: ['pipe', 'pipe', 'pipe']
583
+ stdio: ['pipe', 'pipe', 'pipe'],
52
584
  });
53
585
 
54
586
  let stderr = '';
55
- git.stderr.on('data', (chunk) => {
56
- stderr += chunk.toString();
587
+ git.stderr.on('data', (d) => {
588
+ stderr += d.toString();
57
589
  });
58
590
 
59
591
  git.on('close', (code) => {
60
592
  resolve({
61
593
  success: code === 0,
62
- stderr: stderr.trim()
594
+ stderr: stderr.trim(),
63
595
  });
64
596
  });
65
597
 
@@ -68,53 +600,110 @@ function runGitApply(diffContent, flags = []) {
68
600
  });
69
601
  }
70
602
 
71
- function fallbackManualPatch(diffString) {
72
- try {
73
- const fileMatch = diffString.match(/\+\+\+ b\/(.+)/);
74
- if (!fileMatch) return false;
75
-
76
- const relativePath = fileMatch[1].trim();
77
- const filePath = path.resolve(process.cwd(), relativePath);
78
- if (!fs.existsSync(filePath)) return false;
603
+ /**
604
+ * Main patch application entry point.
605
+ * Tries progressive Git Apply strategies, then falls back to Semantic Fallback Patcher.
606
+ * Guarantees syntax validation and atomic rollback on errors.
607
+ */
608
+ export async function applyDiff(diffString) {
609
+ if (!diffString || typeof diffString !== 'string' || !diffString.trim()) {
610
+ throw new Error('No valid diff provided to apply.');
611
+ }
79
612
 
80
- const fileContent = fs.readFileSync(filePath, 'utf8');
613
+ const normalized = normalizeDiff(diffString);
81
614
 
82
- // Extract removed (-) and added (+) lines from the hunk
83
- const lines = diffString.split('\n');
84
- let oldLine = null;
85
- let newLine = null;
615
+ // Snapshot target file mtimes to verify Git apply actually touched files
616
+ const filePatches = parseUnifiedDiff(normalized);
617
+ const targetPaths = filePatches
618
+ .map((p) => resolveFilePath(p.newPath || p.oldPath))
619
+ .filter(Boolean);
86
620
 
87
- for (const line of lines) {
88
- if (line.startsWith('-') && !line.startsWith('---')) {
89
- oldLine = line.slice(1).trim();
90
- } else if (line.startsWith('+') && !line.startsWith('+++')) {
91
- newLine = line.slice(1).trim();
92
- }
621
+ const initialContents = new Map();
622
+ for (const p of targetPaths) {
623
+ if (fs.existsSync(p)) {
624
+ initialContents.set(p, fs.readFileSync(p, 'utf8'));
93
625
  }
626
+ }
94
627
 
95
- if (!oldLine || !newLine) return false;
628
+ // Strategy 1: Git apply with whitespace and recount adjustments
629
+ const strat1 = await runGitApply(normalized, ['-p1', '--recount', '--whitespace=fix', '-C1']);
630
+ if (strat1.success && verifyAndValidateGitApply(targetPaths, initialContents)) {
631
+ return { success: true, method: 'git-apply-recount' };
632
+ }
96
633
 
97
- // Scan file lines and replace the matching content while preserving original indentation
98
- const fileLines = fileContent.split('\n');
99
- let replaced = false;
634
+ // Strategy 2: Git apply with relaxed whitespace
635
+ const strat2 = await runGitApply(normalized, [
636
+ '-p1',
637
+ '--ignore-space-change',
638
+ '--ignore-whitespace',
639
+ '--recount',
640
+ '-C1',
641
+ ]);
642
+ if (strat2.success && verifyAndValidateGitApply(targetPaths, initialContents)) {
643
+ return { success: true, method: 'git-apply-ignore-whitespace' };
644
+ }
100
645
 
101
- for (let i = 0; i < fileLines.length; i++) {
102
- if (fileLines[i].includes(oldLine)) {
103
- const indentMatch = fileLines[i].match(/^\s*/);
104
- const indent = indentMatch ? indentMatch[0] : '';
105
- fileLines[i] = indent + newLine;
106
- replaced = true;
107
- break;
646
+ // Strategy 3: Git apply with zero context
647
+ const strat3 = await runGitApply(normalized, [
648
+ '-p1',
649
+ '--unidiff-zero',
650
+ '-C0',
651
+ '--ignore-space-change',
652
+ '--ignore-whitespace',
653
+ ]);
654
+ if (strat3.success && verifyAndValidateGitApply(targetPaths, initialContents)) {
655
+ return { success: true, method: 'git-apply-unidiff-zero' };
656
+ }
657
+
658
+ // Strategy 4: Git apply with -p0
659
+ const strat4 = await runGitApply(normalized, ['-p0', '--recount', '--whitespace=fix']);
660
+ if (strat4.success && verifyAndValidateGitApply(targetPaths, initialContents)) {
661
+ return { success: true, method: 'git-apply-p0' };
662
+ }
663
+
664
+ // Strategy 5: Git 3-way merge
665
+ const strat5 = await runGitApply(normalized, ['-3', '--recount', '--whitespace=fix']);
666
+ if (strat5.success && verifyAndValidateGitApply(targetPaths, initialContents)) {
667
+ return { success: true, method: 'git-apply-3way' };
668
+ }
669
+
670
+ // Rollback any partial/broken git apply attempt before semantic fallback
671
+ for (const [p, content] of initialContents.entries()) {
672
+ try {
673
+ if (fs.existsSync(p)) fs.writeFileSync(p, content, 'utf8');
674
+ } catch {}
675
+ }
676
+
677
+ // Strategy 6: Intelligent Semantic Fallback Patcher
678
+ return await applySemanticFallback(normalized);
679
+ }
680
+
681
+ /**
682
+ * Verifies that git apply actually modified the target files and did not produce syntax errors.
683
+ */
684
+ function verifyAndValidateGitApply(targetPaths, initialContents) {
685
+ let anyModified = false;
686
+
687
+ for (const p of targetPaths) {
688
+ if (!fs.existsSync(p)) continue;
689
+ let curContent = fs.readFileSync(p, 'utf8');
690
+ const initContent = initialContents.get(p);
691
+
692
+ if (initContent !== undefined && curContent !== initContent) {
693
+ // If original file had CRLF line endings, ensure CRLF is preserved consistently
694
+ const origHadCrlf = initContent.includes('\r\n');
695
+ if (origHadCrlf) {
696
+ curContent = curContent.replace(/\r?\n/g, '\r\n');
697
+ fs.writeFileSync(p, curContent, 'utf8');
108
698
  }
109
- }
110
699
 
111
- if (replaced) {
112
- fs.writeFileSync(filePath, fileLines.join('\n'), 'utf8');
113
- return true;
700
+ anyModified = true;
701
+ const syntax = validateSyntax(p, curContent);
702
+ if (!syntax.valid) {
703
+ return false; // Reject git apply if syntax error introduced
704
+ }
114
705
  }
115
-
116
- return false;
117
- } catch {
118
- return false;
119
706
  }
120
- }
707
+
708
+ return anyModified;
709
+ }