deepdebug-local-agent 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +24 -0
- package/.idea/deepdebug-local-agent.iml +12 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/Dockerfile +46 -0
- package/cloudbuild.yaml +42 -0
- package/index.js +42 -0
- package/mcp-server.js +533 -0
- package/package.json +22 -0
- package/src/ai-engine.js +861 -0
- package/src/analyzers/config-analyzer.js +446 -0
- package/src/analyzers/controller-analyzer.js +429 -0
- package/src/analyzers/dto-analyzer.js +455 -0
- package/src/detectors/build-tool-detector.js +0 -0
- package/src/detectors/framework-detector.js +91 -0
- package/src/detectors/language-detector.js +89 -0
- package/src/detectors/multi-project-detector.js +191 -0
- package/src/detectors/service-detector.js +244 -0
- package/src/detectors.js +30 -0
- package/src/exec-utils.js +215 -0
- package/src/fs-utils.js +34 -0
- package/src/git/base-git-provider.js +384 -0
- package/src/git/git-provider-registry.js +110 -0
- package/src/git/github-provider.js +502 -0
- package/src/mcp-http-server.js +313 -0
- package/src/patch/patch-engine.js +339 -0
- package/src/patch-manager.js +816 -0
- package/src/patch.js +607 -0
- package/src/patch_bkp.js +154 -0
- package/src/ports.js +69 -0
- package/src/routes/workspace.route.js +528 -0
- package/src/runtimes/base-runtime.js +290 -0
- package/src/runtimes/java/gradle-runtime.js +378 -0
- package/src/runtimes/java/java-integrations.js +339 -0
- package/src/runtimes/java/maven-runtime.js +418 -0
- package/src/runtimes/node/node-integrations.js +247 -0
- package/src/runtimes/node/npm-runtime.js +466 -0
- package/src/runtimes/node/yarn-runtime.js +354 -0
- package/src/runtimes/runtime-registry.js +256 -0
- package/src/server-local.js +576 -0
- package/src/server.js +4565 -0
- package/src/utils/environment-diagnostics.js +666 -0
- package/src/utils/exec-utils.js +264 -0
- package/src/utils/fs-utils.js +218 -0
- package/src/workspace/detect-port.js +176 -0
- package/src/workspace/file-reader.js +54 -0
- package/src/workspace/git-client.js +0 -0
- package/src/workspace/process-manager.js +619 -0
- package/src/workspace/scanner.js +72 -0
- package/src/workspace-manager.js +172 -0
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PatchManager - Sistema robusto de aplicação de patches
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Validação rigorosa de unified diff
|
|
10
|
+
* - Sistema de backup com rollback
|
|
11
|
+
* - Mensagens de erro detalhadas
|
|
12
|
+
* - Suporte a múltiplos arquivos
|
|
13
|
+
* - Cleanup automático de backups antigos
|
|
14
|
+
*/
|
|
15
|
+
export class PatchManager {
|
|
16
|
+
constructor(workspaceRoot) {
|
|
17
|
+
this.workspaceRoot = workspaceRoot;
|
|
18
|
+
this.backupDir = path.join(workspaceRoot, '.deepdebug', 'backups');
|
|
19
|
+
this.maxBackupsPerFile = 10;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ============================================
|
|
23
|
+
// PUBLIC METHODS
|
|
24
|
+
// ============================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Valida se o diff está no formato unified diff correto
|
|
28
|
+
* @param {string} diffText - Texto do diff
|
|
29
|
+
* @returns {ValidationResult} - { valid: boolean, errors: string[] }
|
|
30
|
+
*/
|
|
31
|
+
validateDiff(diffText) {
|
|
32
|
+
const errors = [];
|
|
33
|
+
|
|
34
|
+
if (!diffText || typeof diffText !== 'string') {
|
|
35
|
+
return { valid: false, errors: ['Diff text is empty or invalid'] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const lines = diffText.split('\n');
|
|
39
|
+
|
|
40
|
+
// Verificar se tem pelo menos os headers básicos
|
|
41
|
+
const hasOldFileHeader = lines.some(l => l.startsWith('--- '));
|
|
42
|
+
const hasNewFileHeader = lines.some(l => l.startsWith('+++ '));
|
|
43
|
+
const hasHunkHeader = lines.some(l => /^@@ -\d+,?\d* \+\d+,?\d* @@/.test(l));
|
|
44
|
+
|
|
45
|
+
if (!hasOldFileHeader) {
|
|
46
|
+
errors.push('Missing old file header (--- a/path/to/file)');
|
|
47
|
+
}
|
|
48
|
+
if (!hasNewFileHeader) {
|
|
49
|
+
errors.push('Missing new file header (+++ b/path/to/file)');
|
|
50
|
+
}
|
|
51
|
+
if (!hasHunkHeader) {
|
|
52
|
+
errors.push('Missing hunk header (@@ -line,count +line,count @@)');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Validar estrutura dos hunks
|
|
56
|
+
const hunkErrors = this._validateHunks(lines);
|
|
57
|
+
errors.push(...hunkErrors);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
valid: errors.length === 0,
|
|
61
|
+
errors
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Faz parse do diff em estrutura manipulável
|
|
67
|
+
* @param {string} diffText - Texto do diff
|
|
68
|
+
* @returns {ParsedPatch} - Estrutura parseada do patch
|
|
69
|
+
*/
|
|
70
|
+
parseDiff(diffText) {
|
|
71
|
+
const validation = this.validateDiff(diffText);
|
|
72
|
+
if (!validation.valid) {
|
|
73
|
+
throw new PatchError('INVALID_DIFF', `Invalid diff format: ${validation.errors.join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lines = diffText.split('\n');
|
|
77
|
+
const patches = [];
|
|
78
|
+
let currentPatch = null;
|
|
79
|
+
let currentHunk = null;
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < lines.length; i++) {
|
|
82
|
+
const line = lines[i];
|
|
83
|
+
|
|
84
|
+
// Detectar inÃcio de novo arquivo
|
|
85
|
+
if (line.startsWith('--- ')) {
|
|
86
|
+
if (currentPatch) {
|
|
87
|
+
if (currentHunk) {
|
|
88
|
+
currentPatch.hunks.push(currentHunk);
|
|
89
|
+
}
|
|
90
|
+
patches.push(currentPatch);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
currentPatch = {
|
|
94
|
+
oldFile: this._extractFilePath(line, '--- '),
|
|
95
|
+
newFile: null,
|
|
96
|
+
hunks: []
|
|
97
|
+
};
|
|
98
|
+
currentHunk = null;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (line.startsWith('+++ ') && currentPatch) {
|
|
103
|
+
currentPatch.newFile = this._extractFilePath(line, '+++ ');
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Detectar inÃcio de hunk
|
|
108
|
+
const hunkMatch = line.match(/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@(.*)$/);
|
|
109
|
+
if (hunkMatch && currentPatch) {
|
|
110
|
+
if (currentHunk) {
|
|
111
|
+
currentPatch.hunks.push(currentHunk);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
currentHunk = {
|
|
115
|
+
oldStart: parseInt(hunkMatch[1]),
|
|
116
|
+
oldCount: hunkMatch[2] ? parseInt(hunkMatch[2]) : 1,
|
|
117
|
+
newStart: parseInt(hunkMatch[3]),
|
|
118
|
+
newCount: hunkMatch[4] ? parseInt(hunkMatch[4]) : 1,
|
|
119
|
+
context: hunkMatch[5] || '',
|
|
120
|
+
lines: []
|
|
121
|
+
};
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Coletar linhas do hunk
|
|
126
|
+
if (currentHunk && (line.startsWith(' ') || line.startsWith('+') || line.startsWith('-') || line === '')) {
|
|
127
|
+
const type = line.startsWith('+') ? 'add' :
|
|
128
|
+
line.startsWith('-') ? 'remove' : 'context';
|
|
129
|
+
currentHunk.lines.push({
|
|
130
|
+
type,
|
|
131
|
+
content: line.substring(1) // Remove o prefixo
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Adicionar último patch/hunk
|
|
137
|
+
if (currentPatch) {
|
|
138
|
+
if (currentHunk) {
|
|
139
|
+
currentPatch.hunks.push(currentHunk);
|
|
140
|
+
}
|
|
141
|
+
patches.push(currentPatch);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (patches.length === 0) {
|
|
145
|
+
throw new PatchError('NO_PATCHES', 'No valid patches found in diff');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
patches,
|
|
150
|
+
totalHunks: patches.reduce((sum, p) => sum + p.hunks.length, 0),
|
|
151
|
+
totalFiles: patches.length
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Cria backup de um arquivo
|
|
157
|
+
* @param {string} filePath - Caminho relativo do arquivo
|
|
158
|
+
* @returns {Promise<BackupResult>}
|
|
159
|
+
*/
|
|
160
|
+
async backupFile(filePath) {
|
|
161
|
+
const fullPath = path.join(this.workspaceRoot, filePath);
|
|
162
|
+
|
|
163
|
+
// Verificar se arquivo existe
|
|
164
|
+
try {
|
|
165
|
+
await fs.access(fullPath);
|
|
166
|
+
} catch {
|
|
167
|
+
throw new PatchError('FILE_NOT_FOUND', `File not found: ${filePath}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Criar diretório de backup
|
|
171
|
+
await this._ensureBackupDir();
|
|
172
|
+
|
|
173
|
+
// Ler conteúdo original
|
|
174
|
+
const originalContent = await fs.readFile(fullPath, 'utf8');
|
|
175
|
+
|
|
176
|
+
// Gerar ID único para o backup
|
|
177
|
+
const timestamp = Date.now();
|
|
178
|
+
const hash = crypto.createHash('md5').update(filePath + timestamp).digest('hex').substring(0, 8);
|
|
179
|
+
const backupId = `${timestamp}-${hash}`;
|
|
180
|
+
|
|
181
|
+
// Criar nome do arquivo de backup
|
|
182
|
+
const safeFileName = filePath.replace(/[\/\\]/g, '_');
|
|
183
|
+
const backupFileName = `${backupId}_${safeFileName}`;
|
|
184
|
+
const backupPath = path.join(this.backupDir, backupFileName);
|
|
185
|
+
|
|
186
|
+
// Salvar backup
|
|
187
|
+
await fs.writeFile(backupPath, originalContent, 'utf8');
|
|
188
|
+
|
|
189
|
+
// Salvar metadados
|
|
190
|
+
const metadataPath = path.join(this.backupDir, `${backupId}.meta.json`);
|
|
191
|
+
const metadata = {
|
|
192
|
+
backupId,
|
|
193
|
+
originalPath: filePath,
|
|
194
|
+
fullPath,
|
|
195
|
+
backupPath,
|
|
196
|
+
timestamp: new Date().toISOString(),
|
|
197
|
+
size: originalContent.length,
|
|
198
|
+
checksum: crypto.createHash('md5').update(originalContent).digest('hex')
|
|
199
|
+
};
|
|
200
|
+
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf8');
|
|
201
|
+
|
|
202
|
+
// Cleanup de backups antigos
|
|
203
|
+
await this._cleanupOldBackups(filePath);
|
|
204
|
+
|
|
205
|
+
console.log(`📦 Backup created: ${backupId} for ${filePath}`);
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
backupId,
|
|
209
|
+
originalPath: filePath,
|
|
210
|
+
backupPath: backupFileName,
|
|
211
|
+
timestamp: metadata.timestamp,
|
|
212
|
+
size: originalContent.length
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Aplica um patch parseado ao workspace
|
|
218
|
+
* @param {ParsedPatch} parsedPatch - Resultado do parseDiff
|
|
219
|
+
* @param {object} options - Opções { createBackup: boolean, dryRun: boolean }
|
|
220
|
+
* @returns {Promise<ApplyResult>}
|
|
221
|
+
*/
|
|
222
|
+
async applyPatch(parsedPatch, options = {}) {
|
|
223
|
+
const { createBackup = true, dryRun = false } = options;
|
|
224
|
+
const results = [];
|
|
225
|
+
const backups = [];
|
|
226
|
+
|
|
227
|
+
for (const patch of parsedPatch.patches) {
|
|
228
|
+
const targetPath = patch.newFile || patch.oldFile;
|
|
229
|
+
|
|
230
|
+
if (!targetPath) {
|
|
231
|
+
results.push({
|
|
232
|
+
file: 'unknown',
|
|
233
|
+
success: false,
|
|
234
|
+
error: 'Could not determine target file path'
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
// Criar backup se solicitado
|
|
241
|
+
if (createBackup && !dryRun) {
|
|
242
|
+
const backup = await this.backupFile(targetPath);
|
|
243
|
+
backups.push(backup);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Ler arquivo original
|
|
247
|
+
const fullPath = path.join(this.workspaceRoot, targetPath);
|
|
248
|
+
let originalContent;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
originalContent = await fs.readFile(fullPath, 'utf8');
|
|
252
|
+
} catch (err) {
|
|
253
|
+
// Arquivo novo sendo criado
|
|
254
|
+
if (patch.oldFile === '/dev/null' || patch.oldFile.endsWith('/dev/null')) {
|
|
255
|
+
originalContent = '';
|
|
256
|
+
} else {
|
|
257
|
+
throw new PatchError('FILE_NOT_FOUND', `File not found: ${targetPath}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Aplicar cada hunk
|
|
262
|
+
const originalLines = originalContent.split('\n');
|
|
263
|
+
let modifiedLines = [...originalLines];
|
|
264
|
+
let offset = 0; // Offset causado por adições/remoções anteriores
|
|
265
|
+
|
|
266
|
+
for (let hunkIndex = 0; hunkIndex < patch.hunks.length; hunkIndex++) {
|
|
267
|
+
const hunk = patch.hunks[hunkIndex];
|
|
268
|
+
const hunkResult = this._applyHunk(modifiedLines, hunk, offset, hunkIndex + 1);
|
|
269
|
+
|
|
270
|
+
if (!hunkResult.success) {
|
|
271
|
+
throw new PatchError('HUNK_FAILED', hunkResult.error);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
modifiedLines = hunkResult.lines;
|
|
275
|
+
offset = hunkResult.newOffset;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Salvar arquivo modificado (se não for dry run)
|
|
279
|
+
if (!dryRun) {
|
|
280
|
+
const newContent = modifiedLines.join('\n');
|
|
281
|
+
await fs.writeFile(fullPath, newContent, 'utf8');
|
|
282
|
+
console.log(`✅ Patched: ${targetPath}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
results.push({
|
|
286
|
+
file: targetPath,
|
|
287
|
+
success: true,
|
|
288
|
+
hunksApplied: patch.hunks.length,
|
|
289
|
+
backup: backups.find(b => b.originalPath === targetPath)?.backupId
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
} catch (err) {
|
|
293
|
+
// Rollback dos backups já criados nesta operação
|
|
294
|
+
if (!dryRun && backups.length > 0) {
|
|
295
|
+
console.log(`âš ï¸ Rolling back ${backups.length} backups due to error...`);
|
|
296
|
+
for (const backup of backups) {
|
|
297
|
+
try {
|
|
298
|
+
await this.rollback(backup.backupId);
|
|
299
|
+
} catch (rollbackErr) {
|
|
300
|
+
console.error(`⌠Rollback failed for ${backup.backupId}: ${rollbackErr.message}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
results.push({
|
|
306
|
+
file: targetPath,
|
|
307
|
+
success: false,
|
|
308
|
+
error: err instanceof PatchError ? err.message : err.toString()
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Parar no primeiro erro
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const allSuccess = results.every(r => r.success);
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
success: allSuccess,
|
|
320
|
+
filesModified: results.filter(r => r.success).length,
|
|
321
|
+
totalFiles: parsedPatch.totalFiles,
|
|
322
|
+
results,
|
|
323
|
+
backups: backups.map(b => b.backupId),
|
|
324
|
+
dryRun
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Restaura um arquivo do backup
|
|
330
|
+
* @param {string} backupId - ID do backup
|
|
331
|
+
* @returns {Promise<boolean>}
|
|
332
|
+
*/
|
|
333
|
+
async rollback(backupId) {
|
|
334
|
+
const metadataPath = path.join(this.backupDir, `${backupId}.meta.json`);
|
|
335
|
+
|
|
336
|
+
// Ler metadados
|
|
337
|
+
let metadata;
|
|
338
|
+
try {
|
|
339
|
+
const metadataContent = await fs.readFile(metadataPath, 'utf8');
|
|
340
|
+
metadata = JSON.parse(metadataContent);
|
|
341
|
+
} catch {
|
|
342
|
+
throw new PatchError('BACKUP_NOT_FOUND', `Backup not found: ${backupId}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Ler conteúdo do backup
|
|
346
|
+
const backupFullPath = path.join(this.backupDir, path.basename(metadata.backupPath));
|
|
347
|
+
let backupContent;
|
|
348
|
+
try {
|
|
349
|
+
backupContent = await fs.readFile(backupFullPath, 'utf8');
|
|
350
|
+
} catch {
|
|
351
|
+
throw new PatchError('BACKUP_CORRUPTED', `Backup file corrupted or missing: ${backupId}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Verificar checksum
|
|
355
|
+
const currentChecksum = crypto.createHash('md5').update(backupContent).digest('hex');
|
|
356
|
+
if (currentChecksum !== metadata.checksum) {
|
|
357
|
+
throw new PatchError('CHECKSUM_MISMATCH', `Backup checksum mismatch for ${backupId}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Restaurar arquivo original
|
|
361
|
+
await fs.writeFile(metadata.fullPath, backupContent, 'utf8');
|
|
362
|
+
|
|
363
|
+
console.log(`🔄 Rolled back: ${metadata.originalPath} from backup ${backupId}`);
|
|
364
|
+
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Lista todos os backups disponÃveis
|
|
370
|
+
* @returns {Promise<BackupInfo[]>}
|
|
371
|
+
*/
|
|
372
|
+
async listBackups() {
|
|
373
|
+
try {
|
|
374
|
+
await fs.access(this.backupDir);
|
|
375
|
+
} catch {
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const files = await fs.readdir(this.backupDir);
|
|
380
|
+
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
|
|
381
|
+
|
|
382
|
+
const backups = [];
|
|
383
|
+
for (const metaFile of metaFiles) {
|
|
384
|
+
try {
|
|
385
|
+
const content = await fs.readFile(path.join(this.backupDir, metaFile), 'utf8');
|
|
386
|
+
const metadata = JSON.parse(content);
|
|
387
|
+
backups.push({
|
|
388
|
+
backupId: metadata.backupId,
|
|
389
|
+
originalPath: metadata.originalPath,
|
|
390
|
+
timestamp: metadata.timestamp,
|
|
391
|
+
size: metadata.size
|
|
392
|
+
});
|
|
393
|
+
} catch {
|
|
394
|
+
// Ignorar arquivos de metadados corrompidos
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Ordenar por timestamp (mais recente primeiro)
|
|
399
|
+
backups.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
400
|
+
|
|
401
|
+
return backups;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Aplica patch de forma segura com backup automático e rollback em falha
|
|
406
|
+
* @param {string} diffText - Texto do diff
|
|
407
|
+
* @param {string} incidentId - ID do incident (para logging)
|
|
408
|
+
* @returns {Promise<SafePatchResult>}
|
|
409
|
+
*/
|
|
410
|
+
async safePatch(diffText, incidentId = null) {
|
|
411
|
+
const startTime = Date.now();
|
|
412
|
+
|
|
413
|
+
console.log(`🔧 SafePatch starting${incidentId ? ` for incident ${incidentId}` : ''}...`);
|
|
414
|
+
console.log(`📠Diff size: ${diffText.length} characters`);
|
|
415
|
+
|
|
416
|
+
// 1. Validar diff
|
|
417
|
+
const validation = this.validateDiff(diffText);
|
|
418
|
+
if (!validation.valid) {
|
|
419
|
+
return {
|
|
420
|
+
success: false,
|
|
421
|
+
error: `Invalid diff: ${validation.errors.join('; ')}`,
|
|
422
|
+
stage: 'validation',
|
|
423
|
+
duration: Date.now() - startTime
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 2. Parsear diff
|
|
428
|
+
let parsed;
|
|
429
|
+
try {
|
|
430
|
+
parsed = this.parseDiff(diffText);
|
|
431
|
+
console.log(`📊 Parsed: ${parsed.totalFiles} file(s), ${parsed.totalHunks} hunk(s)`);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
return {
|
|
434
|
+
success: false,
|
|
435
|
+
error: err.message,
|
|
436
|
+
stage: 'parsing',
|
|
437
|
+
duration: Date.now() - startTime
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 3. Aplicar com backup
|
|
442
|
+
let result;
|
|
443
|
+
try {
|
|
444
|
+
result = await this.applyPatch(parsed, { createBackup: true, dryRun: false });
|
|
445
|
+
} catch (err) {
|
|
446
|
+
return {
|
|
447
|
+
success: false,
|
|
448
|
+
error: err.message,
|
|
449
|
+
stage: 'apply',
|
|
450
|
+
duration: Date.now() - startTime
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const duration = Date.now() - startTime;
|
|
455
|
+
|
|
456
|
+
if (result.success) {
|
|
457
|
+
console.log(`✅ SafePatch completed in ${duration}ms`);
|
|
458
|
+
return {
|
|
459
|
+
success: true,
|
|
460
|
+
filesModified: result.filesModified,
|
|
461
|
+
backups: result.backups,
|
|
462
|
+
results: result.results,
|
|
463
|
+
stage: 'complete',
|
|
464
|
+
duration
|
|
465
|
+
};
|
|
466
|
+
} else {
|
|
467
|
+
console.log(`⌠SafePatch failed: ${result.results.find(r => !r.success)?.error}`);
|
|
468
|
+
return {
|
|
469
|
+
success: false,
|
|
470
|
+
error: result.results.find(r => !r.success)?.error || 'Unknown error',
|
|
471
|
+
stage: 'apply',
|
|
472
|
+
duration
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ============================================
|
|
478
|
+
// PRIVATE METHODS
|
|
479
|
+
// ============================================
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Valida a estrutura dos hunks
|
|
483
|
+
*/
|
|
484
|
+
_validateHunks(lines) {
|
|
485
|
+
const errors = [];
|
|
486
|
+
let inHunk = false;
|
|
487
|
+
let hunkNumber = 0;
|
|
488
|
+
let expectedOldLines = 0;
|
|
489
|
+
let expectedNewLines = 0;
|
|
490
|
+
let actualOldLines = 0;
|
|
491
|
+
let actualNewLines = 0;
|
|
492
|
+
|
|
493
|
+
for (let i = 0; i < lines.length; i++) {
|
|
494
|
+
const line = lines[i];
|
|
495
|
+
|
|
496
|
+
// Detectar inÃcio de hunk
|
|
497
|
+
const hunkMatch = line.match(/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
|
|
498
|
+
if (hunkMatch) {
|
|
499
|
+
// Verificar hunk anterior
|
|
500
|
+
if (inHunk) {
|
|
501
|
+
if (actualOldLines !== expectedOldLines) {
|
|
502
|
+
errors.push(`Hunk #${hunkNumber}: expected ${expectedOldLines} old lines, got ${actualOldLines}`);
|
|
503
|
+
}
|
|
504
|
+
if (actualNewLines !== expectedNewLines) {
|
|
505
|
+
errors.push(`Hunk #${hunkNumber}: expected ${expectedNewLines} new lines, got ${actualNewLines}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
hunkNumber++;
|
|
510
|
+
inHunk = true;
|
|
511
|
+
expectedOldLines = hunkMatch[2] ? parseInt(hunkMatch[2]) : 1;
|
|
512
|
+
expectedNewLines = hunkMatch[4] ? parseInt(hunkMatch[4]) : 1;
|
|
513
|
+
actualOldLines = 0;
|
|
514
|
+
actualNewLines = 0;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Contar linhas dentro do hunk
|
|
519
|
+
if (inHunk) {
|
|
520
|
+
if (line.startsWith(' ')) {
|
|
521
|
+
actualOldLines++;
|
|
522
|
+
actualNewLines++;
|
|
523
|
+
} else if (line.startsWith('-')) {
|
|
524
|
+
actualOldLines++;
|
|
525
|
+
} else if (line.startsWith('+')) {
|
|
526
|
+
actualNewLines++;
|
|
527
|
+
} else if (line.startsWith('\\')) {
|
|
528
|
+
// "" - ignorar
|
|
529
|
+
} else if (line.startsWith('diff ') || line.startsWith('index ') ||
|
|
530
|
+
line.startsWith('--- ') || line.startsWith('+++ ')) {
|
|
531
|
+
// Novo arquivo, resetar
|
|
532
|
+
inHunk = false;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Verificar último hunk
|
|
538
|
+
if (inHunk) {
|
|
539
|
+
if (actualOldLines !== expectedOldLines && expectedOldLines > 0) {
|
|
540
|
+
errors.push(`Hunk #${hunkNumber}: expected ${expectedOldLines} old lines, got ${actualOldLines}`);
|
|
541
|
+
}
|
|
542
|
+
if (actualNewLines !== expectedNewLines && expectedNewLines > 0) {
|
|
543
|
+
errors.push(`Hunk #${hunkNumber}: expected ${expectedNewLines} new lines, got ${actualNewLines}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return errors;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Extrai o caminho do arquivo do header
|
|
552
|
+
*/
|
|
553
|
+
_extractFilePath(line, prefix) {
|
|
554
|
+
let filePath = line.substring(prefix.length).trim();
|
|
555
|
+
|
|
556
|
+
// Remover prefixo a/ ou b/
|
|
557
|
+
if (filePath.startsWith('a/') || filePath.startsWith('b/')) {
|
|
558
|
+
filePath = filePath.substring(2);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Remover timestamp se presente (git diff format)
|
|
562
|
+
const tabIndex = filePath.indexOf('\t');
|
|
563
|
+
if (tabIndex > 0) {
|
|
564
|
+
filePath = filePath.substring(0, tabIndex);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return filePath;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Aplica um único hunk ao array de linhas
|
|
572
|
+
*/
|
|
573
|
+
_applyHunk(lines, hunk, offset, hunkNumber) {
|
|
574
|
+
let startLine = hunk.oldStart - 1 + offset; // 0-indexed
|
|
575
|
+
|
|
576
|
+
// Collect original lines (context + remove) for verification
|
|
577
|
+
const originalLines = hunk.lines.filter(l => l.type === 'context' || l.type === 'remove');
|
|
578
|
+
|
|
579
|
+
// Try exact position first, then fuzzy search if context mismatch
|
|
580
|
+
let matchOffset = this._findContextMatch(lines, originalLines, startLine);
|
|
581
|
+
if (matchOffset === null) {
|
|
582
|
+
// Fuzzy search: look for the context within a window around the expected position
|
|
583
|
+
const searchWindow = 50; // lines to search in each direction
|
|
584
|
+
const searchStart = Math.max(0, startLine - searchWindow);
|
|
585
|
+
const searchEnd = Math.min(lines.length - originalLines.length, startLine + searchWindow);
|
|
586
|
+
|
|
587
|
+
for (let tryLine = searchStart; tryLine <= searchEnd; tryLine++) {
|
|
588
|
+
if (tryLine === startLine) continue; // already tried
|
|
589
|
+
matchOffset = this._findContextMatch(lines, originalLines, tryLine);
|
|
590
|
+
if (matchOffset !== null) {
|
|
591
|
+
console.log(` Hunk #${hunkNumber}: context found at line ${tryLine + 1} instead of ${startLine + 1} (fuzzy match)`);
|
|
592
|
+
startLine = tryLine;
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (matchOffset === null && originalLines.length > 0) {
|
|
599
|
+
// Context truly not found anywhere nearby
|
|
600
|
+
const expectedContent = originalLines[0].content;
|
|
601
|
+
const actualLine = startLine < lines.length ? lines[startLine] : '<EOF>';
|
|
602
|
+
return {
|
|
603
|
+
success: false,
|
|
604
|
+
error: `Hunk #${hunkNumber} FAILED at line ${startLine + 1}: ` +
|
|
605
|
+
`expected "${this._truncate(expectedContent, 50)}" but found "${this._truncate(actualLine, 50)}". Context mismatch.`
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Context verified (exact or fuzzy match) - apply the changes
|
|
610
|
+
const newLines = [...lines];
|
|
611
|
+
|
|
612
|
+
// Calcular quantas linhas remover e quais linhas adicionar
|
|
613
|
+
const linesToAdd = hunk.lines.filter(l => l.type === 'add').map(l => l.content);
|
|
614
|
+
const removeCount = hunk.lines.filter(l => l.type === 'remove').length;
|
|
615
|
+
const contextAndRemoveCount = originalLines.length;
|
|
616
|
+
|
|
617
|
+
// Construir o novo conteúdo para esta seção
|
|
618
|
+
const newSectionLines = [];
|
|
619
|
+
for (const hunkLine of hunk.lines) {
|
|
620
|
+
if (hunkLine.type === 'context' || hunkLine.type === 'add') {
|
|
621
|
+
newSectionLines.push(hunkLine.content);
|
|
622
|
+
}
|
|
623
|
+
// 'remove' lines são ignoradas (não vão para o novo conteúdo)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Substituir a seção antiga pela nova
|
|
627
|
+
newLines.splice(startLine, contextAndRemoveCount, ...newSectionLines);
|
|
628
|
+
|
|
629
|
+
// Calcular novo offset
|
|
630
|
+
const addedCount = linesToAdd.length;
|
|
631
|
+
const newOffset = offset + addedCount - removeCount;
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
success: true,
|
|
635
|
+
lines: newLines,
|
|
636
|
+
newOffset,
|
|
637
|
+
added: addedCount,
|
|
638
|
+
removed: removeCount
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Trunca string para exibição
|
|
644
|
+
*/
|
|
645
|
+
/**
|
|
646
|
+
* Check if context lines match at a given position in the file.
|
|
647
|
+
* Returns the position if match found, null otherwise.
|
|
648
|
+
* Uses flexible whitespace comparison.
|
|
649
|
+
*/
|
|
650
|
+
_findContextMatch(lines, originalLines, startLine) {
|
|
651
|
+
if (originalLines.length === 0) return startLine;
|
|
652
|
+
|
|
653
|
+
for (let i = 0; i < originalLines.length; i++) {
|
|
654
|
+
const fileLineIndex = startLine + i;
|
|
655
|
+
if (fileLineIndex >= lines.length) return null;
|
|
656
|
+
|
|
657
|
+
const expectedContent = originalLines[i].content;
|
|
658
|
+
const actualLine = lines[fileLineIndex];
|
|
659
|
+
|
|
660
|
+
// Primary: exact match after trimEnd
|
|
661
|
+
const expectedTrimmed = expectedContent.trimEnd();
|
|
662
|
+
const actualTrimmed = actualLine.trimEnd();
|
|
663
|
+
|
|
664
|
+
if (expectedTrimmed === actualTrimmed) continue;
|
|
665
|
+
|
|
666
|
+
// Secondary: normalize all whitespace (tabs vs spaces, etc)
|
|
667
|
+
const expectedNorm = expectedContent.trim().replace(/\s+/g, ' ');
|
|
668
|
+
const actualNorm = actualLine.trim().replace(/\s+/g, ' ');
|
|
669
|
+
|
|
670
|
+
if (expectedNorm === actualNorm) continue;
|
|
671
|
+
|
|
672
|
+
// No match at this position
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return startLine;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
_truncate(str, maxLen) {
|
|
680
|
+
if (str.length <= maxLen) return str;
|
|
681
|
+
return str.substring(0, maxLen - 3) + '...';
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Garante que o diretório de backup existe
|
|
686
|
+
*/
|
|
687
|
+
async _ensureBackupDir() {
|
|
688
|
+
try {
|
|
689
|
+
await fs.access(this.backupDir);
|
|
690
|
+
} catch {
|
|
691
|
+
await fs.mkdir(this.backupDir, { recursive: true });
|
|
692
|
+
console.log(`📠Created backup directory: ${this.backupDir}`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Remove backups antigos para um arquivo especÃfico
|
|
698
|
+
*/
|
|
699
|
+
async _cleanupOldBackups(originalPath) {
|
|
700
|
+
const backups = await this.listBackups();
|
|
701
|
+
const fileBackups = backups.filter(b => b.originalPath === originalPath);
|
|
702
|
+
|
|
703
|
+
if (fileBackups.length <= this.maxBackupsPerFile) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Remover backups mais antigos
|
|
708
|
+
const toRemove = fileBackups.slice(this.maxBackupsPerFile);
|
|
709
|
+
|
|
710
|
+
for (const backup of toRemove) {
|
|
711
|
+
try {
|
|
712
|
+
const safeFileName = backup.originalPath.replace(/[\/\\]/g, '_');
|
|
713
|
+
const backupFileName = `${backup.backupId}_${safeFileName}`;
|
|
714
|
+
|
|
715
|
+
await fs.unlink(path.join(this.backupDir, backupFileName));
|
|
716
|
+
await fs.unlink(path.join(this.backupDir, `${backup.backupId}.meta.json`));
|
|
717
|
+
|
|
718
|
+
console.log(`ðŸ—‘ï¸ Removed old backup: ${backup.backupId}`);
|
|
719
|
+
} catch (err) {
|
|
720
|
+
console.warn(`âš ï¸ Failed to remove old backup ${backup.backupId}: ${err.message}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Custom error class for patch operations
|
|
728
|
+
*/
|
|
729
|
+
export class PatchError extends Error {
|
|
730
|
+
constructor(code, message) {
|
|
731
|
+
super(message);
|
|
732
|
+
this.code = code;
|
|
733
|
+
this.name = 'PatchError';
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ============================================
|
|
738
|
+
// TYPE DEFINITIONS (for documentation)
|
|
739
|
+
// ============================================
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* @typedef {Object} ValidationResult
|
|
743
|
+
* @property {boolean} valid
|
|
744
|
+
* @property {string[]} errors
|
|
745
|
+
*/
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* @typedef {Object} ParsedPatch
|
|
749
|
+
* @property {Patch[]} patches
|
|
750
|
+
* @property {number} totalHunks
|
|
751
|
+
* @property {number} totalFiles
|
|
752
|
+
*/
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* @typedef {Object} Patch
|
|
756
|
+
* @property {string} oldFile
|
|
757
|
+
* @property {string} newFile
|
|
758
|
+
* @property {Hunk[]} hunks
|
|
759
|
+
*/
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* @typedef {Object} Hunk
|
|
763
|
+
* @property {number} oldStart
|
|
764
|
+
* @property {number} oldCount
|
|
765
|
+
* @property {number} newStart
|
|
766
|
+
* @property {number} newCount
|
|
767
|
+
* @property {string} context
|
|
768
|
+
* @property {HunkLine[]} lines
|
|
769
|
+
*/
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* @typedef {Object} HunkLine
|
|
773
|
+
* @property {'add'|'remove'|'context'} type
|
|
774
|
+
* @property {string} content
|
|
775
|
+
*/
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* @typedef {Object} BackupResult
|
|
779
|
+
* @property {string} backupId
|
|
780
|
+
* @property {string} originalPath
|
|
781
|
+
* @property {string} backupPath
|
|
782
|
+
* @property {string} timestamp
|
|
783
|
+
* @property {number} size
|
|
784
|
+
*/
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* @typedef {Object} ApplyResult
|
|
788
|
+
* @property {boolean} success
|
|
789
|
+
* @property {number} filesModified
|
|
790
|
+
* @property {number} totalFiles
|
|
791
|
+
* @property {FileResult[]} results
|
|
792
|
+
* @property {string[]} backups
|
|
793
|
+
* @property {boolean} dryRun
|
|
794
|
+
*/
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* @typedef {Object} FileResult
|
|
798
|
+
* @property {string} file
|
|
799
|
+
* @property {boolean} success
|
|
800
|
+
* @property {number} [hunksApplied]
|
|
801
|
+
* @property {string} [backup]
|
|
802
|
+
* @property {string} [error]
|
|
803
|
+
*/
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* @typedef {Object} SafePatchResult
|
|
807
|
+
* @property {boolean} success
|
|
808
|
+
* @property {string} [error]
|
|
809
|
+
* @property {string} stage
|
|
810
|
+
* @property {number} duration
|
|
811
|
+
* @property {number} [filesModified]
|
|
812
|
+
* @property {string[]} [backups]
|
|
813
|
+
* @property {FileResult[]} [results]
|
|
814
|
+
*/
|
|
815
|
+
|
|
816
|
+
export default PatchManager;
|