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.
Files changed (50) hide show
  1. package/.dockerignore +24 -0
  2. package/.idea/deepdebug-local-agent.iml +12 -0
  3. package/.idea/modules.xml +8 -0
  4. package/.idea/vcs.xml +6 -0
  5. package/Dockerfile +46 -0
  6. package/cloudbuild.yaml +42 -0
  7. package/index.js +42 -0
  8. package/mcp-server.js +533 -0
  9. package/package.json +22 -0
  10. package/src/ai-engine.js +861 -0
  11. package/src/analyzers/config-analyzer.js +446 -0
  12. package/src/analyzers/controller-analyzer.js +429 -0
  13. package/src/analyzers/dto-analyzer.js +455 -0
  14. package/src/detectors/build-tool-detector.js +0 -0
  15. package/src/detectors/framework-detector.js +91 -0
  16. package/src/detectors/language-detector.js +89 -0
  17. package/src/detectors/multi-project-detector.js +191 -0
  18. package/src/detectors/service-detector.js +244 -0
  19. package/src/detectors.js +30 -0
  20. package/src/exec-utils.js +215 -0
  21. package/src/fs-utils.js +34 -0
  22. package/src/git/base-git-provider.js +384 -0
  23. package/src/git/git-provider-registry.js +110 -0
  24. package/src/git/github-provider.js +502 -0
  25. package/src/mcp-http-server.js +313 -0
  26. package/src/patch/patch-engine.js +339 -0
  27. package/src/patch-manager.js +816 -0
  28. package/src/patch.js +607 -0
  29. package/src/patch_bkp.js +154 -0
  30. package/src/ports.js +69 -0
  31. package/src/routes/workspace.route.js +528 -0
  32. package/src/runtimes/base-runtime.js +290 -0
  33. package/src/runtimes/java/gradle-runtime.js +378 -0
  34. package/src/runtimes/java/java-integrations.js +339 -0
  35. package/src/runtimes/java/maven-runtime.js +418 -0
  36. package/src/runtimes/node/node-integrations.js +247 -0
  37. package/src/runtimes/node/npm-runtime.js +466 -0
  38. package/src/runtimes/node/yarn-runtime.js +354 -0
  39. package/src/runtimes/runtime-registry.js +256 -0
  40. package/src/server-local.js +576 -0
  41. package/src/server.js +4565 -0
  42. package/src/utils/environment-diagnostics.js +666 -0
  43. package/src/utils/exec-utils.js +264 -0
  44. package/src/utils/fs-utils.js +218 -0
  45. package/src/workspace/detect-port.js +176 -0
  46. package/src/workspace/file-reader.js +54 -0
  47. package/src/workspace/git-client.js +0 -0
  48. package/src/workspace/process-manager.js +619 -0
  49. package/src/workspace/scanner.js +72 -0
  50. 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;