ng-di-graph 0.1.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/LICENSE +21 -0
- package/README.md +199 -0
- package/dist/cli/index.cjs +1907 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/core/error-handler.d.ts +89 -0
- package/dist/core/graph-builder.d.ts +13 -0
- package/dist/core/graph-filter.d.ts +12 -0
- package/dist/core/logger.d.ts +95 -0
- package/dist/core/output-handler.d.ts +12 -0
- package/dist/core/parser.d.ts +252 -0
- package/dist/formatters/json-formatter.d.ts +24 -0
- package/dist/formatters/mermaid-formatter.d.ts +31 -0
- package/dist/tests/helpers/test-utils.d.ts +107 -0
- package/dist/types/index.d.ts +102 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1907 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
let commander = require("commander");
|
|
3
|
+
let node_fs = require("node:fs");
|
|
4
|
+
let node_path = require("node:path");
|
|
5
|
+
let ts_morph = require("ts-morph");
|
|
6
|
+
|
|
7
|
+
//#region src/core/error-handler.ts
|
|
8
|
+
/**
|
|
9
|
+
* Comprehensive error handling infrastructure for ng-di-graph CLI
|
|
10
|
+
* Implements FR-10 requirements from PRD Section 13
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Exit codes for ng-di-graph CLI
|
|
14
|
+
* Implements PRD Section 13 error handling requirements
|
|
15
|
+
*/
|
|
16
|
+
let ExitCodes = /* @__PURE__ */ function(ExitCodes$1) {
|
|
17
|
+
ExitCodes$1[ExitCodes$1["SUCCESS"] = 0] = "SUCCESS";
|
|
18
|
+
ExitCodes$1[ExitCodes$1["GENERAL_ERROR"] = 1] = "GENERAL_ERROR";
|
|
19
|
+
ExitCodes$1[ExitCodes$1["INVALID_ARGUMENTS"] = 2] = "INVALID_ARGUMENTS";
|
|
20
|
+
ExitCodes$1[ExitCodes$1["TSCONFIG_ERROR"] = 3] = "TSCONFIG_ERROR";
|
|
21
|
+
ExitCodes$1[ExitCodes$1["PARSING_ERROR"] = 4] = "PARSING_ERROR";
|
|
22
|
+
ExitCodes$1[ExitCodes$1["TYPE_RESOLUTION_ERROR"] = 5] = "TYPE_RESOLUTION_ERROR";
|
|
23
|
+
ExitCodes$1[ExitCodes$1["MEMORY_ERROR"] = 6] = "MEMORY_ERROR";
|
|
24
|
+
ExitCodes$1[ExitCodes$1["FILE_NOT_FOUND"] = 7] = "FILE_NOT_FOUND";
|
|
25
|
+
ExitCodes$1[ExitCodes$1["PERMISSION_ERROR"] = 8] = "PERMISSION_ERROR";
|
|
26
|
+
return ExitCodes$1;
|
|
27
|
+
}({});
|
|
28
|
+
/**
|
|
29
|
+
* Custom error class for ng-di-graph CLI
|
|
30
|
+
* Provides structured error information with context
|
|
31
|
+
*/
|
|
32
|
+
var CliError = class CliError extends Error {
|
|
33
|
+
constructor(message, code, filePath, context) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.filePath = filePath;
|
|
37
|
+
this.context = context;
|
|
38
|
+
this.name = "CliError";
|
|
39
|
+
Object.setPrototypeOf(this, CliError.prototype);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check if error is fatal (should exit immediately)
|
|
43
|
+
*/
|
|
44
|
+
isFatal() {
|
|
45
|
+
return [
|
|
46
|
+
"TSCONFIG_NOT_FOUND",
|
|
47
|
+
"TSCONFIG_INVALID",
|
|
48
|
+
"PROJECT_LOAD_FAILED",
|
|
49
|
+
"MEMORY_LIMIT_EXCEEDED",
|
|
50
|
+
"INTERNAL_ERROR",
|
|
51
|
+
"INVALID_ARGUMENTS",
|
|
52
|
+
"PERMISSION_DENIED",
|
|
53
|
+
"COMPILATION_ERROR"
|
|
54
|
+
].includes(this.code);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Check if error is recoverable (can continue processing)
|
|
58
|
+
*/
|
|
59
|
+
isRecoverable() {
|
|
60
|
+
return !this.isFatal();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* ErrorHandler - Static utility class for error handling
|
|
65
|
+
* Provides centralized error formatting and exit code management
|
|
66
|
+
*/
|
|
67
|
+
var ErrorHandler = class ErrorHandler {
|
|
68
|
+
/**
|
|
69
|
+
* Map error code to exit code
|
|
70
|
+
* @param error CliError instance or null
|
|
71
|
+
* @returns Exit code for process.exit()
|
|
72
|
+
*/
|
|
73
|
+
static classifyExitCode(error) {
|
|
74
|
+
if (!error) return ExitCodes.SUCCESS;
|
|
75
|
+
switch (error.code) {
|
|
76
|
+
case "TSCONFIG_NOT_FOUND":
|
|
77
|
+
case "TSCONFIG_INVALID":
|
|
78
|
+
case "PROJECT_LOAD_FAILED": return ExitCodes.TSCONFIG_ERROR;
|
|
79
|
+
case "FILE_PARSE_ERROR":
|
|
80
|
+
case "COMPILATION_ERROR": return ExitCodes.PARSING_ERROR;
|
|
81
|
+
case "TYPE_RESOLUTION_ERROR":
|
|
82
|
+
case "DEPENDENCY_NOT_FOUND": return ExitCodes.TYPE_RESOLUTION_ERROR;
|
|
83
|
+
case "MEMORY_LIMIT_EXCEEDED": return ExitCodes.MEMORY_ERROR;
|
|
84
|
+
case "FILE_NOT_FOUND": return ExitCodes.FILE_NOT_FOUND;
|
|
85
|
+
case "PERMISSION_DENIED": return ExitCodes.PERMISSION_ERROR;
|
|
86
|
+
case "INVALID_ARGUMENTS": return ExitCodes.INVALID_ARGUMENTS;
|
|
87
|
+
default: return ExitCodes.GENERAL_ERROR;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Format error for user-friendly display
|
|
92
|
+
* @param error CliError to format
|
|
93
|
+
* @param verbose Include stack traces and detailed info
|
|
94
|
+
* @returns Formatted error message string
|
|
95
|
+
*/
|
|
96
|
+
static formatError(error, verbose = false) {
|
|
97
|
+
const lines = [];
|
|
98
|
+
if (error.isFatal()) lines.push("❌ Fatal Error");
|
|
99
|
+
else if (error.code === "TYPE_RESOLUTION_ERROR") lines.push("⚠️ Type Resolution Warning");
|
|
100
|
+
else lines.push("⚠️ Warning");
|
|
101
|
+
lines.push("");
|
|
102
|
+
lines.push(`Message: ${error.message}`);
|
|
103
|
+
if (error.filePath) lines.push(`File: ${error.filePath}`);
|
|
104
|
+
lines.push(`Code: ${error.code}`);
|
|
105
|
+
if (error.context && Object.keys(error.context).length > 0) {
|
|
106
|
+
lines.push("");
|
|
107
|
+
lines.push("Context:");
|
|
108
|
+
for (const [key, value] of Object.entries(error.context)) lines.push(` ${key}: ${JSON.stringify(value)}`);
|
|
109
|
+
}
|
|
110
|
+
const guidance = ErrorHandler.getRecoveryGuidance(error);
|
|
111
|
+
if (guidance) {
|
|
112
|
+
lines.push("");
|
|
113
|
+
lines.push("💡 Suggestions:");
|
|
114
|
+
for (const line of guidance.split("\n")) if (line.trim()) lines.push(` • ${line.trim()}`);
|
|
115
|
+
}
|
|
116
|
+
if (verbose && error.stack) {
|
|
117
|
+
lines.push("");
|
|
118
|
+
lines.push("🔍 Stack Trace:");
|
|
119
|
+
lines.push(error.stack);
|
|
120
|
+
}
|
|
121
|
+
lines.push("");
|
|
122
|
+
lines.push("Run with --help for usage information");
|
|
123
|
+
lines.push("Use --verbose for detailed debugging information");
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Get actionable recovery guidance for error
|
|
128
|
+
* @param error CliError to provide guidance for
|
|
129
|
+
* @returns Multi-line guidance string
|
|
130
|
+
*/
|
|
131
|
+
static getRecoveryGuidance(error) {
|
|
132
|
+
switch (error.code) {
|
|
133
|
+
case "TSCONFIG_NOT_FOUND": return `Check that the file path is correct
|
|
134
|
+
Ensure the tsconfig.json file exists
|
|
135
|
+
Try using an absolute path instead of relative
|
|
136
|
+
Use --project flag to specify correct path`;
|
|
137
|
+
case "TSCONFIG_INVALID": return `Validate JSON syntax with a JSON validator
|
|
138
|
+
Check TypeScript compiler options are valid
|
|
139
|
+
Ensure all referenced files exist
|
|
140
|
+
Try with a minimal tsconfig.json first`;
|
|
141
|
+
case "PROJECT_LOAD_FAILED": return `Check TypeScript compilation errors
|
|
142
|
+
Ensure all dependencies are installed
|
|
143
|
+
Verify import paths are correct
|
|
144
|
+
Try cleaning node_modules and reinstalling`;
|
|
145
|
+
case "FILE_PARSE_ERROR": return `Check TypeScript syntax in the problematic file
|
|
146
|
+
Ensure all imports are properly resolved
|
|
147
|
+
Try excluding the file from tsconfig if not needed
|
|
148
|
+
Use --verbose to see detailed parsing errors`;
|
|
149
|
+
case "TYPE_RESOLUTION_ERROR": return `Consider adding explicit type annotations
|
|
150
|
+
Check import statements are correct
|
|
151
|
+
Verify the dependency is properly exported
|
|
152
|
+
Use 'any' type as temporary workaround if needed`;
|
|
153
|
+
case "DEPENDENCY_NOT_FOUND": return `Check import statements in ${error.filePath || "the file"}
|
|
154
|
+
Verify all dependencies are properly installed
|
|
155
|
+
Ensure the dependency is exported from its module
|
|
156
|
+
Consider using --verbose for detailed analysis`;
|
|
157
|
+
case "MEMORY_LIMIT_EXCEEDED": return `Try processing smaller portions of the codebase
|
|
158
|
+
Use --entry filtering to limit scope
|
|
159
|
+
Consider increasing available memory (NODE_OPTIONS="--max-old-space-size=4096")
|
|
160
|
+
Process files in batches rather than all at once`;
|
|
161
|
+
case "ANONYMOUS_CLASS_SKIPPED": return `Consider giving the class a name for better tracking
|
|
162
|
+
This is a non-fatal warning - processing will continue
|
|
163
|
+
Anonymous classes cannot be included in dependency graphs`;
|
|
164
|
+
case "OUTPUT_WRITE_ERROR": return `Check file permissions for the output location
|
|
165
|
+
Ensure the output directory exists
|
|
166
|
+
Try writing to a different location
|
|
167
|
+
Use stdout instead of file output as workaround`;
|
|
168
|
+
case "PERMISSION_DENIED": return `Check file and directory permissions
|
|
169
|
+
Try running with appropriate user privileges
|
|
170
|
+
Ensure you have read access to source files
|
|
171
|
+
Verify write access to output location`;
|
|
172
|
+
case "COMPILATION_ERROR": return `Fix TypeScript compilation errors
|
|
173
|
+
Check compiler options in tsconfig.json
|
|
174
|
+
Ensure all type definitions are available
|
|
175
|
+
Try running tsc directly to see detailed errors`;
|
|
176
|
+
case "INVALID_ARGUMENTS": return `Check CLI argument syntax
|
|
177
|
+
Review --help for valid options
|
|
178
|
+
Ensure all required arguments are provided
|
|
179
|
+
Verify argument values are in correct format`;
|
|
180
|
+
case "FILE_NOT_FOUND": return `Verify file path is correct
|
|
181
|
+
Check file exists in the specified location
|
|
182
|
+
Ensure file permissions allow reading
|
|
183
|
+
Try using absolute path instead of relative`;
|
|
184
|
+
default: return `Review the error message for specific details
|
|
185
|
+
Try running with --verbose for more information
|
|
186
|
+
Check project configuration and dependencies
|
|
187
|
+
Consider filing an issue if the problem persists`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Handle error and exit process (never returns)
|
|
192
|
+
* @param error CliError to handle
|
|
193
|
+
* @param verbose Include verbose error information
|
|
194
|
+
*/
|
|
195
|
+
static handleError(error, verbose = false) {
|
|
196
|
+
const formattedError = ErrorHandler.formatError(error, verbose);
|
|
197
|
+
console.error(formattedError);
|
|
198
|
+
const exitCode = ErrorHandler.classifyExitCode(error);
|
|
199
|
+
process.exit(exitCode);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Factory method to create CliError
|
|
203
|
+
* @param message Error message
|
|
204
|
+
* @param code Error code
|
|
205
|
+
* @param filePath Optional file path
|
|
206
|
+
* @param context Optional context object
|
|
207
|
+
* @returns CliError instance
|
|
208
|
+
*/
|
|
209
|
+
static createError(message, code, filePath, context) {
|
|
210
|
+
return new CliError(message, code, filePath, context);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Output warning without exiting
|
|
214
|
+
* @param message Warning message
|
|
215
|
+
* @param filePath Optional file path
|
|
216
|
+
*/
|
|
217
|
+
static warn(message, filePath) {
|
|
218
|
+
console.warn(`⚠️ Warning: ${message}${filePath ? ` (${filePath})` : ""}`);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/core/logger.ts
|
|
224
|
+
/**
|
|
225
|
+
* Logger Infrastructure for ng-di-graph
|
|
226
|
+
* Provides structured logging with categories, performance timing, and memory tracking
|
|
227
|
+
* Implements FR-12: Verbose mode support
|
|
228
|
+
*
|
|
229
|
+
* Usage:
|
|
230
|
+
* ```typescript
|
|
231
|
+
* const logger = createLogger(verbose);
|
|
232
|
+
* logger?.info(LogCategory.FILE_PROCESSING, 'Processing file', { filePath: '/test.ts' });
|
|
233
|
+
* logger?.time('operation');
|
|
234
|
+
* // ... operation ...
|
|
235
|
+
* const elapsed = logger?.timeEnd('operation');
|
|
236
|
+
* const stats = logger?.getStats();
|
|
237
|
+
* ```
|
|
238
|
+
*/
|
|
239
|
+
/**
|
|
240
|
+
* Log category enumeration for structured logging
|
|
241
|
+
* Each category represents a distinct phase of ng-di-graph processing
|
|
242
|
+
*/
|
|
243
|
+
let LogCategory = /* @__PURE__ */ function(LogCategory$1) {
|
|
244
|
+
LogCategory$1["FILE_PROCESSING"] = "file-processing";
|
|
245
|
+
LogCategory$1["AST_ANALYSIS"] = "ast-analysis";
|
|
246
|
+
LogCategory$1["TYPE_RESOLUTION"] = "type-resolution";
|
|
247
|
+
LogCategory$1["GRAPH_CONSTRUCTION"] = "graph-construction";
|
|
248
|
+
LogCategory$1["FILTERING"] = "filtering";
|
|
249
|
+
LogCategory$1["ERROR_RECOVERY"] = "error-recovery";
|
|
250
|
+
LogCategory$1["PERFORMANCE"] = "performance";
|
|
251
|
+
return LogCategory$1;
|
|
252
|
+
}({});
|
|
253
|
+
/**
|
|
254
|
+
* Factory function for Logger creation
|
|
255
|
+
* Returns undefined when verbose is false (no-op pattern)
|
|
256
|
+
*
|
|
257
|
+
* @param verbose - Enable verbose logging
|
|
258
|
+
* @returns Logger instance or undefined
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* ```typescript
|
|
262
|
+
* const logger = createLogger(cliOptions.verbose);
|
|
263
|
+
* logger?.info(LogCategory.FILE_PROCESSING, 'Starting processing');
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
function createLogger(verbose) {
|
|
267
|
+
return verbose ? new LoggerImpl() : void 0;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Logger implementation (internal)
|
|
271
|
+
* Provides structured logging with minimal performance overhead
|
|
272
|
+
*/
|
|
273
|
+
var LoggerImpl = class {
|
|
274
|
+
constructor() {
|
|
275
|
+
this._timers = /* @__PURE__ */ new Map();
|
|
276
|
+
this._stats = {
|
|
277
|
+
totalLogs: 0,
|
|
278
|
+
categoryCounts: {},
|
|
279
|
+
performanceMetrics: {
|
|
280
|
+
totalTime: 0,
|
|
281
|
+
fileProcessingTime: 0,
|
|
282
|
+
graphBuildingTime: 0,
|
|
283
|
+
outputGenerationTime: 0
|
|
284
|
+
},
|
|
285
|
+
memoryUsage: {
|
|
286
|
+
peakUsage: 0,
|
|
287
|
+
currentUsage: 0
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
this._peakMemory = 0;
|
|
291
|
+
}
|
|
292
|
+
debug(category, message, context) {
|
|
293
|
+
this._log("DEBUG", category, message, context);
|
|
294
|
+
}
|
|
295
|
+
info(category, message, context) {
|
|
296
|
+
this._log("INFO", category, message, context);
|
|
297
|
+
}
|
|
298
|
+
warn(category, message, context) {
|
|
299
|
+
this._log("WARN", category, message, context);
|
|
300
|
+
}
|
|
301
|
+
error(category, message, context) {
|
|
302
|
+
this._log("ERROR", category, message, context);
|
|
303
|
+
}
|
|
304
|
+
time(label) {
|
|
305
|
+
this._timers.set(label, { startTime: performance.now() });
|
|
306
|
+
}
|
|
307
|
+
timeEnd(label) {
|
|
308
|
+
const timer = this._timers.get(label);
|
|
309
|
+
if (!timer) throw new Error(`Timer '${label}' does not exist`);
|
|
310
|
+
const elapsed = performance.now() - timer.startTime;
|
|
311
|
+
this._timers.delete(label);
|
|
312
|
+
return elapsed;
|
|
313
|
+
}
|
|
314
|
+
getStats() {
|
|
315
|
+
const memUsage = process.memoryUsage();
|
|
316
|
+
this._stats.memoryUsage.currentUsage = memUsage.heapUsed;
|
|
317
|
+
return { ...this._stats };
|
|
318
|
+
}
|
|
319
|
+
_log(level, category, message, context) {
|
|
320
|
+
this._stats.totalLogs++;
|
|
321
|
+
this._stats.categoryCounts[category] = (this._stats.categoryCounts[category] || 0) + 1;
|
|
322
|
+
const memUsage = process.memoryUsage().heapUsed;
|
|
323
|
+
if (memUsage > this._peakMemory) {
|
|
324
|
+
this._peakMemory = memUsage;
|
|
325
|
+
this._stats.memoryUsage.peakUsage = memUsage;
|
|
326
|
+
}
|
|
327
|
+
const formattedLog = this._formatLog(level, category, message, context);
|
|
328
|
+
console.error(formattedLog);
|
|
329
|
+
}
|
|
330
|
+
_formatLog(level, category, message, context) {
|
|
331
|
+
return `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] [${category}] ${message}${context ? ` ${JSON.stringify(context)}` : ""}`;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/core/graph-builder.ts
|
|
337
|
+
/**
|
|
338
|
+
* Validates input parameters for the buildGraph function
|
|
339
|
+
* @param parsedClasses Array to validate
|
|
340
|
+
* @throws Error if validation fails
|
|
341
|
+
*/
|
|
342
|
+
function validateInput(parsedClasses) {
|
|
343
|
+
if (parsedClasses == null) throw new Error("parsedClasses parameter cannot be null or undefined");
|
|
344
|
+
for (let i = 0; i < parsedClasses.length; i++) {
|
|
345
|
+
const parsedClass = parsedClasses[i];
|
|
346
|
+
if (typeof parsedClass.name !== "string") throw new Error("ParsedClass must have a valid name property");
|
|
347
|
+
if (parsedClass.name.trim() === "") throw new Error("ParsedClass name cannot be empty");
|
|
348
|
+
if (typeof parsedClass.kind !== "string") throw new Error("ParsedClass must have a valid kind property");
|
|
349
|
+
if (parsedClass.dependencies == null) throw new Error("ParsedClass must have a dependencies array");
|
|
350
|
+
if (!Array.isArray(parsedClass.dependencies)) throw new Error("ParsedClass dependencies must be an array");
|
|
351
|
+
for (const dependency of parsedClass.dependencies) if (typeof dependency.token !== "string") throw new Error("ParsedDependency must have a valid token property");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Detects circular dependencies using DFS (Depth-First Search)
|
|
356
|
+
* @param edges Array of edges representing the dependency graph
|
|
357
|
+
* @param nodes Array of nodes in the graph
|
|
358
|
+
* @returns Array of circular dependency paths and edges marked as circular
|
|
359
|
+
*/
|
|
360
|
+
function detectCircularDependencies(edges, nodes) {
|
|
361
|
+
const circularDependencies = [];
|
|
362
|
+
const circularEdges = /* @__PURE__ */ new Set();
|
|
363
|
+
const adjacencyList = /* @__PURE__ */ new Map();
|
|
364
|
+
for (const node of nodes) adjacencyList.set(node.id, []);
|
|
365
|
+
for (const edge of edges) {
|
|
366
|
+
if (!adjacencyList.has(edge.from)) adjacencyList.set(edge.from, []);
|
|
367
|
+
const neighbors = adjacencyList.get(edge.from);
|
|
368
|
+
if (neighbors) neighbors.push(edge.to);
|
|
369
|
+
}
|
|
370
|
+
const recursionStack = /* @__PURE__ */ new Set();
|
|
371
|
+
const processedNodes = /* @__PURE__ */ new Set();
|
|
372
|
+
/**
|
|
373
|
+
* DFS helper function to detect cycles
|
|
374
|
+
*/
|
|
375
|
+
function dfs(node, path) {
|
|
376
|
+
if (processedNodes.has(node)) return;
|
|
377
|
+
if (recursionStack.has(node)) {
|
|
378
|
+
const cycleStartIndex = path.indexOf(node);
|
|
379
|
+
const cyclePath = [...path.slice(cycleStartIndex), node];
|
|
380
|
+
circularDependencies.push(cyclePath);
|
|
381
|
+
for (let i = 0; i < cyclePath.length - 1; i++) {
|
|
382
|
+
const edgeKey = `${cyclePath[i]}->${cyclePath[i + 1]}`;
|
|
383
|
+
circularEdges.add(edgeKey);
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
recursionStack.add(node);
|
|
388
|
+
const newPath = [...path, node];
|
|
389
|
+
const neighbors = adjacencyList.get(node) || [];
|
|
390
|
+
for (const neighbor of neighbors) dfs(neighbor, newPath);
|
|
391
|
+
recursionStack.delete(node);
|
|
392
|
+
processedNodes.add(node);
|
|
393
|
+
}
|
|
394
|
+
for (const node of nodes) if (!processedNodes.has(node.id)) dfs(node.id, []);
|
|
395
|
+
return {
|
|
396
|
+
circularDependencies,
|
|
397
|
+
circularEdges
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Builds a dependency graph from parsed Angular classes
|
|
402
|
+
* @param parsedClasses Array of parsed classes with their dependencies
|
|
403
|
+
* @param logger Optional Logger instance for verbose mode logging
|
|
404
|
+
* @returns Graph containing nodes and edges representing the dependency relationships
|
|
405
|
+
*/
|
|
406
|
+
function buildGraph(parsedClasses, logger) {
|
|
407
|
+
logger?.time("buildGraph");
|
|
408
|
+
logger?.info(LogCategory.GRAPH_CONSTRUCTION, "Starting graph construction", { classCount: parsedClasses.length });
|
|
409
|
+
validateInput(parsedClasses);
|
|
410
|
+
const nodeMap = /* @__PURE__ */ new Map();
|
|
411
|
+
const edges = [];
|
|
412
|
+
for (const parsedClass of parsedClasses) if (!nodeMap.has(parsedClass.name)) nodeMap.set(parsedClass.name, {
|
|
413
|
+
id: parsedClass.name,
|
|
414
|
+
kind: parsedClass.kind
|
|
415
|
+
});
|
|
416
|
+
logger?.info(LogCategory.GRAPH_CONSTRUCTION, `Created ${nodeMap.size} nodes`, { nodeCount: nodeMap.size });
|
|
417
|
+
let unknownNodeCount = 0;
|
|
418
|
+
for (const parsedClass of parsedClasses) for (const dependency of parsedClass.dependencies) {
|
|
419
|
+
if (!nodeMap.has(dependency.token)) {
|
|
420
|
+
nodeMap.set(dependency.token, {
|
|
421
|
+
id: dependency.token,
|
|
422
|
+
kind: "unknown"
|
|
423
|
+
});
|
|
424
|
+
unknownNodeCount++;
|
|
425
|
+
logger?.warn(LogCategory.GRAPH_CONSTRUCTION, `Created unknown node: ${dependency.token}`, {
|
|
426
|
+
nodeId: dependency.token,
|
|
427
|
+
referencedBy: parsedClass.name
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
const edge = {
|
|
431
|
+
from: parsedClass.name,
|
|
432
|
+
to: dependency.token
|
|
433
|
+
};
|
|
434
|
+
if (dependency.flags) edge.flags = dependency.flags;
|
|
435
|
+
edges.push(edge);
|
|
436
|
+
}
|
|
437
|
+
logger?.info(LogCategory.GRAPH_CONSTRUCTION, `Created ${edges.length} edges`, {
|
|
438
|
+
edgeCount: edges.length,
|
|
439
|
+
unknownNodeCount
|
|
440
|
+
});
|
|
441
|
+
const nodes = Array.from(nodeMap.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
442
|
+
edges.sort((a, b) => {
|
|
443
|
+
const fromCompare = a.from.localeCompare(b.from);
|
|
444
|
+
if (fromCompare !== 0) return fromCompare;
|
|
445
|
+
return a.to.localeCompare(b.to);
|
|
446
|
+
});
|
|
447
|
+
logger?.time("circularDetection");
|
|
448
|
+
const { circularDependencies, circularEdges } = detectCircularDependencies(edges, nodes);
|
|
449
|
+
const circularDetectionTime = logger?.timeEnd("circularDetection");
|
|
450
|
+
if (circularDependencies.length > 0) logger?.warn(LogCategory.GRAPH_CONSTRUCTION, `Circular dependencies detected: ${circularDependencies.length}`, {
|
|
451
|
+
circularCount: circularDependencies.length,
|
|
452
|
+
cycles: circularDependencies,
|
|
453
|
+
detectionTime: circularDetectionTime
|
|
454
|
+
});
|
|
455
|
+
else logger?.info(LogCategory.GRAPH_CONSTRUCTION, "No circular dependencies detected", { detectionTime: circularDetectionTime });
|
|
456
|
+
for (const edge of edges) {
|
|
457
|
+
const edgeKey = `${edge.from}->${edge.to}`;
|
|
458
|
+
if (circularEdges.has(edgeKey)) edge.isCircular = true;
|
|
459
|
+
}
|
|
460
|
+
const duration = logger?.timeEnd("buildGraph");
|
|
461
|
+
logger?.info(LogCategory.GRAPH_CONSTRUCTION, "Graph construction complete", {
|
|
462
|
+
duration,
|
|
463
|
+
nodeCount: nodes.length,
|
|
464
|
+
edgeCount: edges.length,
|
|
465
|
+
circularCount: circularDependencies.length
|
|
466
|
+
});
|
|
467
|
+
return {
|
|
468
|
+
nodes,
|
|
469
|
+
edges,
|
|
470
|
+
circularDependencies
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region src/core/graph-filter.ts
|
|
476
|
+
/**
|
|
477
|
+
* Helper function to validate entry points and perform traversal
|
|
478
|
+
* @param entryPoints Array of entry point names to validate and traverse from
|
|
479
|
+
* @param graph The graph containing all nodes
|
|
480
|
+
* @param adjacencyList The adjacency list for traversal
|
|
481
|
+
* @param resultSet The set to collect traversal results
|
|
482
|
+
* @param options CLI options for verbose output
|
|
483
|
+
*/
|
|
484
|
+
function validateAndTraverseEntryPoints(entryPoints, graph, adjacencyList, resultSet, options) {
|
|
485
|
+
for (const entryPoint of entryPoints) if (graph.nodes.some((n) => n.id === entryPoint)) traverseFromEntry(entryPoint, adjacencyList, resultSet);
|
|
486
|
+
else if (options.verbose) console.warn(`Entry point '${entryPoint}' not found in graph`);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Filters a graph based on entry points and traversal direction
|
|
490
|
+
* @param graph The graph to filter
|
|
491
|
+
* @param options CLI options containing entry points and direction
|
|
492
|
+
* @returns Filtered graph containing only nodes reachable from entry points
|
|
493
|
+
*/
|
|
494
|
+
function filterGraph(graph, options) {
|
|
495
|
+
if (!options.entry || options.entry.length === 0) return graph;
|
|
496
|
+
const includedNodeIds = /* @__PURE__ */ new Set();
|
|
497
|
+
if (options.direction === "both") {
|
|
498
|
+
const upstreamAdjacencyList = buildAdjacencyList(graph, "upstream");
|
|
499
|
+
const upstreamNodes = /* @__PURE__ */ new Set();
|
|
500
|
+
validateAndTraverseEntryPoints(options.entry, graph, upstreamAdjacencyList, upstreamNodes, options);
|
|
501
|
+
const downstreamAdjacencyList = buildAdjacencyList(graph, "downstream");
|
|
502
|
+
const downstreamNodes = /* @__PURE__ */ new Set();
|
|
503
|
+
validateAndTraverseEntryPoints(options.entry, graph, downstreamAdjacencyList, downstreamNodes, options);
|
|
504
|
+
const combinedNodes = new Set([...upstreamNodes, ...downstreamNodes]);
|
|
505
|
+
for (const nodeId of combinedNodes) includedNodeIds.add(nodeId);
|
|
506
|
+
} else {
|
|
507
|
+
const adjacencyList = buildAdjacencyList(graph, options.direction);
|
|
508
|
+
validateAndTraverseEntryPoints(options.entry, graph, adjacencyList, includedNodeIds, options);
|
|
509
|
+
}
|
|
510
|
+
const filteredNodes = graph.nodes.filter((node) => includedNodeIds.has(node.id));
|
|
511
|
+
const filteredEdges = graph.edges.filter((edge) => includedNodeIds.has(edge.from) && includedNodeIds.has(edge.to));
|
|
512
|
+
const filteredCircularDeps = graph.circularDependencies.filter((cycle) => {
|
|
513
|
+
if (cycle.length < 2) return false;
|
|
514
|
+
const isSelfLoop = cycle.length === 2 && cycle[0] === cycle[1];
|
|
515
|
+
const isProperCycleWithClosing = cycle.length >= 3 && cycle[0] === cycle[cycle.length - 1];
|
|
516
|
+
const isProperCycleWithoutClosing = cycle.length >= 3 && cycle[0] !== cycle[cycle.length - 1];
|
|
517
|
+
if (!isSelfLoop && !isProperCycleWithClosing && !isProperCycleWithoutClosing) return false;
|
|
518
|
+
if (!cycle.every((nodeId) => includedNodeIds.has(nodeId))) return false;
|
|
519
|
+
const edgesToCheck = isProperCycleWithClosing ? cycle.length - 1 : cycle.length;
|
|
520
|
+
for (let i = 0; i < edgesToCheck; i++) {
|
|
521
|
+
const fromNode = cycle[i];
|
|
522
|
+
const toNode = isProperCycleWithClosing ? cycle[i + 1] : cycle[(i + 1) % cycle.length];
|
|
523
|
+
if (!graph.edges.some((edge) => edge.from === fromNode && edge.to === toNode)) return false;
|
|
524
|
+
}
|
|
525
|
+
return true;
|
|
526
|
+
});
|
|
527
|
+
if (options.verbose) {
|
|
528
|
+
console.log(`Filtered graph: ${filteredNodes.length} nodes, ${filteredEdges.length} edges`);
|
|
529
|
+
console.log(`Entry points: ${options.entry.join(", ")}`);
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
nodes: filteredNodes,
|
|
533
|
+
edges: filteredEdges,
|
|
534
|
+
circularDependencies: filteredCircularDeps
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Traverses the graph from a starting node using DFS
|
|
539
|
+
* @param startNode The node to start traversal from
|
|
540
|
+
* @param adjacencyList The adjacency list representation of the graph
|
|
541
|
+
* @param visited Set to track visited nodes
|
|
542
|
+
*/
|
|
543
|
+
function traverseFromEntry(startNode, adjacencyList, visited) {
|
|
544
|
+
const stack = [startNode];
|
|
545
|
+
while (stack.length > 0) {
|
|
546
|
+
const currentNode = stack.pop();
|
|
547
|
+
if (!currentNode) break;
|
|
548
|
+
if (visited.has(currentNode)) continue;
|
|
549
|
+
visited.add(currentNode);
|
|
550
|
+
const neighbors = adjacencyList.get(currentNode) || [];
|
|
551
|
+
for (const neighbor of neighbors) if (!visited.has(neighbor)) stack.push(neighbor);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Builds an adjacency list from the graph based on direction
|
|
556
|
+
* @param graph The graph to build adjacency list from
|
|
557
|
+
* @param direction The direction to follow edges
|
|
558
|
+
* @returns Map representing adjacency list
|
|
559
|
+
*/
|
|
560
|
+
function buildAdjacencyList(graph, direction) {
|
|
561
|
+
const adjacencyList = /* @__PURE__ */ new Map();
|
|
562
|
+
for (const node of graph.nodes) adjacencyList.set(node.id, []);
|
|
563
|
+
for (const edge of graph.edges) if (direction === "downstream") {
|
|
564
|
+
const neighbors = adjacencyList.get(edge.from) || [];
|
|
565
|
+
neighbors.push(edge.to);
|
|
566
|
+
adjacencyList.set(edge.from, neighbors);
|
|
567
|
+
} else if (direction === "upstream") {
|
|
568
|
+
const neighbors = adjacencyList.get(edge.to) || [];
|
|
569
|
+
neighbors.push(edge.from);
|
|
570
|
+
adjacencyList.set(edge.to, neighbors);
|
|
571
|
+
}
|
|
572
|
+
return adjacencyList;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
//#endregion
|
|
576
|
+
//#region src/core/output-handler.ts
|
|
577
|
+
/**
|
|
578
|
+
* Handles output writing to stdout or files
|
|
579
|
+
* Supports directory creation and error handling
|
|
580
|
+
*/
|
|
581
|
+
var OutputHandler = class {
|
|
582
|
+
/**
|
|
583
|
+
* Write output content to stdout or file
|
|
584
|
+
* @param content The content to write
|
|
585
|
+
* @param filePath Optional file path. If not provided, writes to stdout
|
|
586
|
+
*/
|
|
587
|
+
async writeOutput(content, filePath) {
|
|
588
|
+
if (!filePath) {
|
|
589
|
+
process.stdout.write(content);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
const dir = (0, node_path.dirname)(filePath);
|
|
594
|
+
if (!(0, node_fs.existsSync)(dir)) (0, node_fs.mkdirSync)(dir, { recursive: true });
|
|
595
|
+
(0, node_fs.writeFileSync)(filePath, content, "utf-8");
|
|
596
|
+
} catch (error) {
|
|
597
|
+
throw new Error(`Failed to write output file: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
//#endregion
|
|
603
|
+
//#region src/core/parser.ts
|
|
604
|
+
/**
|
|
605
|
+
* AngularParser - Core TypeScript AST parsing using ts-morph
|
|
606
|
+
* Implements FR-01: ts-morph project loading with comprehensive error handling
|
|
607
|
+
* Implements FR-02: Decorated class collection
|
|
608
|
+
* Implements FR-03: Constructor token resolution
|
|
609
|
+
*/
|
|
610
|
+
const GLOBAL_WARNING_KEYS = /* @__PURE__ */ new Set();
|
|
611
|
+
var AngularParser = class {
|
|
612
|
+
constructor(_options, _logger) {
|
|
613
|
+
this._options = _options;
|
|
614
|
+
this._logger = _logger;
|
|
615
|
+
this._typeResolutionCache = /* @__PURE__ */ new Map();
|
|
616
|
+
this._circularTypeRefs = /* @__PURE__ */ new Set();
|
|
617
|
+
this._structuredWarnings = {
|
|
618
|
+
categories: {
|
|
619
|
+
typeResolution: [],
|
|
620
|
+
skippedTypes: [],
|
|
621
|
+
unresolvedImports: [],
|
|
622
|
+
circularReferences: [],
|
|
623
|
+
performance: []
|
|
624
|
+
},
|
|
625
|
+
totalCount: 0
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Reset global warning deduplication state (useful for testing)
|
|
630
|
+
*/
|
|
631
|
+
static resetWarningState() {
|
|
632
|
+
GLOBAL_WARNING_KEYS.clear();
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Get structured warnings for analysis (Task 3.3)
|
|
636
|
+
* Returns a copy of the structured warnings collected during parsing
|
|
637
|
+
* @returns StructuredWarnings object with categorized warnings
|
|
638
|
+
*/
|
|
639
|
+
getStructuredWarnings() {
|
|
640
|
+
return {
|
|
641
|
+
categories: {
|
|
642
|
+
typeResolution: [...this._structuredWarnings.categories.typeResolution],
|
|
643
|
+
skippedTypes: [...this._structuredWarnings.categories.skippedTypes],
|
|
644
|
+
unresolvedImports: [...this._structuredWarnings.categories.unresolvedImports],
|
|
645
|
+
circularReferences: [...this._structuredWarnings.categories.circularReferences],
|
|
646
|
+
performance: [...this._structuredWarnings.categories.performance]
|
|
647
|
+
},
|
|
648
|
+
totalCount: this._structuredWarnings.totalCount
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Add structured warning to collection (Task 3.3)
|
|
653
|
+
* Includes global deduplication for both structured warnings and console output
|
|
654
|
+
* @param category Warning category
|
|
655
|
+
* @param warning Warning object
|
|
656
|
+
*/
|
|
657
|
+
addStructuredWarning(category, warning) {
|
|
658
|
+
const warnKey = `${category}_${warning.type}_${warning.file}_${warning.message}`;
|
|
659
|
+
if (!GLOBAL_WARNING_KEYS.has(warnKey)) {
|
|
660
|
+
this._structuredWarnings.categories[category].push(warning);
|
|
661
|
+
this._structuredWarnings.totalCount++;
|
|
662
|
+
const location = warning.line ? `${warning.file}:${warning.line}:${warning.column}` : warning.file;
|
|
663
|
+
console.warn(`[${warning.severity.toUpperCase()}] ${warning.message} (${location})`);
|
|
664
|
+
if (warning.suggestion && this._options.verbose) console.warn(` Suggestion: ${warning.suggestion}`);
|
|
665
|
+
GLOBAL_WARNING_KEYS.add(warnKey);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Load TypeScript project using ts-morph
|
|
670
|
+
* Implements FR-01 with error handling from PRD Section 13
|
|
671
|
+
*/
|
|
672
|
+
loadProject() {
|
|
673
|
+
if (!(0, node_fs.existsSync)(this._options.project)) throw ErrorHandler.createError(`tsconfig.json not found at: ${this._options.project}`, "TSCONFIG_NOT_FOUND", this._options.project);
|
|
674
|
+
try {
|
|
675
|
+
try {
|
|
676
|
+
const configContent = (0, node_fs.readFileSync)(this._options.project, "utf8");
|
|
677
|
+
JSON.parse(configContent);
|
|
678
|
+
} catch (jsonError) {
|
|
679
|
+
const errorMessage = jsonError instanceof Error ? jsonError.message : String(jsonError);
|
|
680
|
+
throw ErrorHandler.createError(`Invalid tsconfig.json: ${errorMessage}`, "TSCONFIG_INVALID", this._options.project);
|
|
681
|
+
}
|
|
682
|
+
this._project = new ts_morph.Project({ tsConfigFilePath: this._options.project });
|
|
683
|
+
if (!this._project) throw ErrorHandler.createError("Failed to load TypeScript project", "PROJECT_LOAD_FAILED", this._options.project);
|
|
684
|
+
this._project.getSourceFiles();
|
|
685
|
+
const diagnostics = this._project.getProgram().getConfigFileParsingDiagnostics();
|
|
686
|
+
if (diagnostics.length > 0) {
|
|
687
|
+
const message = diagnostics[0].getMessageText();
|
|
688
|
+
throw ErrorHandler.createError(`TypeScript configuration error: ${message}`, "PROJECT_LOAD_FAILED", this._options.project, { diagnosticCount: diagnostics.length });
|
|
689
|
+
}
|
|
690
|
+
} catch (error) {
|
|
691
|
+
if (error instanceof CliError) throw error;
|
|
692
|
+
if (error instanceof Error) {
|
|
693
|
+
if (error.message.includes("JSON") || error.message.includes("Unexpected token") || error.message.includes("expected")) throw ErrorHandler.createError(`Invalid tsconfig.json: ${error.message}`, "TSCONFIG_INVALID", this._options.project);
|
|
694
|
+
if (error.message.includes("TypeScript") || error.message.includes("Compiler option")) throw ErrorHandler.createError(`TypeScript compilation failed: ${error.message}`, "COMPILATION_ERROR", this._options.project);
|
|
695
|
+
throw ErrorHandler.createError(`Failed to load TypeScript project: ${error.message}`, "PROJECT_LOAD_FAILED", this._options.project);
|
|
696
|
+
}
|
|
697
|
+
throw ErrorHandler.createError("Failed to load TypeScript project due to unknown error", "PROJECT_LOAD_FAILED", this._options.project);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Get the loaded ts-morph Project instance
|
|
702
|
+
* @returns Project instance
|
|
703
|
+
* @throws Error if project not loaded
|
|
704
|
+
*/
|
|
705
|
+
getProject() {
|
|
706
|
+
if (!this._project) throw new Error("Project not loaded. Call loadProject() first.");
|
|
707
|
+
return this._project;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Parse decorated classes from the loaded project
|
|
711
|
+
* Auto-loads project if not already loaded
|
|
712
|
+
* @returns Promise of parsed class information
|
|
713
|
+
*/
|
|
714
|
+
async parseClasses() {
|
|
715
|
+
if (!this._project) this.loadProject();
|
|
716
|
+
return this.findDecoratedClasses();
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Find all classes decorated with @Injectable, @Component, or @Directive
|
|
720
|
+
* Implements FR-02: Decorated Class Collection
|
|
721
|
+
* @returns Promise<ParsedClass[]> List of decorated classes
|
|
722
|
+
*/
|
|
723
|
+
async findDecoratedClasses() {
|
|
724
|
+
if (!this._project) this.loadProject();
|
|
725
|
+
if (!this._project) throw ErrorHandler.createError("Failed to load TypeScript project", "PROJECT_LOAD_FAILED", this._options.project);
|
|
726
|
+
const decoratedClasses = [];
|
|
727
|
+
const sourceFiles = this._project.getSourceFiles();
|
|
728
|
+
let processedFiles = 0;
|
|
729
|
+
let skippedFiles = 0;
|
|
730
|
+
this._circularTypeRefs.clear();
|
|
731
|
+
this._logger?.time("findDecoratedClasses");
|
|
732
|
+
this._logger?.info(LogCategory.FILE_PROCESSING, "Starting file processing", { fileCount: sourceFiles.length });
|
|
733
|
+
if (this._options.verbose) console.log(`Processing ${sourceFiles.length} source files`);
|
|
734
|
+
for (const sourceFile of sourceFiles) {
|
|
735
|
+
const filePath = sourceFile.getFilePath();
|
|
736
|
+
try {
|
|
737
|
+
if (this._options.verbose) console.log(`🔍 Parsing file: ${filePath}`);
|
|
738
|
+
this._logger?.debug(LogCategory.FILE_PROCESSING, "Processing file", { filePath });
|
|
739
|
+
const classes = sourceFile.getClasses();
|
|
740
|
+
if (this._options.verbose) console.log(`File: ${filePath}, Classes: ${classes.length}`);
|
|
741
|
+
this._logger?.debug(LogCategory.AST_ANALYSIS, "Analyzing classes in file", {
|
|
742
|
+
filePath,
|
|
743
|
+
classCount: classes.length
|
|
744
|
+
});
|
|
745
|
+
for (const classDeclaration of classes) {
|
|
746
|
+
const parsedClass = this.parseClassDeclaration(classDeclaration);
|
|
747
|
+
if (parsedClass) {
|
|
748
|
+
decoratedClasses.push(parsedClass);
|
|
749
|
+
if (this._options.verbose) console.log(`Found decorated class: ${parsedClass.name} (${parsedClass.kind})`);
|
|
750
|
+
this._logger?.info(LogCategory.AST_ANALYSIS, "Found decorated class", {
|
|
751
|
+
className: parsedClass.name,
|
|
752
|
+
kind: parsedClass.kind,
|
|
753
|
+
filePath
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
this.detectAnonymousClasses(sourceFile);
|
|
758
|
+
processedFiles++;
|
|
759
|
+
} catch (error) {
|
|
760
|
+
skippedFiles++;
|
|
761
|
+
if (error instanceof CliError) {
|
|
762
|
+
if (!error.isFatal()) {
|
|
763
|
+
ErrorHandler.warn(error.message, filePath);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
ErrorHandler.warn(`Failed to parse file (skipping): ${error instanceof Error ? error.message : "Unknown error"}`, filePath);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (this._options.verbose) console.log(`✅ Processed ${processedFiles} files, skipped ${skippedFiles} files`);
|
|
772
|
+
const elapsed = this._logger?.timeEnd("findDecoratedClasses") || 0;
|
|
773
|
+
this._logger?.info(LogCategory.PERFORMANCE, "File processing complete", {
|
|
774
|
+
totalClasses: decoratedClasses.length,
|
|
775
|
+
processedFiles,
|
|
776
|
+
skippedFiles,
|
|
777
|
+
timing: elapsed
|
|
778
|
+
});
|
|
779
|
+
if (decoratedClasses.length === 0) ErrorHandler.warn("No decorated classes found in the project");
|
|
780
|
+
return decoratedClasses;
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Parse a single class declaration for Angular decorators
|
|
784
|
+
* @param classDeclaration ts-morph ClassDeclaration
|
|
785
|
+
* @returns ParsedClass if decorated with Angular decorator, null otherwise
|
|
786
|
+
*/
|
|
787
|
+
parseClassDeclaration(classDeclaration) {
|
|
788
|
+
const className = classDeclaration.getName();
|
|
789
|
+
if (!className) {
|
|
790
|
+
console.warn("Warning: Skipping anonymous class - classes must be named for dependency injection analysis");
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
const decorators = classDeclaration.getDecorators();
|
|
794
|
+
if (this._options.verbose) {
|
|
795
|
+
const decoratorNames = decorators.map((d) => this.getDecoratorName(d)).join(", ");
|
|
796
|
+
console.log(`Class: ${className}, Decorators: ${decorators.length} [${decoratorNames}]`);
|
|
797
|
+
}
|
|
798
|
+
const angularDecorator = this.findAngularDecorator(decorators);
|
|
799
|
+
if (!angularDecorator) {
|
|
800
|
+
if (this._options.verbose && decorators.length > 0) console.log(` No Angular decorator found for ${className}`);
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
name: className,
|
|
805
|
+
kind: this.determineNodeKind(angularDecorator),
|
|
806
|
+
filePath: classDeclaration.getSourceFile().getFilePath(),
|
|
807
|
+
dependencies: this.extractConstructorDependencies(classDeclaration)
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Find Angular decorator (@Injectable, @Component, @Directive) from list of decorators
|
|
812
|
+
* @param decorators Array of decorators from ts-morph
|
|
813
|
+
* @returns Angular decorator if found, null otherwise
|
|
814
|
+
*/
|
|
815
|
+
findAngularDecorator(decorators) {
|
|
816
|
+
for (const decorator of decorators) {
|
|
817
|
+
const decoratorName = this.getDecoratorName(decorator);
|
|
818
|
+
if (decoratorName === "Injectable" || decoratorName === "Component" || decoratorName === "Directive") return decorator;
|
|
819
|
+
}
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Extract decorator name from ts-morph Decorator
|
|
824
|
+
* Handles various import patterns and aliases
|
|
825
|
+
* @param decorator ts-morph Decorator
|
|
826
|
+
* @returns Decorator name string
|
|
827
|
+
*/
|
|
828
|
+
getDecoratorName(decorator) {
|
|
829
|
+
const callExpression = decorator.getCallExpression();
|
|
830
|
+
if (!callExpression) return "";
|
|
831
|
+
const expression = callExpression.getExpression();
|
|
832
|
+
if (expression.getKind() === ts_morph.SyntaxKind.Identifier) {
|
|
833
|
+
const decoratorName = expression.asKindOrThrow(ts_morph.SyntaxKind.Identifier).getText();
|
|
834
|
+
return this.resolveDecoratorAlias(decorator.getSourceFile(), decoratorName) || decoratorName;
|
|
835
|
+
}
|
|
836
|
+
return "";
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Resolve decorator alias from import declarations with basic caching
|
|
840
|
+
* @param sourceFile Source file containing the decorator
|
|
841
|
+
* @param decoratorName Raw decorator name from AST
|
|
842
|
+
* @returns Original decorator name if alias found, null otherwise
|
|
843
|
+
*/
|
|
844
|
+
resolveDecoratorAlias(sourceFile, decoratorName) {
|
|
845
|
+
const importDeclarations = sourceFile.getImportDeclarations();
|
|
846
|
+
for (const importDecl of importDeclarations) if (importDecl.getModuleSpecifierValue() === "@angular/core") {
|
|
847
|
+
const namedImports = importDecl.getNamedImports();
|
|
848
|
+
for (const namedImport of namedImports) {
|
|
849
|
+
const alias = namedImport.getAliasNode();
|
|
850
|
+
if (alias && alias.getText() === decoratorName) return namedImport.getName();
|
|
851
|
+
if (!alias && namedImport.getName() === decoratorName) return decoratorName;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Determine NodeKind from Angular decorator
|
|
858
|
+
* @param decorator Angular decorator
|
|
859
|
+
* @returns NodeKind mapping
|
|
860
|
+
*/
|
|
861
|
+
determineNodeKind(decorator) {
|
|
862
|
+
switch (this.getDecoratorName(decorator)) {
|
|
863
|
+
case "Injectable": return "service";
|
|
864
|
+
case "Component": return "component";
|
|
865
|
+
case "Directive": return "directive";
|
|
866
|
+
default: return "unknown";
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Detect and warn about anonymous class expressions
|
|
871
|
+
* Handles patterns like: const X = Decorator()(class { ... })
|
|
872
|
+
* @param sourceFile Source file to analyze
|
|
873
|
+
*/
|
|
874
|
+
detectAnonymousClasses(sourceFile) {
|
|
875
|
+
try {
|
|
876
|
+
sourceFile.forEachDescendant((node) => {
|
|
877
|
+
if (node.getKind() === ts_morph.SyntaxKind.ClassExpression) {
|
|
878
|
+
const parent = node.getParent();
|
|
879
|
+
if (parent && parent.getKind() === ts_morph.SyntaxKind.CallExpression) {
|
|
880
|
+
const grandParent = parent.getParent();
|
|
881
|
+
if (grandParent && grandParent.getKind() === ts_morph.SyntaxKind.CallExpression) {
|
|
882
|
+
console.warn("Warning: Skipping anonymous class - classes must be named for dependency injection analysis");
|
|
883
|
+
if (this._options.verbose) console.log(` Anonymous class found in ${sourceFile.getFilePath()}`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (this._options.verbose) console.log(` Could not detect anonymous classes in ${sourceFile.getFilePath()}: ${error}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Extract constructor dependencies from a class declaration
|
|
894
|
+
* Implements FR-03: Constructor parameter analysis
|
|
895
|
+
* Implements TDD Cycle 2.1: inject() function detection
|
|
896
|
+
* @param classDeclaration ts-morph ClassDeclaration
|
|
897
|
+
* @returns Array of parsed dependencies
|
|
898
|
+
*/
|
|
899
|
+
extractConstructorDependencies(classDeclaration) {
|
|
900
|
+
const dependencies = [];
|
|
901
|
+
const verboseStats = {
|
|
902
|
+
decoratorCounts: {
|
|
903
|
+
optional: 0,
|
|
904
|
+
self: 0,
|
|
905
|
+
skipSelf: 0,
|
|
906
|
+
host: 0
|
|
907
|
+
},
|
|
908
|
+
skippedDecorators: [],
|
|
909
|
+
parametersWithDecorators: 0,
|
|
910
|
+
parametersWithoutDecorators: 0,
|
|
911
|
+
legacyDecoratorsUsed: 0,
|
|
912
|
+
injectPatternsUsed: 0,
|
|
913
|
+
totalProcessingTime: 0,
|
|
914
|
+
totalParameters: 0
|
|
915
|
+
};
|
|
916
|
+
const startTime = performance.now();
|
|
917
|
+
if (this._options.verbose && this._options.includeDecorators) {
|
|
918
|
+
console.log("=== Decorator Analysis ===");
|
|
919
|
+
const className = classDeclaration.getName() || "unknown";
|
|
920
|
+
console.log(`Analyzing decorators for class: ${className}`);
|
|
921
|
+
}
|
|
922
|
+
if (this._options.verbose && !this._options.includeDecorators) console.log("Decorator analysis disabled - --include-decorators flag not set");
|
|
923
|
+
const constructors = classDeclaration.getConstructors();
|
|
924
|
+
if (constructors.length > 0) {
|
|
925
|
+
const parameters = constructors[0].getParameters();
|
|
926
|
+
verboseStats.totalParameters = parameters.length;
|
|
927
|
+
for (const param of parameters) {
|
|
928
|
+
const paramStartTime = performance.now();
|
|
929
|
+
const dependency = this.parseConstructorParameter(param);
|
|
930
|
+
const paramEndTime = performance.now();
|
|
931
|
+
if (dependency) {
|
|
932
|
+
dependencies.push(dependency);
|
|
933
|
+
if (this._options.verbose && this._options.includeDecorators) this.collectVerboseStats(param, dependency, verboseStats);
|
|
934
|
+
}
|
|
935
|
+
verboseStats.totalProcessingTime += paramEndTime - paramStartTime;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
const injectDependencies = this.extractInjectFunctionDependencies(classDeclaration);
|
|
939
|
+
dependencies.push(...injectDependencies);
|
|
940
|
+
verboseStats.totalProcessingTime = performance.now() - startTime;
|
|
941
|
+
if (this._options.verbose) this.outputVerboseAnalysis(dependencies, verboseStats, classDeclaration);
|
|
942
|
+
return dependencies;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Check if type reference is circular (Task 3.3)
|
|
946
|
+
* @param typeText Type text to check
|
|
947
|
+
* @param typeNode TypeNode for context
|
|
948
|
+
* @returns True if circular reference detected
|
|
949
|
+
*/
|
|
950
|
+
isCircularTypeReference(typeText, typeNode) {
|
|
951
|
+
const currentClass = typeNode.getFirstAncestorByKind(ts_morph.SyntaxKind.ClassDeclaration);
|
|
952
|
+
if (currentClass) {
|
|
953
|
+
if (currentClass.getName() === typeText) return true;
|
|
954
|
+
}
|
|
955
|
+
if (this._circularTypeRefs.has(typeText)) return true;
|
|
956
|
+
this._circularTypeRefs.add(typeText);
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Check if type is generic (Task 3.3)
|
|
961
|
+
* @param typeText Type text to check
|
|
962
|
+
* @returns True if generic type
|
|
963
|
+
*/
|
|
964
|
+
isGenericType(typeText) {
|
|
965
|
+
return typeText.includes("<") && typeText.includes(">");
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Handle generic types (Task 3.3)
|
|
969
|
+
* @param typeText Generic type text
|
|
970
|
+
* @param filePath File path for context
|
|
971
|
+
* @param lineNumber Line number
|
|
972
|
+
* @param columnNumber Column number
|
|
973
|
+
* @returns Token string or null
|
|
974
|
+
*/
|
|
975
|
+
handleGenericType(typeText, _filePath, _lineNumber, _columnNumber) {
|
|
976
|
+
if (this._options.verbose) console.log(`Processing generic type: ${typeText}`);
|
|
977
|
+
return typeText;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Check if type is union type (Task 3.3)
|
|
981
|
+
* @param typeText Type text to check
|
|
982
|
+
* @returns True if union type
|
|
983
|
+
*/
|
|
984
|
+
isUnionType(typeText) {
|
|
985
|
+
return typeText.includes(" | ") && !typeText.includes("<");
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Handle union types (Task 3.3)
|
|
989
|
+
* @param typeText Union type text
|
|
990
|
+
* @param filePath File path for context
|
|
991
|
+
* @param lineNumber Line number
|
|
992
|
+
* @param columnNumber Column number
|
|
993
|
+
* @returns Null (union types are skipped)
|
|
994
|
+
*/
|
|
995
|
+
handleUnionType(typeText, filePath, lineNumber, columnNumber) {
|
|
996
|
+
this.addStructuredWarning("skippedTypes", {
|
|
997
|
+
type: "complex_union_type",
|
|
998
|
+
message: `Skipping complex union type: ${typeText}`,
|
|
999
|
+
file: filePath,
|
|
1000
|
+
line: lineNumber,
|
|
1001
|
+
column: columnNumber,
|
|
1002
|
+
suggestion: "Consider using a single service type or interface",
|
|
1003
|
+
severity: "info"
|
|
1004
|
+
});
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Check if type can be resolved through imports (Task 3.3)
|
|
1009
|
+
* Handles module-scoped types (e.g., MyModule.ScopedService)
|
|
1010
|
+
* @param typeNode TypeNode to check
|
|
1011
|
+
* @returns True if type can be resolved
|
|
1012
|
+
*/
|
|
1013
|
+
canResolveType(typeNode) {
|
|
1014
|
+
try {
|
|
1015
|
+
const sourceFile = typeNode.getSourceFile();
|
|
1016
|
+
const typeText = typeNode.getText();
|
|
1017
|
+
const type = typeNode.getType();
|
|
1018
|
+
const typeTextFromSystem = type.getText();
|
|
1019
|
+
const symbol = type.getSymbol();
|
|
1020
|
+
if (typeTextFromSystem === "error" || typeTextFromSystem === "any" || type.isUnknown()) return false;
|
|
1021
|
+
if (!symbol) return false;
|
|
1022
|
+
if ([
|
|
1023
|
+
"Array",
|
|
1024
|
+
"Promise",
|
|
1025
|
+
"Observable",
|
|
1026
|
+
"Date",
|
|
1027
|
+
"Error"
|
|
1028
|
+
].includes(typeText)) return true;
|
|
1029
|
+
let simpleTypeText = typeText;
|
|
1030
|
+
if (typeText.includes(".")) simpleTypeText = typeText.split(".")[0];
|
|
1031
|
+
const imports = sourceFile.getImportDeclarations();
|
|
1032
|
+
for (const importDecl of imports) {
|
|
1033
|
+
const namedImports = importDecl.getNamedImports();
|
|
1034
|
+
for (const namedImport of namedImports) if (namedImport.getName() === typeText || namedImport.getName() === simpleTypeText) return true;
|
|
1035
|
+
}
|
|
1036
|
+
const typeAliases = sourceFile.getTypeAliases();
|
|
1037
|
+
const interfaces = sourceFile.getInterfaces();
|
|
1038
|
+
const classes = sourceFile.getClasses();
|
|
1039
|
+
if ([
|
|
1040
|
+
...typeAliases,
|
|
1041
|
+
...interfaces,
|
|
1042
|
+
...classes
|
|
1043
|
+
].some((decl) => decl.getName() === typeText || decl.getName() === simpleTypeText)) return true;
|
|
1044
|
+
if (typeText.includes(".")) {
|
|
1045
|
+
const namespaces = sourceFile.getModules();
|
|
1046
|
+
for (const namespace of namespaces) if (namespace.getName() === simpleTypeText) return true;
|
|
1047
|
+
}
|
|
1048
|
+
return false;
|
|
1049
|
+
} catch {
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Enhanced type token extraction with advanced analysis (Task 3.3)
|
|
1055
|
+
* @param typeNode TypeNode to extract token from
|
|
1056
|
+
* @param filePath File path for context
|
|
1057
|
+
* @param lineNumber Line number
|
|
1058
|
+
* @param columnNumber Column number
|
|
1059
|
+
* @returns Token string or null
|
|
1060
|
+
*/
|
|
1061
|
+
extractTypeTokenEnhanced(typeNode, filePath, lineNumber, columnNumber) {
|
|
1062
|
+
const typeText = typeNode.getText();
|
|
1063
|
+
if (this._options.verbose) console.log(`Type resolution steps: Processing '${typeText}' at ${filePath}:${lineNumber}:${columnNumber}`);
|
|
1064
|
+
if (this.isCircularTypeReference(typeText, typeNode)) {
|
|
1065
|
+
this.addStructuredWarning("circularReferences", {
|
|
1066
|
+
type: "circular_type_reference",
|
|
1067
|
+
message: `Circular type reference detected: ${typeText}`,
|
|
1068
|
+
file: filePath,
|
|
1069
|
+
line: lineNumber,
|
|
1070
|
+
column: columnNumber,
|
|
1071
|
+
suggestion: "Consider using interfaces or breaking the circular dependency",
|
|
1072
|
+
severity: "warning"
|
|
1073
|
+
});
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
if (this.isGenericType(typeText)) return this.handleGenericType(typeText, filePath, lineNumber, columnNumber);
|
|
1077
|
+
if (this.isUnionType(typeText)) return this.handleUnionType(typeText, filePath, lineNumber, columnNumber);
|
|
1078
|
+
if (this.shouldSkipType(typeText)) {
|
|
1079
|
+
this.addStructuredWarning("skippedTypes", {
|
|
1080
|
+
type: "any_unknown_type",
|
|
1081
|
+
message: `Skipping parameter with any/unknown type: ${typeText}`,
|
|
1082
|
+
file: filePath,
|
|
1083
|
+
line: lineNumber,
|
|
1084
|
+
column: columnNumber,
|
|
1085
|
+
suggestion: "Consider adding explicit type annotation",
|
|
1086
|
+
severity: "warning"
|
|
1087
|
+
});
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
if (this.isPrimitiveType(typeText)) {
|
|
1091
|
+
this.addStructuredWarning("skippedTypes", {
|
|
1092
|
+
type: "primitive_type",
|
|
1093
|
+
message: `Skipping primitive type: ${typeText}`,
|
|
1094
|
+
file: filePath,
|
|
1095
|
+
line: lineNumber,
|
|
1096
|
+
column: columnNumber,
|
|
1097
|
+
suggestion: "Use dependency injection for services, not primitive types",
|
|
1098
|
+
severity: "info"
|
|
1099
|
+
});
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
if (!this.canResolveType(typeNode)) {
|
|
1103
|
+
this.addStructuredWarning("unresolvedImports", {
|
|
1104
|
+
type: "unresolved_type",
|
|
1105
|
+
message: `Unresolved type '${typeText}' - check imports`,
|
|
1106
|
+
file: filePath,
|
|
1107
|
+
line: lineNumber,
|
|
1108
|
+
column: columnNumber,
|
|
1109
|
+
suggestion: `Ensure ${typeText} is properly imported`,
|
|
1110
|
+
severity: "warning"
|
|
1111
|
+
});
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
return typeText;
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Resolve inferred types with enhanced validation (Task 3.3)
|
|
1118
|
+
* @param type Type object from ts-morph
|
|
1119
|
+
* @param typeText Type text representation
|
|
1120
|
+
* @param param Parameter declaration for context
|
|
1121
|
+
* @param filePath File path for warnings
|
|
1122
|
+
* @param lineNumber Line number for warnings
|
|
1123
|
+
* @param columnNumber Column number for warnings
|
|
1124
|
+
* @returns Resolved token or null
|
|
1125
|
+
*/
|
|
1126
|
+
resolveInferredTypeEnhanced(type, typeText, param, filePath, lineNumber, columnNumber) {
|
|
1127
|
+
if (this._options.verbose) console.log(`Attempting to resolve inferred type: ${typeText}`);
|
|
1128
|
+
const symbol = type.getSymbol?.();
|
|
1129
|
+
if (symbol) {
|
|
1130
|
+
const symbolName = symbol.getName();
|
|
1131
|
+
if (symbolName && symbolName !== "__type") return symbolName;
|
|
1132
|
+
}
|
|
1133
|
+
const aliasSymbol = type.getAliasSymbol?.();
|
|
1134
|
+
if (aliasSymbol) {
|
|
1135
|
+
const declarations = aliasSymbol.getDeclarations();
|
|
1136
|
+
if (declarations && declarations.length > 0) return aliasSymbol.getName();
|
|
1137
|
+
}
|
|
1138
|
+
if (this.shouldSkipType(typeText)) {
|
|
1139
|
+
this.addStructuredWarning("skippedTypes", {
|
|
1140
|
+
type: "inferred_any_unknown",
|
|
1141
|
+
message: `Skipping parameter '${param.getName()}' with inferred any/unknown type`,
|
|
1142
|
+
file: filePath,
|
|
1143
|
+
line: lineNumber,
|
|
1144
|
+
column: columnNumber,
|
|
1145
|
+
suggestion: "Add explicit type annotation to improve type safety",
|
|
1146
|
+
severity: "warning"
|
|
1147
|
+
});
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
if (this.isPrimitiveType(typeText)) {
|
|
1151
|
+
this.addStructuredWarning("skippedTypes", {
|
|
1152
|
+
type: "inferred_primitive",
|
|
1153
|
+
message: `Skipping inferred primitive type parameter '${param.getName()}': ${typeText}`,
|
|
1154
|
+
file: filePath,
|
|
1155
|
+
line: lineNumber,
|
|
1156
|
+
column: columnNumber,
|
|
1157
|
+
suggestion: "Use dependency injection for services, not primitive types",
|
|
1158
|
+
severity: "info"
|
|
1159
|
+
});
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
const aliasSymbolCheck = type.getAliasSymbol?.();
|
|
1163
|
+
const hasValidAliasSymbol = aliasSymbolCheck && aliasSymbolCheck.getDeclarations().length > 0;
|
|
1164
|
+
if (!symbol && !hasValidAliasSymbol) {
|
|
1165
|
+
this.addStructuredWarning("unresolvedImports", {
|
|
1166
|
+
type: "unresolved_type",
|
|
1167
|
+
message: `Unresolved type '${typeText}' - check imports`,
|
|
1168
|
+
file: filePath,
|
|
1169
|
+
line: lineNumber,
|
|
1170
|
+
column: columnNumber,
|
|
1171
|
+
suggestion: `Ensure ${typeText} is properly imported`,
|
|
1172
|
+
severity: "warning"
|
|
1173
|
+
});
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
return typeText;
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Parse a single constructor parameter to extract dependency token
|
|
1180
|
+
* Implements FR-03 token resolution priority: @Inject > type annotation > inferred type
|
|
1181
|
+
* Implements FR-04 parameter decorator handling
|
|
1182
|
+
* @param param ts-morph ParameterDeclaration
|
|
1183
|
+
* @returns ParsedDependency if valid dependency, null if should be skipped
|
|
1184
|
+
*/
|
|
1185
|
+
parseConstructorParameter(param) {
|
|
1186
|
+
const parameterName = param.getName();
|
|
1187
|
+
const filePath = param.getSourceFile().getFilePath();
|
|
1188
|
+
const lineNumber = param.getStartLineNumber();
|
|
1189
|
+
const columnNumber = param.getStart() - param.getStartLinePos();
|
|
1190
|
+
const startTime = performance.now();
|
|
1191
|
+
let cacheKey = null;
|
|
1192
|
+
try {
|
|
1193
|
+
const flags = this.extractParameterDecorators(param);
|
|
1194
|
+
const injectDecorator = param.getDecorator("Inject");
|
|
1195
|
+
if (injectDecorator) {
|
|
1196
|
+
const token = this.extractInjectToken(injectDecorator);
|
|
1197
|
+
if (token) return {
|
|
1198
|
+
token,
|
|
1199
|
+
flags,
|
|
1200
|
+
parameterName
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
const initializer = param.getInitializer();
|
|
1204
|
+
if (initializer) {
|
|
1205
|
+
const injectResult = this.analyzeInjectCall(initializer);
|
|
1206
|
+
if (injectResult) {
|
|
1207
|
+
const finalFlags = Object.keys(flags).length > 0 ? flags : injectResult.flags;
|
|
1208
|
+
return {
|
|
1209
|
+
token: injectResult.token,
|
|
1210
|
+
flags: finalFlags,
|
|
1211
|
+
parameterName
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const typeNode_check = param.getTypeNode();
|
|
1216
|
+
if (typeNode_check) {
|
|
1217
|
+
const token = this.extractTypeTokenEnhanced(typeNode_check, filePath, lineNumber, columnNumber);
|
|
1218
|
+
if (token) return {
|
|
1219
|
+
token,
|
|
1220
|
+
flags,
|
|
1221
|
+
parameterName
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
const type = param.getType();
|
|
1225
|
+
const typeText = type.getText(param);
|
|
1226
|
+
cacheKey = `${filePath}:${parameterName}:${typeText}`;
|
|
1227
|
+
if (this._typeResolutionCache.has(cacheKey)) {
|
|
1228
|
+
const cachedResult = this._typeResolutionCache.get(cacheKey);
|
|
1229
|
+
if (this._options.verbose) console.log(`Cache hit for parameter '${parameterName}': ${typeText}`);
|
|
1230
|
+
return cachedResult ? {
|
|
1231
|
+
token: cachedResult,
|
|
1232
|
+
flags,
|
|
1233
|
+
parameterName
|
|
1234
|
+
} : null;
|
|
1235
|
+
}
|
|
1236
|
+
if (this._options.verbose) console.log(`Cache miss for parameter '${parameterName}': ${typeText}`);
|
|
1237
|
+
const resolvedToken = this.resolveInferredTypeEnhanced(type, typeText, param, filePath, lineNumber, columnNumber);
|
|
1238
|
+
this._typeResolutionCache.set(cacheKey, resolvedToken);
|
|
1239
|
+
if (resolvedToken) return {
|
|
1240
|
+
token: resolvedToken,
|
|
1241
|
+
flags,
|
|
1242
|
+
parameterName
|
|
1243
|
+
};
|
|
1244
|
+
return null;
|
|
1245
|
+
} finally {
|
|
1246
|
+
const duration = performance.now() - startTime;
|
|
1247
|
+
if (cacheKey && duration > 10) {
|
|
1248
|
+
if (this._typeResolutionCache.get(cacheKey)) this.addStructuredWarning("performance", {
|
|
1249
|
+
type: "slow_type_resolution",
|
|
1250
|
+
message: `Slow type resolution for parameter '${parameterName}' (${duration.toFixed(2)}ms)`,
|
|
1251
|
+
file: filePath,
|
|
1252
|
+
line: lineNumber,
|
|
1253
|
+
column: columnNumber,
|
|
1254
|
+
suggestion: "Consider adding explicit type annotation",
|
|
1255
|
+
severity: "info"
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Extract token from @Inject decorator
|
|
1262
|
+
* @param decorator @Inject decorator
|
|
1263
|
+
* @returns Token string or null
|
|
1264
|
+
*/
|
|
1265
|
+
extractInjectToken(decorator) {
|
|
1266
|
+
const callExpr = decorator.getCallExpression();
|
|
1267
|
+
if (!callExpr) return null;
|
|
1268
|
+
const args = callExpr.getArguments();
|
|
1269
|
+
if (args.length === 0) return null;
|
|
1270
|
+
return args[0].getText().replace(/['"]/g, "");
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Check if type should be skipped (any/unknown types)
|
|
1274
|
+
* Implements FR-09: Skip dependencies whose type resolves to any/unknown
|
|
1275
|
+
* @param typeText Type text to check
|
|
1276
|
+
* @returns True if should be skipped
|
|
1277
|
+
*/
|
|
1278
|
+
shouldSkipType(typeText) {
|
|
1279
|
+
return [
|
|
1280
|
+
"any",
|
|
1281
|
+
"unknown",
|
|
1282
|
+
"object",
|
|
1283
|
+
"Object"
|
|
1284
|
+
].includes(typeText);
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Check if type is primitive and should be skipped
|
|
1288
|
+
* @param typeText Type text to check
|
|
1289
|
+
* @returns True if primitive type
|
|
1290
|
+
*/
|
|
1291
|
+
isPrimitiveType(typeText) {
|
|
1292
|
+
return [
|
|
1293
|
+
"string",
|
|
1294
|
+
"number",
|
|
1295
|
+
"boolean",
|
|
1296
|
+
"symbol",
|
|
1297
|
+
"bigint",
|
|
1298
|
+
"undefined",
|
|
1299
|
+
"null"
|
|
1300
|
+
].includes(typeText);
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Extract parameter decorators from constructor parameter
|
|
1304
|
+
* Implements FR-04: Parameter decorator handling (@Optional, @Self, @SkipSelf, @Host)
|
|
1305
|
+
* Optimized for performance with early returns and minimal object allocation
|
|
1306
|
+
* @param param ts-morph ParameterDeclaration
|
|
1307
|
+
* @returns EdgeFlags object with detected decorators
|
|
1308
|
+
*/
|
|
1309
|
+
extractParameterDecorators(param) {
|
|
1310
|
+
return this.analyzeParameterDecorators(param, this._options.includeDecorators);
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Extract dependencies from inject() function calls in class properties
|
|
1314
|
+
* Implements TDD Cycle 2.1: Modern Angular inject() pattern detection
|
|
1315
|
+
* @param classDeclaration ts-morph ClassDeclaration
|
|
1316
|
+
* @returns Array of parsed dependencies from inject() calls
|
|
1317
|
+
*/
|
|
1318
|
+
extractInjectFunctionDependencies(classDeclaration) {
|
|
1319
|
+
const dependencies = [];
|
|
1320
|
+
try {
|
|
1321
|
+
const properties = classDeclaration.getProperties();
|
|
1322
|
+
for (const property of properties) {
|
|
1323
|
+
const dependency = this.parseInjectProperty(property);
|
|
1324
|
+
if (dependency) dependencies.push(dependency);
|
|
1325
|
+
}
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
if (this._options.verbose) {
|
|
1328
|
+
const className = classDeclaration.getName() || "unknown";
|
|
1329
|
+
const filePath = classDeclaration.getSourceFile().getFilePath();
|
|
1330
|
+
console.warn(`Warning: Failed to extract inject() dependencies for class '${className}' in ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
return dependencies;
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Parse a property declaration for inject() function calls
|
|
1337
|
+
* @param property ts-morph PropertyDeclaration
|
|
1338
|
+
* @returns ParsedDependency if inject() call found, null otherwise
|
|
1339
|
+
*/
|
|
1340
|
+
parseInjectProperty(property) {
|
|
1341
|
+
try {
|
|
1342
|
+
const initializer = property.getInitializer();
|
|
1343
|
+
if (!initializer) return null;
|
|
1344
|
+
if (initializer.getKind() !== ts_morph.SyntaxKind.CallExpression) return null;
|
|
1345
|
+
const callExpression = initializer;
|
|
1346
|
+
const expression = callExpression.getExpression();
|
|
1347
|
+
if (expression.getKind() !== ts_morph.SyntaxKind.Identifier) return null;
|
|
1348
|
+
if (expression.getText() !== "inject") return null;
|
|
1349
|
+
if (!this.isAngularInjectImported(property.getSourceFile())) return null;
|
|
1350
|
+
const args = callExpression.getArguments();
|
|
1351
|
+
if (args.length === 0) return null;
|
|
1352
|
+
const token = args[0].getText().replace(/['"]/g, "");
|
|
1353
|
+
if (this.shouldSkipType(token) || this.isPrimitiveType(token)) return null;
|
|
1354
|
+
let flags = {};
|
|
1355
|
+
if (args.length > 1 && this._options.includeDecorators) flags = this.parseInjectOptions(args[1]);
|
|
1356
|
+
const propertyName = property.getName() || "unknown";
|
|
1357
|
+
return {
|
|
1358
|
+
token,
|
|
1359
|
+
flags,
|
|
1360
|
+
parameterName: propertyName
|
|
1361
|
+
};
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
if (this._options.verbose) {
|
|
1364
|
+
const propertyName = property.getName() || "unknown";
|
|
1365
|
+
const filePath = property.getSourceFile().getFilePath();
|
|
1366
|
+
console.warn(`Warning: Failed to parse inject() property '${propertyName}' in ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1367
|
+
}
|
|
1368
|
+
return null;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Parse options object from inject() function call
|
|
1373
|
+
* @param optionsArg Options argument from inject() call
|
|
1374
|
+
* @returns EdgeFlags object with parsed options
|
|
1375
|
+
*/
|
|
1376
|
+
parseInjectOptions(optionsArg) {
|
|
1377
|
+
const flags = {};
|
|
1378
|
+
try {
|
|
1379
|
+
if (optionsArg.getKind() !== ts_morph.SyntaxKind.ObjectLiteralExpression) return flags;
|
|
1380
|
+
const properties = optionsArg.getProperties();
|
|
1381
|
+
const supportedOptions = new Set([
|
|
1382
|
+
"optional",
|
|
1383
|
+
"self",
|
|
1384
|
+
"skipSelf",
|
|
1385
|
+
"host"
|
|
1386
|
+
]);
|
|
1387
|
+
for (const prop of properties) {
|
|
1388
|
+
if (prop.getKind() !== ts_morph.SyntaxKind.PropertyAssignment) continue;
|
|
1389
|
+
const propertyAssignment = prop;
|
|
1390
|
+
const name = propertyAssignment.getName();
|
|
1391
|
+
if (!supportedOptions.has(name)) {
|
|
1392
|
+
if (this._options.verbose) console.warn(`Unknown inject() option: '${name}' - ignoring`);
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
const initializer = propertyAssignment.getInitializer();
|
|
1396
|
+
if (initializer && initializer.getText() === "true") switch (name) {
|
|
1397
|
+
case "optional":
|
|
1398
|
+
flags.optional = true;
|
|
1399
|
+
break;
|
|
1400
|
+
case "self":
|
|
1401
|
+
flags.self = true;
|
|
1402
|
+
break;
|
|
1403
|
+
case "skipSelf":
|
|
1404
|
+
flags.skipSelf = true;
|
|
1405
|
+
break;
|
|
1406
|
+
case "host":
|
|
1407
|
+
flags.host = true;
|
|
1408
|
+
break;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
} catch (error) {
|
|
1412
|
+
if (this._options.verbose) console.warn(`Warning: Failed to parse inject() options: ${error instanceof Error ? error.message : String(error)}`);
|
|
1413
|
+
}
|
|
1414
|
+
return flags;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Analyze parameter decorators for TDD Cycle 1.1
|
|
1418
|
+
* Legacy parameter decorator detection method for @Optional, @Self, @SkipSelf, @Host
|
|
1419
|
+
* @param parameter ParameterDeclaration to analyze
|
|
1420
|
+
* @param includeDecorators Whether to include decorators in analysis
|
|
1421
|
+
* @returns EdgeFlags object with detected decorators
|
|
1422
|
+
*/
|
|
1423
|
+
analyzeParameterDecorators(parameter, includeDecorators, verboseStats) {
|
|
1424
|
+
if (!includeDecorators) return {};
|
|
1425
|
+
const decorators = parameter.getDecorators();
|
|
1426
|
+
if (decorators.length === 0) return {};
|
|
1427
|
+
const flags = {};
|
|
1428
|
+
try {
|
|
1429
|
+
const supportedDecorators = new Set([
|
|
1430
|
+
"Optional",
|
|
1431
|
+
"Self",
|
|
1432
|
+
"SkipSelf",
|
|
1433
|
+
"Host"
|
|
1434
|
+
]);
|
|
1435
|
+
for (const decorator of decorators) {
|
|
1436
|
+
const decoratorName = this.getDecoratorName(decorator);
|
|
1437
|
+
if (decoratorName === "Inject") continue;
|
|
1438
|
+
if (supportedDecorators.has(decoratorName)) switch (decoratorName) {
|
|
1439
|
+
case "Optional":
|
|
1440
|
+
flags.optional = true;
|
|
1441
|
+
break;
|
|
1442
|
+
case "Self":
|
|
1443
|
+
flags.self = true;
|
|
1444
|
+
break;
|
|
1445
|
+
case "SkipSelf":
|
|
1446
|
+
flags.skipSelf = true;
|
|
1447
|
+
break;
|
|
1448
|
+
case "Host":
|
|
1449
|
+
flags.host = true;
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
else if (decoratorName) {
|
|
1453
|
+
if (verboseStats) verboseStats.skippedDecorators.push({
|
|
1454
|
+
name: decoratorName,
|
|
1455
|
+
reason: "Unknown or unsupported decorator"
|
|
1456
|
+
});
|
|
1457
|
+
console.warn(`Unknown or unsupported decorator: @${decoratorName}() - This decorator is not recognized as an Angular DI decorator and will be ignored.`);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
} catch (error) {
|
|
1461
|
+
if (this._options.verbose) {
|
|
1462
|
+
const paramName = parameter.getName();
|
|
1463
|
+
const filePath = parameter.getSourceFile().getFilePath();
|
|
1464
|
+
console.warn(`Warning: Failed to extract decorators for parameter '${paramName}' in ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1465
|
+
}
|
|
1466
|
+
return {};
|
|
1467
|
+
}
|
|
1468
|
+
return flags;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Check if inject() function is imported from @angular/core
|
|
1472
|
+
* Prevents false positives from custom inject() functions
|
|
1473
|
+
* @param sourceFile Source file to check imports
|
|
1474
|
+
* @returns True if Angular inject is imported
|
|
1475
|
+
*/
|
|
1476
|
+
isAngularInjectImported(sourceFile) {
|
|
1477
|
+
try {
|
|
1478
|
+
const importDeclarations = sourceFile.getImportDeclarations();
|
|
1479
|
+
for (const importDecl of importDeclarations) if (importDecl.getModuleSpecifierValue() === "@angular/core") {
|
|
1480
|
+
const namedImports = importDecl.getNamedImports();
|
|
1481
|
+
for (const namedImport of namedImports) {
|
|
1482
|
+
const importName = namedImport.getName();
|
|
1483
|
+
const alias = namedImport.getAliasNode();
|
|
1484
|
+
if (importName === "inject" || alias && alias.getText() === "inject") return true;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return false;
|
|
1488
|
+
} catch (error) {
|
|
1489
|
+
if (this._options.verbose) console.warn(`Warning: Could not verify inject() import in ${sourceFile.getFilePath()}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1490
|
+
return true;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Analyze inject() function call expression to extract token and options
|
|
1495
|
+
* Implements TDD Cycle 2.1 - Modern Angular inject() pattern support
|
|
1496
|
+
* @param expression Expression to analyze (should be a CallExpression)
|
|
1497
|
+
* @returns ParameterAnalysisResult or null if not a valid inject() call
|
|
1498
|
+
*/
|
|
1499
|
+
analyzeInjectCall(expression) {
|
|
1500
|
+
if (!expression) return null;
|
|
1501
|
+
if (expression.getKind() !== ts_morph.SyntaxKind.CallExpression) return null;
|
|
1502
|
+
const callExpression = expression;
|
|
1503
|
+
try {
|
|
1504
|
+
const callIdentifier = callExpression.getExpression();
|
|
1505
|
+
if (callIdentifier.getKind() !== ts_morph.SyntaxKind.Identifier) return null;
|
|
1506
|
+
if (callIdentifier.getText() !== "inject") return null;
|
|
1507
|
+
const sourceFile = expression.getSourceFile();
|
|
1508
|
+
if (!this.isAngularInjectImported(sourceFile)) return null;
|
|
1509
|
+
const args = callExpression.getArguments();
|
|
1510
|
+
if (args.length === 0) {
|
|
1511
|
+
if (this._options.verbose) console.warn("inject() called without token parameter - skipping");
|
|
1512
|
+
return null;
|
|
1513
|
+
}
|
|
1514
|
+
const tokenArg = args[0];
|
|
1515
|
+
let token;
|
|
1516
|
+
if (tokenArg.getKind() === ts_morph.SyntaxKind.StringLiteral) {
|
|
1517
|
+
token = tokenArg.getText().slice(1, -1);
|
|
1518
|
+
if (!token) {
|
|
1519
|
+
if (this._options.verbose) console.warn("inject() called with empty string token - skipping");
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
} else if (tokenArg.getKind() === ts_morph.SyntaxKind.Identifier) {
|
|
1523
|
+
token = tokenArg.getText();
|
|
1524
|
+
if (token === "undefined" || token === "null") {
|
|
1525
|
+
if (this._options.verbose) console.warn(`inject() called with ${token} token - skipping`);
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
} else if (tokenArg.getKind() === ts_morph.SyntaxKind.NullKeyword) {
|
|
1529
|
+
if (this._options.verbose) console.warn("inject() called with null token - skipping");
|
|
1530
|
+
return null;
|
|
1531
|
+
} else {
|
|
1532
|
+
token = tokenArg.getText();
|
|
1533
|
+
if (!token) {
|
|
1534
|
+
if (this._options.verbose) console.warn("inject() called with invalid token expression - skipping");
|
|
1535
|
+
return null;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
let flags = {};
|
|
1539
|
+
if (args.length > 1 && this._options.includeDecorators) {
|
|
1540
|
+
const optionsArg = args[1];
|
|
1541
|
+
if (optionsArg.getKind() === ts_morph.SyntaxKind.ObjectLiteralExpression) flags = this.parseInjectOptions(optionsArg);
|
|
1542
|
+
else if (optionsArg.getKind() !== ts_morph.SyntaxKind.NullKeyword && optionsArg.getKind() !== ts_morph.SyntaxKind.UndefinedKeyword) {
|
|
1543
|
+
if (this._options.verbose) console.warn(`inject() called with invalid options type: ${optionsArg.getKindName()} - expected object literal`);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return {
|
|
1547
|
+
token,
|
|
1548
|
+
flags,
|
|
1549
|
+
source: "inject"
|
|
1550
|
+
};
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
if (this._options.verbose) {
|
|
1553
|
+
const filePath = expression.getSourceFile().getFilePath();
|
|
1554
|
+
console.warn(`Warning: Failed to analyze inject() call in ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1555
|
+
}
|
|
1556
|
+
return null;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Collect verbose statistics for decorator analysis
|
|
1561
|
+
* @param param Parameter declaration being analyzed
|
|
1562
|
+
* @param dependency Parsed dependency result
|
|
1563
|
+
* @param verboseStats Statistics object to update
|
|
1564
|
+
*/
|
|
1565
|
+
collectVerboseStats(param, dependency, verboseStats) {
|
|
1566
|
+
const paramName = param.getName();
|
|
1567
|
+
console.log(`Parameter: ${paramName}`);
|
|
1568
|
+
console.log(` Token: ${dependency.token}`);
|
|
1569
|
+
if (Object.keys(dependency.flags || {}).length > 0) {
|
|
1570
|
+
verboseStats.parametersWithDecorators++;
|
|
1571
|
+
if (dependency.flags?.optional) verboseStats.decoratorCounts.optional++;
|
|
1572
|
+
if (dependency.flags?.self) verboseStats.decoratorCounts.self++;
|
|
1573
|
+
if (dependency.flags?.skipSelf) verboseStats.decoratorCounts.skipSelf++;
|
|
1574
|
+
if (dependency.flags?.host) verboseStats.decoratorCounts.host++;
|
|
1575
|
+
const decorators = param.getDecorators();
|
|
1576
|
+
let hasLegacyDecorators = false;
|
|
1577
|
+
let hasInjectPattern = false;
|
|
1578
|
+
this.analyzeParameterDecorators(param, true, verboseStats);
|
|
1579
|
+
for (const decorator of decorators) {
|
|
1580
|
+
const decoratorName = this.getDecoratorName(decorator);
|
|
1581
|
+
if ([
|
|
1582
|
+
"Optional",
|
|
1583
|
+
"Self",
|
|
1584
|
+
"SkipSelf",
|
|
1585
|
+
"Host"
|
|
1586
|
+
].includes(decoratorName)) {
|
|
1587
|
+
hasLegacyDecorators = true;
|
|
1588
|
+
console.log(` Legacy decorator: @${decoratorName}`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
const initializer = param.getInitializer();
|
|
1592
|
+
if (initializer && initializer.getKind() === ts_morph.SyntaxKind.CallExpression) {
|
|
1593
|
+
const expression = initializer.getExpression();
|
|
1594
|
+
if (expression.getKind() === ts_morph.SyntaxKind.Identifier) {
|
|
1595
|
+
if (expression.getText() === "inject") {
|
|
1596
|
+
hasInjectPattern = true;
|
|
1597
|
+
verboseStats.injectPatternsUsed++;
|
|
1598
|
+
const flagsStr = JSON.stringify(dependency.flags);
|
|
1599
|
+
console.log(` inject() options: ${flagsStr}`);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
if (hasLegacyDecorators) {
|
|
1604
|
+
verboseStats.legacyDecoratorsUsed++;
|
|
1605
|
+
if (hasInjectPattern) {
|
|
1606
|
+
console.log(" Decorator Precedence Analysis");
|
|
1607
|
+
console.log(" Legacy decorators take precedence over inject() options");
|
|
1608
|
+
const appliedFlags = Object.keys(dependency.flags || {}).filter((key) => dependency.flags?.[key] === true).map((key) => `@${key.charAt(0).toUpperCase() + key.slice(1)}`).join(", ");
|
|
1609
|
+
console.log(` Applied: ${appliedFlags}`);
|
|
1610
|
+
const injectResult = this.analyzeInjectCall(initializer);
|
|
1611
|
+
if (injectResult && Object.keys(injectResult.flags).length > 0) {
|
|
1612
|
+
const overriddenFlags = JSON.stringify(injectResult.flags);
|
|
1613
|
+
console.log(` Overridden inject() options: ${overriddenFlags}`);
|
|
1614
|
+
}
|
|
1615
|
+
const finalFlags = JSON.stringify(dependency.flags);
|
|
1616
|
+
console.log(` Final flags: ${finalFlags}`);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
} else {
|
|
1620
|
+
verboseStats.parametersWithoutDecorators++;
|
|
1621
|
+
console.log(" No decorators detected");
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Output comprehensive verbose analysis summary
|
|
1626
|
+
* @param dependencies All parsed dependencies
|
|
1627
|
+
* @param verboseStats Collected statistics
|
|
1628
|
+
* @param classDeclaration Class being analyzed
|
|
1629
|
+
*/
|
|
1630
|
+
outputVerboseAnalysis(dependencies, verboseStats, classDeclaration) {
|
|
1631
|
+
if (!this._options.verbose) return;
|
|
1632
|
+
if (this._options.includeDecorators) {
|
|
1633
|
+
console.log("=== Decorator Statistics ===");
|
|
1634
|
+
console.log(`Total decorators detected: ${verboseStats.decoratorCounts.optional + verboseStats.decoratorCounts.self + verboseStats.decoratorCounts.skipSelf + verboseStats.decoratorCounts.host}`);
|
|
1635
|
+
if (verboseStats.decoratorCounts.optional > 0) console.log(`@Optional: ${verboseStats.decoratorCounts.optional}`);
|
|
1636
|
+
if (verboseStats.decoratorCounts.self > 0) console.log(`@Self: ${verboseStats.decoratorCounts.self}`);
|
|
1637
|
+
if (verboseStats.decoratorCounts.skipSelf > 0) console.log(`@SkipSelf: ${verboseStats.decoratorCounts.skipSelf}`);
|
|
1638
|
+
if (verboseStats.decoratorCounts.host > 0) console.log(`@Host: ${verboseStats.decoratorCounts.host}`);
|
|
1639
|
+
console.log(`Parameters with decorators: ${verboseStats.parametersWithDecorators}`);
|
|
1640
|
+
console.log(`Parameters without decorators: ${verboseStats.parametersWithoutDecorators}`);
|
|
1641
|
+
if (verboseStats.injectPatternsUsed > 0) {
|
|
1642
|
+
console.log("inject() Pattern Analysis");
|
|
1643
|
+
for (const dep of dependencies) if (dep.parameterName) {
|
|
1644
|
+
const constructors = classDeclaration.getConstructors();
|
|
1645
|
+
if (constructors.length > 0) {
|
|
1646
|
+
const param = constructors[0].getParameters().find((p) => p.getName() === dep.parameterName);
|
|
1647
|
+
if (param) {
|
|
1648
|
+
const initializer = param.getInitializer();
|
|
1649
|
+
if (initializer) {
|
|
1650
|
+
const injectResult = this.analyzeInjectCall(initializer);
|
|
1651
|
+
if (injectResult) {
|
|
1652
|
+
if (injectResult.token.startsWith("\"") && injectResult.token.endsWith("\"")) console.log(`String token: ${injectResult.token}`);
|
|
1653
|
+
else console.log(`Service token: ${injectResult.token}`);
|
|
1654
|
+
if (Object.keys(injectResult.flags).length > 0) {
|
|
1655
|
+
const flagsStr = JSON.stringify(injectResult.flags);
|
|
1656
|
+
console.log(`inject() options detected: ${flagsStr}`);
|
|
1657
|
+
} else console.log("inject() with no options");
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (verboseStats.skippedDecorators.length > 0) {
|
|
1665
|
+
console.log("Skipped Decorators");
|
|
1666
|
+
for (const skipped of verboseStats.skippedDecorators) {
|
|
1667
|
+
console.log(`${skipped.name}`);
|
|
1668
|
+
console.log(`Reason: ${skipped.reason}`);
|
|
1669
|
+
}
|
|
1670
|
+
console.log(`Total skipped: ${verboseStats.skippedDecorators.length}`);
|
|
1671
|
+
}
|
|
1672
|
+
console.log("Performance Metrics");
|
|
1673
|
+
console.log(`Decorator processing time: ${verboseStats.totalProcessingTime.toFixed(2)}ms`);
|
|
1674
|
+
console.log(`Total parameters analyzed: ${verboseStats.totalParameters}`);
|
|
1675
|
+
if (verboseStats.totalParameters > 0) {
|
|
1676
|
+
const avgTime = verboseStats.totalProcessingTime / verboseStats.totalParameters;
|
|
1677
|
+
console.log(`Average time per parameter: ${avgTime.toFixed(3)}ms`);
|
|
1678
|
+
}
|
|
1679
|
+
console.log("=== Analysis Summary ===");
|
|
1680
|
+
console.log(`Total dependencies: ${dependencies.length}`);
|
|
1681
|
+
console.log(`With decorator flags: ${verboseStats.parametersWithDecorators}`);
|
|
1682
|
+
console.log(`Without decorator flags: ${verboseStats.parametersWithoutDecorators}`);
|
|
1683
|
+
console.log(`Legacy decorators used: ${verboseStats.legacyDecoratorsUsed}`);
|
|
1684
|
+
console.log(`inject() patterns used: ${verboseStats.injectPatternsUsed}`);
|
|
1685
|
+
if (verboseStats.skippedDecorators.length > 0) console.log(`Unknown decorators skipped: ${verboseStats.skippedDecorators.length}`);
|
|
1686
|
+
if (verboseStats.parametersWithDecorators > 0) {
|
|
1687
|
+
console.log("Flags distribution:");
|
|
1688
|
+
if (verboseStats.decoratorCounts.optional > 0) console.log(`optional: ${verboseStats.decoratorCounts.optional}`);
|
|
1689
|
+
if (verboseStats.decoratorCounts.self > 0) console.log(`self: ${verboseStats.decoratorCounts.self}`);
|
|
1690
|
+
if (verboseStats.decoratorCounts.skipSelf > 0) console.log(`skipSelf: ${verboseStats.decoratorCounts.skipSelf}`);
|
|
1691
|
+
if (verboseStats.decoratorCounts.host > 0) console.log(`host: ${verboseStats.decoratorCounts.host}`);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
|
|
1697
|
+
//#endregion
|
|
1698
|
+
//#region src/formatters/json-formatter.ts
|
|
1699
|
+
/**
|
|
1700
|
+
* JSON formatter for dependency graph output
|
|
1701
|
+
* Produces pretty-printed JSON with 2-space indentation
|
|
1702
|
+
*/
|
|
1703
|
+
var JsonFormatter = class {
|
|
1704
|
+
/**
|
|
1705
|
+
* Create a new JSON formatter
|
|
1706
|
+
* @param logger Optional Logger instance for verbose mode
|
|
1707
|
+
*/
|
|
1708
|
+
constructor(logger) {
|
|
1709
|
+
this._logger = logger;
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Format a dependency graph as JSON
|
|
1713
|
+
* @param graph The dependency graph to format
|
|
1714
|
+
* @returns Pretty-printed JSON string
|
|
1715
|
+
*/
|
|
1716
|
+
format(graph) {
|
|
1717
|
+
this._logger?.time("json-format");
|
|
1718
|
+
this._logger?.info(LogCategory.PERFORMANCE, "Generating JSON output", {
|
|
1719
|
+
nodeCount: graph.nodes.length,
|
|
1720
|
+
edgeCount: graph.edges.length
|
|
1721
|
+
});
|
|
1722
|
+
const result = JSON.stringify(graph, null, 2);
|
|
1723
|
+
const elapsed = this._logger?.timeEnd("json-format") ?? 0;
|
|
1724
|
+
this._logger?.info(LogCategory.PERFORMANCE, "JSON output complete", {
|
|
1725
|
+
outputSize: result.length,
|
|
1726
|
+
elapsed
|
|
1727
|
+
});
|
|
1728
|
+
return result;
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
|
|
1732
|
+
//#endregion
|
|
1733
|
+
//#region src/formatters/mermaid-formatter.ts
|
|
1734
|
+
/**
|
|
1735
|
+
* Mermaid formatter for dependency graph output
|
|
1736
|
+
* Produces flowchart LR syntax compatible with Mermaid Live Editor
|
|
1737
|
+
*/
|
|
1738
|
+
var MermaidFormatter = class {
|
|
1739
|
+
/**
|
|
1740
|
+
* Create a new Mermaid formatter
|
|
1741
|
+
* @param logger Optional Logger instance for verbose mode
|
|
1742
|
+
*/
|
|
1743
|
+
constructor(logger) {
|
|
1744
|
+
this._logger = logger;
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Format a dependency graph as Mermaid flowchart
|
|
1748
|
+
* @param graph The dependency graph to format
|
|
1749
|
+
* @returns Mermaid flowchart string
|
|
1750
|
+
*/
|
|
1751
|
+
format(graph) {
|
|
1752
|
+
this._logger?.time("mermaid-format");
|
|
1753
|
+
this._logger?.info(LogCategory.PERFORMANCE, "Generating Mermaid output", {
|
|
1754
|
+
nodeCount: graph.nodes.length,
|
|
1755
|
+
edgeCount: graph.edges.length
|
|
1756
|
+
});
|
|
1757
|
+
if (graph.nodes.length === 0) {
|
|
1758
|
+
const result$1 = "flowchart LR\n %% Empty graph - no nodes to display";
|
|
1759
|
+
const elapsed$1 = this._logger?.timeEnd("mermaid-format") ?? 0;
|
|
1760
|
+
this._logger?.info(LogCategory.PERFORMANCE, "Mermaid output complete", {
|
|
1761
|
+
outputSize: 51,
|
|
1762
|
+
elapsed: elapsed$1
|
|
1763
|
+
});
|
|
1764
|
+
return result$1;
|
|
1765
|
+
}
|
|
1766
|
+
const lines = ["flowchart LR"];
|
|
1767
|
+
for (const edge of graph.edges) {
|
|
1768
|
+
const fromNode = this.sanitizeNodeName(edge.from);
|
|
1769
|
+
const toNode = this.sanitizeNodeName(edge.to);
|
|
1770
|
+
if (edge.isCircular) lines.push(` ${fromNode} -.->|circular| ${toNode}`);
|
|
1771
|
+
else lines.push(` ${fromNode} --> ${toNode}`);
|
|
1772
|
+
}
|
|
1773
|
+
if (graph.circularDependencies.length > 0) {
|
|
1774
|
+
lines.push("");
|
|
1775
|
+
lines.push(" %% Circular Dependencies Detected:");
|
|
1776
|
+
for (const cycle of graph.circularDependencies) lines.push(` %% ${cycle.join(" -> ")} -> ${cycle[0]}`);
|
|
1777
|
+
}
|
|
1778
|
+
const result = lines.join("\n");
|
|
1779
|
+
const elapsed = this._logger?.timeEnd("mermaid-format") ?? 0;
|
|
1780
|
+
this._logger?.info(LogCategory.PERFORMANCE, "Mermaid output complete", {
|
|
1781
|
+
outputSize: result.length,
|
|
1782
|
+
elapsed
|
|
1783
|
+
});
|
|
1784
|
+
return result;
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Sanitize node names for Mermaid compatibility
|
|
1788
|
+
* Replaces special characters that break Mermaid syntax
|
|
1789
|
+
* @param nodeName The original node name
|
|
1790
|
+
* @returns Sanitized node name
|
|
1791
|
+
*/
|
|
1792
|
+
sanitizeNodeName(nodeName) {
|
|
1793
|
+
return nodeName.replace(/[.-]/g, "_").replace(/[^a-zA-Z0-9_]/g, "");
|
|
1794
|
+
}
|
|
1795
|
+
};
|
|
1796
|
+
|
|
1797
|
+
//#endregion
|
|
1798
|
+
//#region src/cli/index.ts
|
|
1799
|
+
/**
|
|
1800
|
+
* ng-di-graph CLI entry point
|
|
1801
|
+
* Supports Node.js (via tsx) execution
|
|
1802
|
+
*/
|
|
1803
|
+
const MIN_NODE_MAJOR_VERSION = 20;
|
|
1804
|
+
function enforceMinimumNodeVersion() {
|
|
1805
|
+
const nodeVersion = process.versions.node;
|
|
1806
|
+
if (!nodeVersion) return;
|
|
1807
|
+
const [majorSegment] = nodeVersion.split(".");
|
|
1808
|
+
const major = Number(majorSegment);
|
|
1809
|
+
if (Number.isNaN(major) || major >= MIN_NODE_MAJOR_VERSION) return;
|
|
1810
|
+
const runtimeLabel = process.versions.bun ? `Bun (Node compatibility ${nodeVersion})` : `Node.js ${nodeVersion}`;
|
|
1811
|
+
console.error([
|
|
1812
|
+
`ng-di-graph requires Node.js >= ${MIN_NODE_MAJOR_VERSION}.0.0.`,
|
|
1813
|
+
`Detected runtime: ${runtimeLabel}.`,
|
|
1814
|
+
"Upgrade to Node.js 20.x LTS (match `.node-version` / mise config) before running ng-di-graph."
|
|
1815
|
+
].join("\n"));
|
|
1816
|
+
process.exit(1);
|
|
1817
|
+
}
|
|
1818
|
+
enforceMinimumNodeVersion();
|
|
1819
|
+
const program = new commander.Command();
|
|
1820
|
+
program.name("ng-di-graph").description("Angular DI dependency graph CLI tool").version("0.1.0");
|
|
1821
|
+
program.option("-p, --project <path>", "tsconfig.json path", "./tsconfig.json").option("-f, --format <format>", "output format: json | mermaid", "json").option("-e, --entry <symbol...>", "starting nodes for sub-graph").option("-d, --direction <dir>", "filtering direction: upstream|downstream|both", "downstream").option("--include-decorators", "include Optional/Self/SkipSelf/Host flags", false).option("--out <file>", "output file (stdout if omitted)").option("-v, --verbose", "show detailed parsing information", false);
|
|
1822
|
+
program.action(async (options) => {
|
|
1823
|
+
try {
|
|
1824
|
+
if (options.direction && ![
|
|
1825
|
+
"upstream",
|
|
1826
|
+
"downstream",
|
|
1827
|
+
"both"
|
|
1828
|
+
].includes(options.direction)) throw ErrorHandler.createError(`Invalid direction: ${options.direction}. Must be 'upstream', 'downstream', or 'both'`, "INVALID_ARGUMENTS");
|
|
1829
|
+
if (options.format && !["json", "mermaid"].includes(options.format)) throw ErrorHandler.createError(`Invalid format: ${options.format}. Must be 'json' or 'mermaid'`, "INVALID_ARGUMENTS");
|
|
1830
|
+
const cliOptions = {
|
|
1831
|
+
project: options.project,
|
|
1832
|
+
format: options.format,
|
|
1833
|
+
entry: options.entry,
|
|
1834
|
+
direction: options.direction,
|
|
1835
|
+
includeDecorators: options.includeDecorators,
|
|
1836
|
+
out: options.out,
|
|
1837
|
+
verbose: options.verbose
|
|
1838
|
+
};
|
|
1839
|
+
const logger = createLogger(cliOptions.verbose);
|
|
1840
|
+
if (logger) {
|
|
1841
|
+
logger.time("total-execution");
|
|
1842
|
+
logger.info(LogCategory.FILE_PROCESSING, "CLI execution started", {
|
|
1843
|
+
runtime: process.versions.bun ? "Bun" : "Node.js",
|
|
1844
|
+
version: process.versions.bun || process.versions.node,
|
|
1845
|
+
options: cliOptions
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
if (cliOptions.verbose) {
|
|
1849
|
+
console.log("🔧 CLI Options:", JSON.stringify(cliOptions, null, 2));
|
|
1850
|
+
console.log(`🚀 Running with ${process.versions.bun ? "Bun" : "Node.js"} ${process.versions.bun || process.versions.node}`);
|
|
1851
|
+
}
|
|
1852
|
+
const parser = new AngularParser(cliOptions, logger);
|
|
1853
|
+
if (cliOptions.verbose) console.log("📂 Loading TypeScript project...");
|
|
1854
|
+
parser.loadProject();
|
|
1855
|
+
if (cliOptions.verbose) console.log("✅ Project loaded successfully");
|
|
1856
|
+
if (cliOptions.verbose) console.log("🔍 Parsing Angular classes...");
|
|
1857
|
+
const parsedClasses = await parser.parseClasses();
|
|
1858
|
+
if (cliOptions.verbose) console.log(`✅ Found ${parsedClasses.length} decorated classes`);
|
|
1859
|
+
if (cliOptions.verbose) console.log("🔗 Building dependency graph...");
|
|
1860
|
+
let graph = buildGraph(parsedClasses, logger);
|
|
1861
|
+
if (cliOptions.verbose) {
|
|
1862
|
+
console.log(`✅ Graph built: ${graph.nodes.length} nodes, ${graph.edges.length} edges`);
|
|
1863
|
+
if (graph.circularDependencies.length > 0) console.log(`⚠️ Detected ${graph.circularDependencies.length} circular dependencies`);
|
|
1864
|
+
}
|
|
1865
|
+
if (cliOptions.entry && cliOptions.entry.length > 0) {
|
|
1866
|
+
if (cliOptions.verbose) console.log(`🔍 Filtering graph by entry points: ${cliOptions.entry.join(", ")}`);
|
|
1867
|
+
graph = filterGraph(graph, cliOptions);
|
|
1868
|
+
if (cliOptions.verbose) console.log(`✅ Filtered graph: ${graph.nodes.length} nodes, ${graph.edges.length} edges`);
|
|
1869
|
+
}
|
|
1870
|
+
let formatter;
|
|
1871
|
+
if (cliOptions.format === "mermaid") formatter = new MermaidFormatter(logger);
|
|
1872
|
+
else formatter = new JsonFormatter(logger);
|
|
1873
|
+
const formattedOutput = formatter.format(graph);
|
|
1874
|
+
await new OutputHandler().writeOutput(formattedOutput, cliOptions.out);
|
|
1875
|
+
if (cliOptions.verbose && cliOptions.out) console.log(`✅ Output written to: ${cliOptions.out}`);
|
|
1876
|
+
if (logger) {
|
|
1877
|
+
const totalTime = logger.timeEnd("total-execution");
|
|
1878
|
+
const stats = logger.getStats();
|
|
1879
|
+
console.error("\n📊 Performance Summary:");
|
|
1880
|
+
console.error(` Total time: ${totalTime.toFixed(2)}ms`);
|
|
1881
|
+
console.error(` Peak memory: ${(stats.memoryUsage.peakUsage / 1024 / 1024).toFixed(2)}MB`);
|
|
1882
|
+
console.error(` Total logs: ${stats.totalLogs}`);
|
|
1883
|
+
}
|
|
1884
|
+
} catch (error) {
|
|
1885
|
+
if (error instanceof CliError) ErrorHandler.handleError(error, options.verbose);
|
|
1886
|
+
else if (error instanceof Error) {
|
|
1887
|
+
const cliError = ErrorHandler.createError(error.message, "INTERNAL_ERROR", void 0, { originalError: error.name });
|
|
1888
|
+
ErrorHandler.handleError(cliError, options.verbose);
|
|
1889
|
+
} else {
|
|
1890
|
+
const cliError = ErrorHandler.createError("An unexpected error occurred", "INTERNAL_ERROR", void 0, { error: String(error) });
|
|
1891
|
+
ErrorHandler.handleError(cliError, options.verbose);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
});
|
|
1895
|
+
process.on("unhandledRejection", (reason, promise) => {
|
|
1896
|
+
const error = ErrorHandler.createError(`Unhandled promise rejection: ${reason}`, "INTERNAL_ERROR", void 0, { promise: String(promise) });
|
|
1897
|
+
ErrorHandler.handleError(error, false);
|
|
1898
|
+
});
|
|
1899
|
+
process.on("uncaughtException", (error) => {
|
|
1900
|
+
const cliError = ErrorHandler.createError(`Uncaught exception: ${error.message}`, "INTERNAL_ERROR", void 0, { stack: error.stack });
|
|
1901
|
+
ErrorHandler.handleError(cliError, true);
|
|
1902
|
+
});
|
|
1903
|
+
const argv = process.argv.length <= 2 ? [...process.argv, "--help"] : process.argv;
|
|
1904
|
+
program.parse(argv);
|
|
1905
|
+
|
|
1906
|
+
//#endregion
|
|
1907
|
+
//# sourceMappingURL=index.cjs.map
|