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/README.md +82 -111
- package/bin/cli.js +23 -11
- package/package.json +4 -2
- package/src/git.js +130 -3
- package/src/patch.js +662 -73
- package/src/triage.js +99 -16
- package/src/utils.js +63 -0
package/src/patch.js
CHANGED
|
@@ -1,65 +1,597 @@
|
|
|
1
|
-
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
66
|
+
function finalizeHunk() {
|
|
67
|
+
if (currentHunkIdx >= 0) {
|
|
68
|
+
result[currentHunkIdx] = `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@${hunkHeaderExtra}`;
|
|
69
|
+
}
|
|
19
70
|
}
|
|
20
71
|
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
425
|
+
const ast = parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] });
|
|
426
|
+
let replaced = false;
|
|
427
|
+
let newCode = code;
|
|
44
428
|
|
|
45
|
-
|
|
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', (
|
|
56
|
-
stderr +=
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
613
|
+
const normalized = normalizeDiff(diffString);
|
|
81
614
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|