@sun-asterisk/impact-analyzer 1.0.0

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.
@@ -0,0 +1,952 @@
1
+ /**
2
+ * Method-Level Call Graph using ts-morph
3
+ * Tracks which methods call which other methods across files
4
+ */
5
+
6
+ import { Project, SyntaxKind } from 'ts-morph';
7
+ import path from 'path';
8
+
9
+ export class MethodCallGraph {
10
+ constructor() {
11
+ this.project = null;
12
+ this.methodCallMap = new Map(); // method -> [callers] (reverse map)
13
+ this.methodCallsMap = new Map(); // method -> [methods it calls] (forward map)
14
+ this.methodToFile = new Map(); // method -> file path
15
+ this.methodToEndpoint = new Map(); // method -> endpoint info
16
+ this.interfaceToClass = new Map(); // interface name -> concrete class name
17
+ this.commandNameToClass = new Map(); // command name -> command handler class
18
+ this.endpointToCommandNames = new Map(); // endpoint method -> [command names]
19
+ }
20
+
21
+ /**
22
+ * Initialize ts-morph project and build call graph
23
+ */
24
+ async initialize(sourceDir, excludePaths = [], verbose = false) {
25
+ this.verbose = verbose;
26
+
27
+ this.project = new Project({
28
+ tsConfigFilePath: path.join(sourceDir, '../tsconfig.json'),
29
+ skipAddingFilesFromTsConfig: true,
30
+ });
31
+
32
+ // Add source files
33
+ this.project.addSourceFilesAtPaths(`${sourceDir}/**/*.ts`);
34
+
35
+ // Filter out excluded paths
36
+ const sourceFiles = this.project.getSourceFiles().filter(sf => {
37
+ const filePath = sf.getFilePath();
38
+ return !excludePaths.some(ex => filePath.includes(ex));
39
+ });
40
+
41
+ for (const sourceFile of sourceFiles) {
42
+ this.extractInterfaceMappings(sourceFile);
43
+ this.extractCommandMappings(sourceFile);
44
+ }
45
+
46
+ for (const sourceFile of sourceFiles) {
47
+ this.analyzeFile(sourceFile);
48
+ }
49
+
50
+ return this;
51
+ }
52
+
53
+ /**
54
+ * Analyze a single file to extract method definitions and calls
55
+ */
56
+ analyzeFile(sourceFile) {
57
+ const filePath = sourceFile.getFilePath();
58
+
59
+ // Get all classes
60
+ const classes = sourceFile.getClasses();
61
+
62
+ for (const classDecl of classes) {
63
+ const className = classDecl.getName();
64
+ if (!className) continue; // Skip anonymous classes
65
+
66
+ // Get all methods in this class
67
+ const methods = classDecl.getMethods();
68
+
69
+ for (const method of methods) {
70
+ const methodName = method.getName();
71
+ const fullMethodName = `${className}.${methodName}`;
72
+
73
+ // Store method location
74
+ this.methodToFile.set(fullMethodName, filePath);
75
+
76
+ // Check if this is an endpoint method (has HTTP decorator)
77
+ const decorators = method.getDecorators();
78
+ const httpDecorator = decorators.find(d =>
79
+ ['Get', 'Post', 'Put', 'Delete', 'Patch', 'Options', 'Head', 'All'].includes(d.getName())
80
+ );
81
+
82
+ if (httpDecorator) {
83
+ const decoratorName = httpDecorator.getName();
84
+ const args = httpDecorator.getArguments();
85
+ const route = args[0]?.getText().replace(/['"]/g, '') || '/';
86
+
87
+ this.methodToEndpoint.set(fullMethodName, {
88
+ method: decoratorName.toUpperCase(),
89
+ path: route,
90
+ controller: className,
91
+ file: filePath,
92
+ });
93
+ }
94
+
95
+ // Find all method calls within this method
96
+ this.analyzeMethodCalls(method, className, fullMethodName);
97
+
98
+ // If this is an endpoint, detect command dispatches
99
+ if (httpDecorator) {
100
+ this.detectCommandDispatches(method, fullMethodName);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Detect command dispatches in endpoint methods
108
+ * Handles both patterns:
109
+ * 1. Direct string: publishMessage('commandName', ...)
110
+ * 2. Object with command property: publishMessage({ message: { command: 'commandName' } })
111
+ */
112
+ detectCommandDispatches(method, fullMethodName) {
113
+ const callExpressions = method.getDescendantsOfKind(SyntaxKind.CallExpression);
114
+ const commandNames = [];
115
+
116
+ for (const call of callExpressions) {
117
+ const expression = call.getExpression();
118
+
119
+ // Look for method calls that might dispatch commands
120
+ if (expression.getKind() === SyntaxKind.PropertyAccessExpression) {
121
+ const methodName = expression.getName();
122
+
123
+ // Common command dispatch patterns (partial match)
124
+ const dispatchMethods = [
125
+ 'sendCommand', 'send', 'execute', 'dispatch',
126
+ 'publish', 'emit', 'trigger', 'enqueue'
127
+ ];
128
+
129
+ // Check if method name contains or matches any dispatch pattern
130
+ const isDispatchMethod = dispatchMethods.some(pattern => {
131
+ const lowerMethodName = methodName.toLowerCase();
132
+ const lowerPattern = pattern.toLowerCase();
133
+
134
+ // Exact match
135
+ if (lowerMethodName === lowerPattern) return true;
136
+
137
+ // Contains pattern (e.g., publishMessage contains 'publish')
138
+ if (lowerMethodName.includes(lowerPattern)) return true;
139
+
140
+ return false;
141
+ });
142
+
143
+ if (isDispatchMethod) {
144
+ const args = call.getArguments();
145
+
146
+ if (args.length > 0) {
147
+ const firstArg = args[0];
148
+
149
+ // Pattern 1: Direct string literal
150
+ // Example: publishMessage('commandName', ...)
151
+ if (firstArg.getKind() === SyntaxKind.StringLiteral) {
152
+ const commandName = firstArg.getText().replace(/['"]/g, '');
153
+ commandNames.push(commandName);
154
+
155
+ if (this.verbose) {
156
+ console.log(` 📨 Found command dispatch: ${fullMethodName} → '${commandName}' (via ${methodName})`);
157
+ }
158
+ }
159
+
160
+ // Pattern 2: Object literal with command property
161
+ // Example: publishMessage({ message: { command: 'commandName' } })
162
+ else if (firstArg.getKind() === SyntaxKind.ObjectLiteralExpression) {
163
+ const commandName = this.extractCommandFromObject(firstArg, method);
164
+
165
+ if (commandName) {
166
+ commandNames.push(commandName);
167
+
168
+ if (this.verbose) {
169
+ console.log(` 📨 Found command dispatch: ${fullMethodName} → '${commandName}' (via ${methodName}, object pattern)`);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ if (commandNames.length > 0) {
179
+ this.endpointToCommandNames.set(fullMethodName, commandNames);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Extract command name from object literal
185
+ * Handles patterns like:
186
+ * - { command: 'xxx' }
187
+ * - { message: { command: 'xxx' } }
188
+ * - { command: variable } (resolves variable value if possible)
189
+ * - { command } (ES6 shorthand - resolves variable)
190
+ */
191
+ extractCommandFromObject(objectLiteral, method) {
192
+ const properties = objectLiteral.getProperties();
193
+
194
+ for (const prop of properties) {
195
+ const propKind = prop.getKind();
196
+
197
+ // Handle ES6 shorthand: { command } → same as { command: command }
198
+ if (propKind === SyntaxKind.ShorthandPropertyAssignment) {
199
+ const propName = prop.getName();
200
+
201
+ if (propName === 'command') {
202
+ // The property name IS the variable name in shorthand
203
+ const variableName = propName;
204
+
205
+ // Try to resolve the variable value
206
+ const resolvedValue = this.resolveVariableValue(variableName, method);
207
+
208
+ if (resolvedValue) {
209
+ return resolvedValue;
210
+ }
211
+
212
+ // Fallback: return variable name if can't resolve
213
+ return variableName;
214
+ }
215
+ }
216
+
217
+ // Handle regular property assignment: { command: 'xxx' } or { command: variable }
218
+ if (propKind === SyntaxKind.PropertyAssignment) {
219
+ const propName = prop.getName();
220
+ const initializer = prop.getInitializer();
221
+
222
+ // Direct: { command: 'xxx' }
223
+ if (propName === 'command') {
224
+ if (initializer && initializer.getKind() === SyntaxKind.StringLiteral) {
225
+ return initializer.getText().replace(/['"]/g, '');
226
+ }
227
+
228
+ // { command: variable } - resolve variable value
229
+ if (initializer && initializer.getKind() === SyntaxKind.Identifier) {
230
+ const variableName = initializer.getText();
231
+
232
+ // Try to resolve the variable value
233
+ const resolvedValue = this.resolveVariableValue(variableName, method);
234
+
235
+ if (resolvedValue) {
236
+ return resolvedValue;
237
+ }
238
+
239
+ // Fallback: return variable name if can't resolve
240
+ return variableName;
241
+ }
242
+ }
243
+
244
+ // Nested: { message: { command: 'xxx' } } or { message: { command } }
245
+ if (propName === 'message' && initializer &&
246
+ initializer.getKind() === SyntaxKind.ObjectLiteralExpression) {
247
+ const nestedCommand = this.extractCommandFromObject(initializer, method);
248
+ if (nestedCommand) return nestedCommand;
249
+ }
250
+ }
251
+ }
252
+
253
+ return null;
254
+ }
255
+
256
+ /**
257
+ * Resolve variable value from its declaration
258
+ * Example: const command = 'exportCsv'; → returns 'exportCsv'
259
+ */
260
+ resolveVariableValue(variableName, method) {
261
+ if (!method) return null;
262
+
263
+ // Get all variable declarations in this method
264
+ const variableDeclarations = method.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
265
+
266
+ for (const varDecl of variableDeclarations) {
267
+ if (varDecl.getName() === variableName) {
268
+ const initializer = varDecl.getInitializer();
269
+
270
+ // If initialized with string literal
271
+ if (initializer && initializer.getKind() === SyntaxKind.StringLiteral) {
272
+ return initializer.getText().replace(/['"]/g, '');
273
+ }
274
+
275
+ // If initialized with another identifier, try to resolve that too (one level)
276
+ if (initializer && initializer.getKind() === SyntaxKind.Identifier) {
277
+ const nestedVarName = initializer.getText();
278
+ return this.resolveVariableValue(nestedVarName, method);
279
+ }
280
+ }
281
+ }
282
+
283
+ // Check method parameters
284
+ const parameters = method.getParameters();
285
+ for (const param of parameters) {
286
+ if (param.getName() === variableName) {
287
+ // Can't resolve parameter values statically
288
+ return variableName;
289
+ }
290
+ }
291
+
292
+ return null;
293
+ }
294
+
295
+ /**
296
+ * Extract command name -> class mappings from @Command decorators
297
+ */
298
+ extractCommandMappings(sourceFile) {
299
+ const classes = sourceFile.getClasses();
300
+
301
+ for (const classDecl of classes) {
302
+ const commandDecorator = classDecl.getDecorator('Command');
303
+ if (!commandDecorator) continue;
304
+
305
+ const className = classDecl.getName();
306
+ if (!className) continue;
307
+
308
+ const args = commandDecorator.getArguments();
309
+ if (args.length === 0) continue;
310
+
311
+ const configObj = args[0];
312
+ if (!configObj) continue;
313
+
314
+ const text = configObj.getText();
315
+
316
+ // Pattern: { name: 'commandName', ... }
317
+ const nameMatch = text.match(/name:\s*['"]([^'"]+)['"]/);
318
+
319
+ if (nameMatch) {
320
+ const commandName = nameMatch[1];
321
+ this.commandNameToClass.set(commandName, className);
322
+ }
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Extract interface -> class mappings from NestJS modules
328
+ */
329
+ extractInterfaceMappings(sourceFile) {
330
+ // Look for @Module decorators
331
+ const classes = sourceFile.getClasses();
332
+
333
+ for (const classDecl of classes) {
334
+ const moduleDecorator = classDecl.getDecorator('Module');
335
+ if (!moduleDecorator) continue;
336
+
337
+ const args = moduleDecorator.getArguments();
338
+ if (args.length === 0) continue;
339
+
340
+ const configObj = args[0];
341
+ if (!configObj) continue;
342
+
343
+ // Parse the configuration object
344
+ const text = configObj.getText();
345
+
346
+ // Pattern: { provide: 'IXxx', useClass: Xxx }
347
+ // or: { provide: 'IXxx', useClass: Xxx, }
348
+ const providerPattern = /\{\s*provide:\s*['"](\w+)['"]\s*,\s*useClass:\s*(\w+)/g;
349
+
350
+ let match;
351
+ while ((match = providerPattern.exec(text)) !== null) {
352
+ const interfaceName = match[1];
353
+ const className = match[2];
354
+
355
+ this.interfaceToClass.set(interfaceName, className);
356
+ }
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Analyze all method calls within a method
362
+ */
363
+ analyzeMethodCalls(method, className, fullMethodName) {
364
+ const callExpressions = method.getDescendantsOfKind(SyntaxKind.CallExpression);
365
+
366
+ for (const call of callExpressions) {
367
+ const expression = call.getExpression();
368
+
369
+ // Handle method calls: this.method(), service.method(), this.repo.method()
370
+ if (expression.getKind() === SyntaxKind.PropertyAccessExpression) {
371
+ const calledMethod = expression.getName();
372
+ const objExpression = expression.getExpression();
373
+
374
+ let calledClassName = null;
375
+
376
+ // Special handling for 'this' keyword
377
+ if (objExpression.getKind() === SyntaxKind.ThisKeyword) {
378
+ calledClassName = className;
379
+ } else {
380
+ // Use TypeScript's type checker to resolve the type
381
+ const type = objExpression.getType();
382
+ const symbol = type.getSymbol();
383
+
384
+ if (symbol) {
385
+ calledClassName = symbol.getName();
386
+
387
+ // Check if this is an interface - map to concrete class
388
+ if (this.interfaceToClass.has(calledClassName)) {
389
+ const concreteClass = this.interfaceToClass.get(calledClassName);
390
+ calledClassName = concreteClass;
391
+ }
392
+
393
+ // Handle interface naming convention (IXxx -> Xxx)
394
+ if (calledClassName.startsWith('I') && calledClassName.length > 1) {
395
+ const possibleClassName = calledClassName.substring(1);
396
+ if (this.classExists(possibleClassName)) {
397
+ calledClassName = possibleClassName;
398
+ }
399
+ }
400
+ }
401
+ }
402
+
403
+ if (calledClassName) {
404
+ this.recordMethodCall(fullMethodName, calledClassName, calledMethod);
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Check if a class exists in the project
412
+ */
413
+ classExists(className) {
414
+ for (const [methodName] of this.methodToFile) {
415
+ if (methodName.startsWith(`${className}.`)) {
416
+ return true;
417
+ }
418
+ }
419
+ return false;
420
+ }
421
+
422
+ /**
423
+ * Record a method call in both forward and reverse maps
424
+ */
425
+ recordMethodCall(callerFullName, calleeClassName, calleeMethodName) {
426
+ const calleeFullName = `${calleeClassName}.${calleeMethodName}`;
427
+
428
+ // Add to reverse map (who calls this method)
429
+ if (!this.methodCallMap.has(calleeFullName)) {
430
+ this.methodCallMap.set(calleeFullName, []);
431
+ }
432
+
433
+ const callers = this.methodCallMap.get(calleeFullName);
434
+ if (!callers.includes(callerFullName)) {
435
+ callers.push(callerFullName);
436
+ }
437
+
438
+ // Add to forward map (what this method calls)
439
+ if (!this.methodCallsMap.has(callerFullName)) {
440
+ this.methodCallsMap.set(callerFullName, []);
441
+ }
442
+
443
+ const calls = this.methodCallsMap.get(callerFullName);
444
+ if (!calls.includes(calleeFullName)) {
445
+ calls.push(calleeFullName);
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Get changed methods from git diff
451
+ * Detects both:
452
+ * 1. Methods that contain changed lines
453
+ * 2. Method calls that were added/removed
454
+ */
455
+ getChangedMethods(diff, filePath) {
456
+ const changedMethods = new Set();
457
+ const changedMethodCalls = new Set();
458
+
459
+ if (!diff || !filePath) return [];
460
+
461
+ const sourceFile = this.project.getSourceFile(filePath);
462
+ if (!sourceFile) return [];
463
+
464
+ const classes = sourceFile.getClasses();
465
+ if (classes.length === 0) return [];
466
+
467
+ // Parse diff to extract changed line numbers
468
+ const changedLines = this.extractChangedLineNumbers(diff);
469
+
470
+ if (changedLines.length === 0) return [];
471
+
472
+ // 1. Find methods that contain the changed lines
473
+ for (const classDecl of classes) {
474
+ const className = classDecl.getName();
475
+ const methods = classDecl.getMethods();
476
+
477
+ for (const method of methods) {
478
+ const methodName = method.getName();
479
+ const startLine = method.getStartLineNumber();
480
+ const endLine = method.getEndLineNumber();
481
+
482
+ // Check if any changed line falls within this method's range
483
+ const hasChangedLine = changedLines.some(
484
+ lineNum => lineNum >= startLine && lineNum <= endLine
485
+ );
486
+
487
+ if (hasChangedLine) {
488
+ const fullName = `${className}.${methodName}`;
489
+ changedMethods.add(fullName);
490
+ }
491
+ }
492
+ }
493
+
494
+ // 2. Detect method calls that were modified in the diff
495
+ const diffLines = diff.split('\n');
496
+
497
+ for (const line of diffLines) {
498
+ // Only look at added/removed lines
499
+ if (!line.startsWith('+') && !line.startsWith('-')) continue;
500
+
501
+ const codeLine = line.substring(1).trim();
502
+
503
+ // Pattern: object.methodName(...) or await object.methodName(...)
504
+ const methodCallPattern = /(?:await\s+)?(\w+)\.(\w+)\s*\(/g;
505
+ let match;
506
+
507
+ while ((match = methodCallPattern.exec(codeLine)) !== null) {
508
+ const objectName = match[1];
509
+ const methodName = match[2];
510
+
511
+ // Try to resolve the type of the object
512
+ const resolvedType = this.resolveObjectType(objectName, sourceFile, classes);
513
+
514
+ if (resolvedType) {
515
+ const fullName = `${resolvedType}.${methodName}`;
516
+ changedMethodCalls.add(fullName);
517
+ }
518
+ }
519
+ }
520
+
521
+ // Combine both: methods containing changes + method calls that changed
522
+ const allChangedMethods = [...changedMethods, ...changedMethodCalls];
523
+
524
+ // Convert to objects with metadata
525
+ return [...new Set(allChangedMethods)].map(fullName => {
526
+ const parts = fullName.split('.');
527
+ return {
528
+ className: parts[0],
529
+ methodName: parts[1],
530
+ file: filePath,
531
+ fullName: fullName
532
+ };
533
+ });
534
+ }
535
+
536
+ /**
537
+ * Extract line numbers that were changed from git diff
538
+ */
539
+ extractChangedLineNumbers(diff) {
540
+ const changedLines = [];
541
+ const lines = diff.split('\n');
542
+ let currentLine = 0;
543
+
544
+ for (const line of lines) {
545
+ // Parse diff hunk headers: @@ -old +new @@
546
+ const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
547
+ if (hunkMatch) {
548
+ currentLine = parseInt(hunkMatch[1], 10);
549
+ continue;
550
+ }
551
+
552
+ // Track line numbers for added/modified lines
553
+ if (line.startsWith('+') && !line.startsWith('+++')) {
554
+ changedLines.push(currentLine);
555
+ currentLine++;
556
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
557
+ // Don't increment for deleted lines (they don't exist in new file)
558
+ continue;
559
+ } else if (!line.startsWith('\\')) {
560
+ // Context lines (no +/-)
561
+ currentLine++;
562
+ }
563
+ }
564
+
565
+ return changedLines;
566
+ }
567
+
568
+ /**
569
+ * Resolve object type from variable/property name
570
+ */
571
+ resolveObjectType(objectName, sourceFile, classes) {
572
+ // Handle 'this.property' pattern
573
+ if (objectName.startsWith('this.')) {
574
+ const propertyName = objectName.replace('this.', '');
575
+ if (classes.length > 0) {
576
+ return this.resolvePropertyType(classes[0], propertyName);
577
+ }
578
+ return null;
579
+ }
580
+
581
+ // Check if it's 'this' or 'super'
582
+ if (objectName === 'this' && classes.length > 0) {
583
+ return classes[0].getName();
584
+ }
585
+
586
+ // Try to find the type from the current class
587
+ for (const classDecl of classes) {
588
+ // Check constructor parameters (DI)
589
+ const constructor = classDecl.getConstructors()[0];
590
+ if (constructor) {
591
+ const params = constructor.getParameters();
592
+ for (const param of params) {
593
+ const paramName = param.getName();
594
+ if (paramName === objectName) {
595
+ const type = param.getType();
596
+ const symbol = type.getSymbol();
597
+ if (symbol) {
598
+ return symbol.getName();
599
+ }
600
+ }
601
+ }
602
+ }
603
+
604
+ // Check class properties
605
+ const properties = classDecl.getProperties();
606
+ for (const prop of properties) {
607
+ if (prop.getName() === objectName) {
608
+ const type = prop.getType();
609
+ const symbol = type.getSymbol();
610
+ if (symbol) {
611
+ return symbol.getName();
612
+ }
613
+ }
614
+ }
615
+
616
+ // Check local variables in methods (simplified)
617
+ const methods = classDecl.getMethods();
618
+ for (const method of methods) {
619
+ const variableDeclarations = method.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
620
+ for (const varDecl of variableDeclarations) {
621
+ if (varDecl.getName() === objectName) {
622
+ const type = varDecl.getType();
623
+ const symbol = type.getSymbol();
624
+ if (symbol) {
625
+ return symbol.getName();
626
+ }
627
+ }
628
+ }
629
+ }
630
+ }
631
+
632
+ // If we can't resolve, try to guess from naming convention
633
+ return this.guessTypeFromVariableName(objectName);
634
+ }
635
+
636
+ /**
637
+ * Guess class name from variable name using naming conventions
638
+ */
639
+ guessTypeFromVariableName(variableName) {
640
+ // Convert camelCase to PascalCase
641
+ return variableName.charAt(0).toUpperCase() + variableName.slice(1);
642
+ }
643
+
644
+ /**
645
+ * Get direct callers of a method
646
+ */
647
+ getCallers(methodName) {
648
+ return this.methodCallMap.get(methodName) || [];
649
+ }
650
+
651
+ /**
652
+ * Find all callers of a method (recursive)
653
+ */
654
+ findAllCallers(methodName, visited = new Set(), depth = 0) {
655
+ if (visited.has(methodName)) return [];
656
+
657
+ visited.add(methodName);
658
+ const directCallers = this.methodCallMap.get(methodName) || [];
659
+ let allCallers = [...directCallers];
660
+
661
+ if (this.verbose && depth === 0) {
662
+ console.log(` Tracing callers of: ${methodName}`);
663
+ console.log(` Direct callers: ${directCallers.join(', ') || 'none'}`);
664
+ }
665
+
666
+ // Recursively find callers of callers
667
+ for (const caller of directCallers) {
668
+ if (this.verbose && depth === 0) {
669
+ console.log(` Tracing up from: ${caller}`);
670
+ }
671
+ const indirectCallers = this.findAllCallers(caller, visited, depth + 1);
672
+ allCallers = allCallers.concat(indirectCallers);
673
+ }
674
+
675
+ return [...new Set(allCallers)]; // Remove duplicates
676
+ }
677
+
678
+ /**
679
+ * Find affected endpoints from changed methods
680
+ * Traverses up the call chain: Repository → Service → Controller → Endpoint
681
+ */
682
+ findAffectedEndpoints(changedMethods) {
683
+ const affectedEndpoints = [];
684
+ const processedMethods = new Set();
685
+
686
+ const methodNames = changedMethods.map(m =>
687
+ typeof m === 'string' ? m : m.fullName
688
+ );
689
+
690
+ if (this.verbose) {
691
+ console.log('\n 🔍 Finding affected endpoints...');
692
+ console.log(` Changed methods: ${methodNames.join(', ')}`);
693
+ }
694
+
695
+ for (const changedMethod of methodNames) {
696
+ const startLayer = this.getMethodLayer(changedMethod);
697
+
698
+ if (this.verbose) {
699
+ console.log(`\n 📌 Processing: ${changedMethod} (${startLayer})`);
700
+ }
701
+
702
+ const callers = this.findAllCallers(changedMethod);
703
+
704
+ if (this.verbose) {
705
+ console.log(` All callers (${callers.length}): ${callers.join(', ') || 'none'}`);
706
+ }
707
+
708
+ const endpointCallers = this.filterUpToEndpoints(changedMethod, callers);
709
+
710
+ if (this.verbose) {
711
+ console.log(` Endpoint callers (${endpointCallers.length}): ${endpointCallers.join(', ') || 'none'}`);
712
+ }
713
+
714
+ for (const caller of endpointCallers) {
715
+ if (processedMethods.has(caller)) continue;
716
+ processedMethods.add(caller);
717
+
718
+ const endpoint = this.methodToEndpoint.get(caller);
719
+ if (endpoint) {
720
+ const callChain = this.getCallChain(changedMethod, caller);
721
+
722
+ if (this.verbose) {
723
+ console.log(` ✅ Found endpoint: ${endpoint.method} ${endpoint.path}`);
724
+ console.log(` Call chain: ${callChain.join(' → ')}`);
725
+ }
726
+
727
+ affectedEndpoints.push({
728
+ ...endpoint,
729
+ affectedBy: changedMethod,
730
+ callChain: callChain,
731
+ layers: this.getCallChainLayers(callChain),
732
+ });
733
+ }
734
+ }
735
+
736
+ const endpoint = this.methodToEndpoint.get(changedMethod);
737
+ if (endpoint && !processedMethods.has(changedMethod)) {
738
+ processedMethods.add(changedMethod);
739
+
740
+ if (this.verbose) {
741
+ console.log(` ✅ Changed method itself is an endpoint: ${endpoint.method} ${endpoint.path}`);
742
+ }
743
+
744
+ affectedEndpoints.push({
745
+ ...endpoint,
746
+ affectedBy: changedMethod,
747
+ callChain: [changedMethod],
748
+ layers: [startLayer],
749
+ });
750
+ }
751
+
752
+ // Check if changed method is in a Command handler
753
+ const commandEndpoints = this.findEndpointsByCommand(changedMethod);
754
+ for (const cmdEndpoint of commandEndpoints) {
755
+ if (processedMethods.has(cmdEndpoint.endpointMethod)) continue;
756
+ processedMethods.add(cmdEndpoint.endpointMethod);
757
+
758
+ affectedEndpoints.push(cmdEndpoint);
759
+ }
760
+
761
+ // NEW: Check if any CALLER is in a Command handler
762
+ // Flow: ServiceB (changed) → ServiceA → Command.run → find endpoint
763
+ for (const caller of callers) {
764
+ const callerCommandEndpoints = this.findEndpointsByCommand(caller);
765
+ for (const cmdEndpoint of callerCommandEndpoints) {
766
+ if (processedMethods.has(cmdEndpoint.endpointMethod)) continue;
767
+ processedMethods.add(cmdEndpoint.endpointMethod);
768
+
769
+ if (this.verbose) {
770
+ console.log(` 🔗 Caller '${caller}' is in a command handler`);
771
+ }
772
+
773
+ affectedEndpoints.push(cmdEndpoint);
774
+ }
775
+ }
776
+ }
777
+
778
+ return affectedEndpoints;
779
+ }
780
+
781
+ /**
782
+ * Find endpoints that dispatch commands handled by the changed method
783
+ */
784
+ findEndpointsByCommand(changedMethod) {
785
+ const affectedEndpoints = [];
786
+
787
+ // Get the class name from the changed method
788
+ const className = changedMethod.split('.')[0];
789
+
790
+ // Check if this class is a command handler
791
+ let commandName = null;
792
+ for (const [cmdName, cmdClass] of this.commandNameToClass) {
793
+ if (cmdClass === className) {
794
+ commandName = cmdName;
795
+ break;
796
+ }
797
+ }
798
+
799
+ if (!commandName) {
800
+ return affectedEndpoints;
801
+ }
802
+
803
+ // Find endpoints that dispatch this command
804
+ for (const [endpointMethod, commandNames] of this.endpointToCommandNames) {
805
+ if (commandNames.includes(commandName)) {
806
+ const endpoint = this.methodToEndpoint.get(endpointMethod);
807
+
808
+ if (endpoint) {
809
+ if (this.verbose) {
810
+ console.log(` ✅ Found endpoint via command: ${endpoint.method} ${endpoint.path}`);
811
+ console.log(` Command chain: ${changedMethod} ← '${commandName}' ← ${endpointMethod}`);
812
+ }
813
+
814
+ affectedEndpoints.push({
815
+ ...endpoint,
816
+ affectedBy: changedMethod,
817
+ callChain: [changedMethod, `Command: '${commandName}'`, endpointMethod],
818
+ layers: [this.getMethodLayer(changedMethod), 'Command', this.getMethodLayer(endpointMethod)],
819
+ viaCommand: commandName,
820
+ endpointMethod: endpointMethod,
821
+ });
822
+ }
823
+ }
824
+ }
825
+
826
+ return affectedEndpoints;
827
+ }
828
+
829
+ /**
830
+ * Get the layer/tier of a method based on file path and class name
831
+ */
832
+ getMethodLayer(methodName) {
833
+ const filePath = this.methodToFile.get(methodName);
834
+ if (!filePath) return 'Unknown';
835
+
836
+ const lowerPath = filePath.toLowerCase();
837
+ const fileName = filePath.split('/').pop().toLowerCase();
838
+
839
+ // Check file path for layer indicators (order matters - most specific first)
840
+ if (lowerPath.includes('/controllers/') || fileName.includes('.controller.')) return 'Controller';
841
+ if (lowerPath.includes('/services/') || fileName.includes('.service.')) return 'Service';
842
+ if (lowerPath.includes('/repositories/') || fileName.includes('.repository.')) return 'Repository';
843
+ if (lowerPath.includes('/providers/') || fileName.includes('.provider.')) return 'Provider';
844
+ if (lowerPath.includes('/commands/') || fileName.includes('.command.')) return 'Command';
845
+ if (lowerPath.includes('/queries/') || fileName.includes('.query.')) return 'Query';
846
+ if (lowerPath.includes('/handlers/') || fileName.includes('.handler.')) return 'Handler';
847
+
848
+ // Additional layers you mentioned
849
+ if (lowerPath.includes('/dto/') || fileName.includes('.dto.')) return 'DTO';
850
+ if (lowerPath.includes('/request/') || fileName.includes('.request.')) return 'Request';
851
+ if (lowerPath.includes('/response/') || fileName.includes('.response.')) return 'Response';
852
+ if (lowerPath.includes('/config/') || fileName.includes('.config.')) return 'Config';
853
+ if (lowerPath.includes('/modules/') || fileName.includes('.module.')) return 'Module';
854
+ if (lowerPath.includes('/entities/') || fileName.includes('.entity.')) return 'Entity';
855
+ if (lowerPath.includes('/models/') || fileName.includes('.model.')) return 'Model';
856
+
857
+ // Common utility/helper patterns
858
+ if (lowerPath.includes('/helpers/') || fileName.includes('.helper.')) return 'Helper';
859
+ if (lowerPath.includes('/utils/') || fileName.includes('.util.')) return 'Utility';
860
+ if (lowerPath.includes('/middleware/') || fileName.includes('.middleware.')) return 'Middleware';
861
+ if (lowerPath.includes('/guards/') || fileName.includes('.guard.')) return 'Guard';
862
+ if (lowerPath.includes('/interceptors/') || fileName.includes('.interceptor.')) return 'Interceptor';
863
+ if (lowerPath.includes('/decorators/') || fileName.includes('.decorator.')) return 'Decorator';
864
+ if (lowerPath.includes('/pipes/') || fileName.includes('.pipe.')) return 'Pipe';
865
+ if (lowerPath.includes('/filters/') || fileName.includes('.filter.')) return 'Filter';
866
+
867
+ // Check class name
868
+ const className = methodName.split('.')[0];
869
+ if (className.endsWith('Controller')) return 'Controller';
870
+ if (className.endsWith('Service')) return 'Service';
871
+ if (className.endsWith('Repository')) return 'Repository';
872
+ if (className.endsWith('Provider')) return 'Provider';
873
+ if (className.endsWith('Command')) return 'Command';
874
+ if (className.endsWith('Query')) return 'Query';
875
+ if (className.endsWith('Handler')) return 'Handler';
876
+ if (className.endsWith('Dto') || className.endsWith('DTO')) return 'DTO';
877
+ if (className.endsWith('Request')) return 'Request';
878
+ if (className.endsWith('Response')) return 'Response';
879
+ if (className.endsWith('Config')) return 'Config';
880
+ if (className.endsWith('Module')) return 'Module';
881
+ if (className.endsWith('Entity')) return 'Entity';
882
+ if (className.endsWith('Model')) return 'Model';
883
+ if (className.endsWith('Helper')) return 'Helper';
884
+ if (className.endsWith('Util') || className.endsWith('Utils')) return 'Utility';
885
+ if (className.endsWith('Middleware')) return 'Middleware';
886
+ if (className.endsWith('Guard')) return 'Guard';
887
+ if (className.endsWith('Interceptor')) return 'Interceptor';
888
+ if (className.endsWith('Decorator')) return 'Decorator';
889
+ if (className.endsWith('Pipe')) return 'Pipe';
890
+ if (className.endsWith('Filter')) return 'Filter';
891
+
892
+ return 'Other';
893
+ }
894
+
895
+ /**
896
+ * Filter callers up through layers to find endpoints
897
+ * Repository → Service → Controller (endpoint)
898
+ */
899
+ filterUpToEndpoints(changedMethod, allCallers) {
900
+ return allCallers.filter(caller => this.methodToEndpoint.has(caller));
901
+ }
902
+
903
+ /**
904
+ * Get layers for each method in call chain
905
+ */
906
+ getCallChainLayers(callChain) {
907
+ return callChain.map(method => {
908
+ if (method === '...') return '...';
909
+ return this.getMethodLayer(method);
910
+ });
911
+ }
912
+
913
+ /**
914
+ * Get the call chain from changed method to endpoint
915
+ */
916
+ getCallChain(fromMethod, toMethod) {
917
+ // Simple BFS to find path
918
+ const queue = [[fromMethod]];
919
+ const visited = new Set();
920
+
921
+ while (queue.length > 0) {
922
+ const path = queue.shift();
923
+ const current = path[path.length - 1];
924
+
925
+ if (current === toMethod) {
926
+ return path;
927
+ }
928
+
929
+ if (visited.has(current)) continue;
930
+ visited.add(current);
931
+
932
+ const callers = this.methodCallMap.get(current) || [];
933
+ for (const caller of callers) {
934
+ queue.push([...path, caller]);
935
+ }
936
+ }
937
+
938
+ return [fromMethod, '...', toMethod];
939
+ }
940
+
941
+ /**
942
+ * Get statistics
943
+ */
944
+ getStats() {
945
+ return {
946
+ totalMethods: this.methodToFile.size,
947
+ totalEndpoints: this.methodToEndpoint.size,
948
+ totalCallRelationships: Array.from(this.methodCallMap.values())
949
+ .reduce((sum, callers) => sum + callers.length, 0),
950
+ };
951
+ }
952
+ }