@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.
- package/README.md +506 -0
- package/cli.js +38 -0
- package/config/default-config.js +56 -0
- package/index.js +128 -0
- package/modules/change-detector.js +258 -0
- package/modules/detectors/database-detector.js +182 -0
- package/modules/detectors/endpoint-detector.js +52 -0
- package/modules/impact-analyzer.js +124 -0
- package/modules/report-generator.js +373 -0
- package/modules/utils/ast-parser.js +241 -0
- package/modules/utils/dependency-graph.js +159 -0
- package/modules/utils/file-utils.js +116 -0
- package/modules/utils/git-utils.js +198 -0
- package/modules/utils/method-call-graph.js +952 -0
- package/package.json +26 -0
- package/run-impact-analysis.sh +124 -0
|
@@ -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
|
+
}
|