ng-di-graph 0.5.0 โ 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -9
- package/dist/cli/index.cjs +285 -50
- package/dist/cli/index.cjs.map +1 -1
- package/dist/core/parser.d.ts +17 -0
- package/dist/formatters/text-formatter.d.ts +9 -0
- package/dist/types/index.d.ts +22 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,8 +19,8 @@ npm install -g ng-di-graph
|
|
|
19
19
|
|
|
20
20
|
## Quick start
|
|
21
21
|
```bash
|
|
22
|
-
# Analyze the whole project and print
|
|
23
|
-
ng-di-graph
|
|
22
|
+
# Analyze the whole project and print text output (default)
|
|
23
|
+
ng-di-graph
|
|
24
24
|
|
|
25
25
|
# Generate a Mermaid diagram and save it
|
|
26
26
|
ng-di-graph --format mermaid --out docs/di-graph.mmd
|
|
@@ -45,13 +45,16 @@ ng-di-graph --project ./tsconfig.json --format json
|
|
|
45
45
|
- ๐ **Dependency Analysis** - Extract DI relationships from `@Injectable`, `@Component`, and `@Directive` classes
|
|
46
46
|
- ๐ฏ **Constructor Injection** - Analyze constructor parameters with type annotations and `@Inject()` tokens
|
|
47
47
|
- ๐ท๏ธ **Decorator Flags** - Capture `@Optional`, `@Self`, `@SkipSelf`, and `@Host` parameter decorators
|
|
48
|
-
- ๐ **Multiple Output Formats** - JSON (machine-readable)
|
|
48
|
+
- ๐ **Multiple Output Formats** - Text (default), JSON (machine-readable), Mermaid (visual flowcharts)
|
|
49
49
|
- ๐จ **Entry Point Filtering** - Generate sub-graphs from specific starting nodes
|
|
50
50
|
- ๐ **Bidirectional Analysis** - Explore upstream dependencies, downstream consumers, or both
|
|
51
51
|
- ๐ **Circular Detection** - Automatically detect and report circular dependencies
|
|
52
|
+
- ๐งน **Angular Core Filtering** - Hide `@angular/core` nodes by default (`--include-angular-core` to show)
|
|
52
53
|
|
|
53
54
|
## Common commands
|
|
54
|
-
- Full project,
|
|
55
|
+
- Full project, text output (default):
|
|
56
|
+
`ng-di-graph`
|
|
57
|
+
- JSON output to stdout:
|
|
55
58
|
`ng-di-graph --format json`
|
|
56
59
|
- Mermaid diagram to file:
|
|
57
60
|
`ng-di-graph --format mermaid --out docs/di-graph.mmd`
|
|
@@ -73,25 +76,58 @@ Option | Default | Description
|
|
|
73
76
|
`filePaths` | none | Positional alias for `--files`; combines with `--files` when both are set.
|
|
74
77
|
`-p, --project <path>` | auto-discovered | Path to the TypeScript config file to analyze (omit to auto-discover).
|
|
75
78
|
`--files <paths...>` | none | Limit analysis to specific files or directories.
|
|
76
|
-
`-f, --format <format>` | `
|
|
79
|
+
`-f, --format <format>` | `text` | Output as `text`, `json`, or `mermaid`.
|
|
77
80
|
`-e, --entry <symbol...>` | none | Start the graph from one or more symbols.
|
|
78
81
|
`-d, --direction <dir>` | `downstream` | `downstream`, `upstream`, or `both` relative to entries.
|
|
79
82
|
`--include-decorators` | `false` | Add `@Optional`, `@Self`, `@SkipSelf`, `@Host` flags to edges.
|
|
83
|
+
`--include-angular-core` | `false` | Include nodes that originate from `@angular/core` imports.
|
|
80
84
|
`--out <file>` | stdout | Write output to a file.
|
|
81
85
|
`-v, --verbose` | `false` | Show detailed parsing and resolution logs.
|
|
82
86
|
`-h, --help` | `false` | Display CLI help.
|
|
83
87
|
|
|
84
88
|
## Output Formats
|
|
85
89
|
|
|
90
|
+
### Text Format (default)
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
Project: ./tsconfig.json
|
|
94
|
+
Scope: direction=downstream, entry=all
|
|
95
|
+
Files: 12 (skipped: 0)
|
|
96
|
+
|
|
97
|
+
Dependencies (A depends on B):
|
|
98
|
+
AppComponent
|
|
99
|
+
โโ UserService
|
|
100
|
+
|
|
101
|
+
UserService
|
|
102
|
+
โโ AuthService
|
|
103
|
+
โโ Logger
|
|
104
|
+
```
|
|
105
|
+
|
|
86
106
|
### JSON Format
|
|
87
107
|
|
|
88
108
|
```json
|
|
89
109
|
{
|
|
90
110
|
"nodes": [
|
|
91
|
-
{
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
111
|
+
{
|
|
112
|
+
"id": "AppComponent",
|
|
113
|
+
"kind": "component",
|
|
114
|
+
"source": { "filePath": "/path/to/src/app.component.ts", "line": 12, "column": 14 }
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"id": "UserService",
|
|
118
|
+
"kind": "service",
|
|
119
|
+
"source": { "filePath": "/path/to/src/user.service.ts", "line": 8, "column": 14 }
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"id": "AuthService",
|
|
123
|
+
"kind": "service",
|
|
124
|
+
"source": { "filePath": "/path/to/src/auth.service.ts", "line": 20, "column": 14 }
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"id": "Logger",
|
|
128
|
+
"kind": "service",
|
|
129
|
+
"source": { "filePath": "/path/to/src/logger.service.ts", "line": 5, "column": 14 }
|
|
130
|
+
}
|
|
95
131
|
],
|
|
96
132
|
"edges": [
|
|
97
133
|
{
|
|
@@ -119,6 +155,11 @@ Option | Default | Description
|
|
|
119
155
|
- `directive` - Classes decorated with `@Directive()`
|
|
120
156
|
- `unknown` - Could not determine decorator type
|
|
121
157
|
|
|
158
|
+
**Node Source** (when available):
|
|
159
|
+
- `source.filePath` - Absolute file path reported by the TypeScript project
|
|
160
|
+
- `source.line` / `source.column` - 1-based position of the class name token
|
|
161
|
+
- `source` is omitted for `unknown` nodes created from unresolved tokens
|
|
162
|
+
|
|
122
163
|
**Edge Flags** (when `--include-decorators` is used):
|
|
123
164
|
- `optional` - Parameter has `@Optional()` decorator
|
|
124
165
|
- `self` - Parameter has `@Self()` decorator
|
package/dist/cli/index.cjs
CHANGED
|
@@ -378,6 +378,9 @@ function validateInput(parsedClasses) {
|
|
|
378
378
|
if (typeof parsedClass.kind !== "string") throw new Error("ParsedClass must have a valid kind property");
|
|
379
379
|
if (parsedClass.dependencies == null) throw new Error("ParsedClass must have a dependencies array");
|
|
380
380
|
if (!Array.isArray(parsedClass.dependencies)) throw new Error("ParsedClass dependencies must be an array");
|
|
381
|
+
if (!parsedClass.source) throw new Error("ParsedClass must have a valid source property");
|
|
382
|
+
if (typeof parsedClass.source.filePath !== "string" || parsedClass.source.filePath === "") throw new Error("ParsedClass source must have a valid filePath");
|
|
383
|
+
if (!Number.isFinite(parsedClass.source.line) || !Number.isFinite(parsedClass.source.column) || parsedClass.source.line < 1 || parsedClass.source.column < 1) throw new Error("ParsedClass source must include valid line and column values");
|
|
381
384
|
for (const dependency of parsedClass.dependencies) if (typeof dependency.token !== "string") throw new Error("ParsedDependency must have a valid token property");
|
|
382
385
|
}
|
|
383
386
|
}
|
|
@@ -441,21 +444,27 @@ function buildGraph(parsedClasses, logger) {
|
|
|
441
444
|
const edges = [];
|
|
442
445
|
for (const parsedClass of parsedClasses) if (!nodeMap.has(parsedClass.name)) nodeMap.set(parsedClass.name, {
|
|
443
446
|
id: parsedClass.name,
|
|
444
|
-
kind: parsedClass.kind
|
|
447
|
+
kind: parsedClass.kind,
|
|
448
|
+
source: parsedClass.source
|
|
445
449
|
});
|
|
446
450
|
logger?.info(LogCategory.GRAPH_CONSTRUCTION, `Created ${nodeMap.size} nodes`, { nodeCount: nodeMap.size });
|
|
447
451
|
let unknownNodeCount = 0;
|
|
448
452
|
for (const parsedClass of parsedClasses) for (const dependency of parsedClass.dependencies) {
|
|
449
453
|
if (!nodeMap.has(dependency.token)) {
|
|
450
|
-
|
|
454
|
+
const unknownNode = {
|
|
451
455
|
id: dependency.token,
|
|
452
456
|
kind: "unknown"
|
|
453
|
-
}
|
|
457
|
+
};
|
|
458
|
+
if (dependency.origin) unknownNode.origin = dependency.origin;
|
|
459
|
+
nodeMap.set(dependency.token, unknownNode);
|
|
454
460
|
unknownNodeCount++;
|
|
455
461
|
logger?.warn(LogCategory.GRAPH_CONSTRUCTION, `Created unknown node: ${dependency.token}`, {
|
|
456
462
|
nodeId: dependency.token,
|
|
457
463
|
referencedBy: parsedClass.name
|
|
458
464
|
});
|
|
465
|
+
} else {
|
|
466
|
+
const existingNode = nodeMap.get(dependency.token);
|
|
467
|
+
if (existingNode && existingNode.kind === "unknown" && !existingNode.origin && dependency.origin) existingNode.origin = dependency.origin;
|
|
459
468
|
}
|
|
460
469
|
const edge = {
|
|
461
470
|
from: parsedClass.name,
|
|
@@ -527,24 +536,57 @@ function validateAndTraverseEntryPoints(entryPoints, graph, adjacencyList, resul
|
|
|
527
536
|
* @returns Filtered graph containing only nodes reachable from entry points
|
|
528
537
|
*/
|
|
529
538
|
function filterGraph(graph, options, logger) {
|
|
530
|
-
|
|
539
|
+
const baseGraph = filterGraphByOrigin(graph, options.includeAngularCore ?? false);
|
|
540
|
+
if (!options.entry || options.entry.length === 0) {
|
|
541
|
+
const includedNodeIds$1 = new Set(baseGraph.nodes.map((node) => node.id));
|
|
542
|
+
return {
|
|
543
|
+
nodes: baseGraph.nodes,
|
|
544
|
+
edges: baseGraph.edges,
|
|
545
|
+
circularDependencies: filterCircularDependencies(baseGraph, includedNodeIds$1)
|
|
546
|
+
};
|
|
547
|
+
}
|
|
531
548
|
const includedNodeIds = /* @__PURE__ */ new Set();
|
|
532
549
|
if (options.direction === "both") {
|
|
533
|
-
const upstreamAdjacencyList = buildAdjacencyList(
|
|
550
|
+
const upstreamAdjacencyList = buildAdjacencyList(baseGraph, "upstream");
|
|
534
551
|
const upstreamNodes = /* @__PURE__ */ new Set();
|
|
535
|
-
validateAndTraverseEntryPoints(options.entry,
|
|
536
|
-
const downstreamAdjacencyList = buildAdjacencyList(
|
|
552
|
+
validateAndTraverseEntryPoints(options.entry, baseGraph, upstreamAdjacencyList, upstreamNodes, options, logger);
|
|
553
|
+
const downstreamAdjacencyList = buildAdjacencyList(baseGraph, "downstream");
|
|
537
554
|
const downstreamNodes = /* @__PURE__ */ new Set();
|
|
538
|
-
validateAndTraverseEntryPoints(options.entry,
|
|
555
|
+
validateAndTraverseEntryPoints(options.entry, baseGraph, downstreamAdjacencyList, downstreamNodes, options, logger);
|
|
539
556
|
const combinedNodes = new Set([...upstreamNodes, ...downstreamNodes]);
|
|
540
557
|
for (const nodeId of combinedNodes) includedNodeIds.add(nodeId);
|
|
541
558
|
} else {
|
|
542
|
-
const adjacencyList = buildAdjacencyList(
|
|
543
|
-
validateAndTraverseEntryPoints(options.entry,
|
|
559
|
+
const adjacencyList = buildAdjacencyList(baseGraph, options.direction);
|
|
560
|
+
validateAndTraverseEntryPoints(options.entry, baseGraph, adjacencyList, includedNodeIds, options, logger);
|
|
561
|
+
}
|
|
562
|
+
const filteredNodes = baseGraph.nodes.filter((node) => includedNodeIds.has(node.id));
|
|
563
|
+
const filteredEdges = baseGraph.edges.filter((edge) => includedNodeIds.has(edge.from) && includedNodeIds.has(edge.to));
|
|
564
|
+
const filteredCircularDeps = filterCircularDependencies(baseGraph, includedNodeIds);
|
|
565
|
+
if (options.verbose && logger) {
|
|
566
|
+
logger.info(LogCategory.FILTERING, "Filtered graph summary", {
|
|
567
|
+
nodeCount: filteredNodes.length,
|
|
568
|
+
edgeCount: filteredEdges.length
|
|
569
|
+
});
|
|
570
|
+
logger.debug(LogCategory.FILTERING, "Entry points applied", { entryPoints: options.entry });
|
|
544
571
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
572
|
+
return {
|
|
573
|
+
nodes: filteredNodes,
|
|
574
|
+
edges: filteredEdges,
|
|
575
|
+
circularDependencies: filteredCircularDeps
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function filterGraphByOrigin(graph, includeAngularCore) {
|
|
579
|
+
if (includeAngularCore) return graph;
|
|
580
|
+
const filteredNodes = graph.nodes.filter((node) => node.origin !== "angular-core");
|
|
581
|
+
const includedNodeIds = new Set(filteredNodes.map((node) => node.id));
|
|
582
|
+
return {
|
|
583
|
+
nodes: filteredNodes,
|
|
584
|
+
edges: graph.edges.filter((edge) => includedNodeIds.has(edge.from) && includedNodeIds.has(edge.to)),
|
|
585
|
+
circularDependencies: graph.circularDependencies
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function filterCircularDependencies(graph, includedNodeIds) {
|
|
589
|
+
return graph.circularDependencies.filter((cycle) => {
|
|
548
590
|
if (cycle.length < 2) return false;
|
|
549
591
|
const isSelfLoop = cycle.length === 2 && cycle[0] === cycle[1];
|
|
550
592
|
const isProperCycleWithClosing = cycle.length >= 3 && cycle[0] === cycle[cycle.length - 1];
|
|
@@ -559,18 +601,6 @@ function filterGraph(graph, options, logger) {
|
|
|
559
601
|
}
|
|
560
602
|
return true;
|
|
561
603
|
});
|
|
562
|
-
if (options.verbose && logger) {
|
|
563
|
-
logger.info(LogCategory.FILTERING, "Filtered graph summary", {
|
|
564
|
-
nodeCount: filteredNodes.length,
|
|
565
|
-
edgeCount: filteredEdges.length
|
|
566
|
-
});
|
|
567
|
-
logger.debug(LogCategory.FILTERING, "Entry points applied", { entryPoints: options.entry });
|
|
568
|
-
}
|
|
569
|
-
return {
|
|
570
|
-
nodes: filteredNodes,
|
|
571
|
-
edges: filteredEdges,
|
|
572
|
-
circularDependencies: filteredCircularDeps
|
|
573
|
-
};
|
|
574
604
|
}
|
|
575
605
|
/**
|
|
576
606
|
* Traverses the graph from a starting node using DFS
|
|
@@ -646,6 +676,7 @@ var OutputHandler = class {
|
|
|
646
676
|
* Implements FR-03: Constructor token resolution
|
|
647
677
|
*/
|
|
648
678
|
const GLOBAL_WARNING_KEYS = /* @__PURE__ */ new Set();
|
|
679
|
+
const ANGULAR_CORE_MODULE = "@angular/core";
|
|
649
680
|
const formatTsDiagnostics = (diagnostics) => diagnostics.map((diagnostic) => {
|
|
650
681
|
const message = typescript.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
|
|
651
682
|
if (diagnostic.file && diagnostic.start !== void 0) {
|
|
@@ -660,6 +691,12 @@ var AngularParser = class {
|
|
|
660
691
|
this._logger = _logger;
|
|
661
692
|
this._typeResolutionCache = /* @__PURE__ */ new Map();
|
|
662
693
|
this._circularTypeRefs = /* @__PURE__ */ new Set();
|
|
694
|
+
this._angularCoreImportCache = /* @__PURE__ */ new Map();
|
|
695
|
+
this._angularCoreAliasMatchers = [];
|
|
696
|
+
this._processingStats = {
|
|
697
|
+
processedFileCount: 0,
|
|
698
|
+
skippedFileCount: 0
|
|
699
|
+
};
|
|
663
700
|
this._structuredWarnings = {
|
|
664
701
|
categories: {
|
|
665
702
|
typeResolution: [],
|
|
@@ -710,6 +747,12 @@ var AngularParser = class {
|
|
|
710
747
|
};
|
|
711
748
|
}
|
|
712
749
|
/**
|
|
750
|
+
* Get file processing stats from the most recent parse run.
|
|
751
|
+
*/
|
|
752
|
+
getProcessingStats() {
|
|
753
|
+
return { ...this._processingStats };
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
713
756
|
* Add structured warning to collection (Task 3.3)
|
|
714
757
|
* Includes global deduplication for both structured warnings and console output
|
|
715
758
|
* @param category Warning category
|
|
@@ -759,6 +802,8 @@ var AngularParser = class {
|
|
|
759
802
|
const message = formatTsDiagnostics(parsedConfig.errors);
|
|
760
803
|
throw ErrorHandler.createError(`TypeScript configuration error: ${message}`, "PROJECT_LOAD_FAILED", this._options.project, { diagnosticCount: parsedConfig.errors.length });
|
|
761
804
|
}
|
|
805
|
+
this.cacheAngularCoreAliases(configFile.config);
|
|
806
|
+
this._angularCoreImportCache.clear();
|
|
762
807
|
this._project = new ts_morph.Project({ tsConfigFilePath: tsConfigPath });
|
|
763
808
|
if (!this._project) throw ErrorHandler.createError("Failed to load TypeScript project", "PROJECT_LOAD_FAILED", this._options.project);
|
|
764
809
|
} catch (error) {
|
|
@@ -898,6 +943,10 @@ var AngularParser = class {
|
|
|
898
943
|
const sourceFiles = this.getTargetSourceFiles();
|
|
899
944
|
let processedFiles = 0;
|
|
900
945
|
let skippedFiles = 0;
|
|
946
|
+
this._processingStats = {
|
|
947
|
+
processedFileCount: 0,
|
|
948
|
+
skippedFileCount: 0
|
|
949
|
+
};
|
|
901
950
|
this._circularTypeRefs.clear();
|
|
902
951
|
this._logger?.time("findDecoratedClasses");
|
|
903
952
|
this._logger?.info(LogCategory.FILE_PROCESSING, "Starting file processing", { fileCount: sourceFiles.length });
|
|
@@ -948,6 +997,10 @@ var AngularParser = class {
|
|
|
948
997
|
timing: elapsed
|
|
949
998
|
});
|
|
950
999
|
if (decoratedClasses.length === 0) ErrorHandler.warn("No decorated classes found in the project");
|
|
1000
|
+
this._processingStats = {
|
|
1001
|
+
processedFileCount: processedFiles,
|
|
1002
|
+
skippedFileCount: skippedFiles
|
|
1003
|
+
};
|
|
951
1004
|
return decoratedClasses;
|
|
952
1005
|
}
|
|
953
1006
|
/**
|
|
@@ -963,6 +1016,13 @@ var AngularParser = class {
|
|
|
963
1016
|
else ErrorHandler.warn(message);
|
|
964
1017
|
return null;
|
|
965
1018
|
}
|
|
1019
|
+
const nameNode = classDeclaration.getNameNode();
|
|
1020
|
+
if (!nameNode) {
|
|
1021
|
+
const message = "Skipping class without name node - classes must be named for dependency injection analysis";
|
|
1022
|
+
if (this._logger) this._logger.warn(LogCategory.AST_ANALYSIS, message, { className });
|
|
1023
|
+
else ErrorHandler.warn(message);
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
966
1026
|
const decorators = classDeclaration.getDecorators();
|
|
967
1027
|
if (this._options.verbose) {
|
|
968
1028
|
const decoratorNames = decorators.map((d) => this.getDecoratorName(d)).join(", ");
|
|
@@ -980,10 +1040,19 @@ var AngularParser = class {
|
|
|
980
1040
|
});
|
|
981
1041
|
return null;
|
|
982
1042
|
}
|
|
1043
|
+
const nodeKind = this.determineNodeKind(angularDecorator);
|
|
1044
|
+
const sourceFile = classDeclaration.getSourceFile();
|
|
1045
|
+
const filePath = sourceFile.getFilePath();
|
|
1046
|
+
const { line, column } = sourceFile.getLineAndColumnAtPos(nameNode.getStart());
|
|
983
1047
|
return {
|
|
984
1048
|
name: className,
|
|
985
|
-
kind:
|
|
986
|
-
filePath
|
|
1049
|
+
kind: nodeKind,
|
|
1050
|
+
filePath,
|
|
1051
|
+
source: {
|
|
1052
|
+
filePath,
|
|
1053
|
+
line,
|
|
1054
|
+
column
|
|
1055
|
+
},
|
|
987
1056
|
dependencies: this.extractConstructorDependencies(classDeclaration)
|
|
988
1057
|
};
|
|
989
1058
|
}
|
|
@@ -1023,15 +1092,79 @@ var AngularParser = class {
|
|
|
1023
1092
|
*/
|
|
1024
1093
|
resolveDecoratorAlias(sourceFile, decoratorName) {
|
|
1025
1094
|
const importDeclarations = sourceFile.getImportDeclarations();
|
|
1026
|
-
for (const importDecl of importDeclarations)
|
|
1095
|
+
for (const importDecl of importDeclarations) {
|
|
1096
|
+
const moduleSpecifier = importDecl.getModuleSpecifierValue();
|
|
1097
|
+
if (this.isAngularCoreModuleSpecifier(moduleSpecifier)) {
|
|
1098
|
+
const namedImports = importDecl.getNamedImports();
|
|
1099
|
+
for (const namedImport of namedImports) {
|
|
1100
|
+
const alias = namedImport.getAliasNode();
|
|
1101
|
+
if (alias && alias.getText() === decoratorName) return namedImport.getName();
|
|
1102
|
+
if (!alias && namedImport.getName() === decoratorName) return decoratorName;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
cacheAngularCoreAliases(tsConfig) {
|
|
1109
|
+
this._angularCoreAliasMatchers = [];
|
|
1110
|
+
if (!tsConfig || typeof tsConfig !== "object") return;
|
|
1111
|
+
const configRecord = tsConfig;
|
|
1112
|
+
const paths = (typeof configRecord.compilerOptions === "object" && configRecord.compilerOptions ? configRecord.compilerOptions : void 0)?.paths;
|
|
1113
|
+
if (!paths) return;
|
|
1114
|
+
for (const [alias, targets] of Object.entries(paths)) {
|
|
1115
|
+
if (!Array.isArray(targets)) continue;
|
|
1116
|
+
if (!targets.some((target) => this.isAngularCorePathTarget(target))) continue;
|
|
1117
|
+
this._angularCoreAliasMatchers.push(this.buildAliasMatcher(alias));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
buildAliasMatcher(alias) {
|
|
1121
|
+
const pattern = alias.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*");
|
|
1122
|
+
return /* @__PURE__ */ new RegExp(`^${pattern}$`);
|
|
1123
|
+
}
|
|
1124
|
+
isAngularCorePathTarget(target) {
|
|
1125
|
+
const normalized = target.replace(/\\/g, "/");
|
|
1126
|
+
return normalized.includes(`${ANGULAR_CORE_MODULE}/`) || normalized.includes(ANGULAR_CORE_MODULE);
|
|
1127
|
+
}
|
|
1128
|
+
isAngularCoreModuleSpecifier(moduleSpecifier) {
|
|
1129
|
+
if (moduleSpecifier === ANGULAR_CORE_MODULE || moduleSpecifier.startsWith(`${ANGULAR_CORE_MODULE}/`)) return true;
|
|
1130
|
+
return this._angularCoreAliasMatchers.some((matcher) => matcher.test(moduleSpecifier));
|
|
1131
|
+
}
|
|
1132
|
+
getAngularCoreImportMap(sourceFile) {
|
|
1133
|
+
const cacheKey = sourceFile.getFilePath();
|
|
1134
|
+
const cached = this._angularCoreImportCache.get(cacheKey);
|
|
1135
|
+
if (cached) return cached;
|
|
1136
|
+
const named = /* @__PURE__ */ new Set();
|
|
1137
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
1138
|
+
const importDeclarations = sourceFile.getImportDeclarations();
|
|
1139
|
+
for (const importDecl of importDeclarations) {
|
|
1140
|
+
const moduleSpecifier = importDecl.getModuleSpecifierValue();
|
|
1141
|
+
if (!this.isAngularCoreModuleSpecifier(moduleSpecifier)) continue;
|
|
1027
1142
|
const namedImports = importDecl.getNamedImports();
|
|
1028
1143
|
for (const namedImport of namedImports) {
|
|
1029
1144
|
const alias = namedImport.getAliasNode();
|
|
1030
|
-
|
|
1031
|
-
if (!alias && namedImport.getName() === decoratorName) return decoratorName;
|
|
1145
|
+
named.add(alias ? alias.getText() : namedImport.getName());
|
|
1032
1146
|
}
|
|
1147
|
+
const namespaceImport = importDecl.getNamespaceImport();
|
|
1148
|
+
if (namespaceImport) namespaces.add(namespaceImport.getText());
|
|
1033
1149
|
}
|
|
1034
|
-
|
|
1150
|
+
const map = {
|
|
1151
|
+
named,
|
|
1152
|
+
namespaces
|
|
1153
|
+
};
|
|
1154
|
+
this._angularCoreImportCache.set(cacheKey, map);
|
|
1155
|
+
return map;
|
|
1156
|
+
}
|
|
1157
|
+
resolveDependencyOrigin(token, sourceFile) {
|
|
1158
|
+
const importMap = this.getAngularCoreImportMap(sourceFile);
|
|
1159
|
+
const normalizedToken = this.normalizeTokenForOrigin(token);
|
|
1160
|
+
if (normalizedToken.includes(".")) {
|
|
1161
|
+
const namespace = normalizedToken.split(".")[0];
|
|
1162
|
+
if (importMap.namespaces.has(namespace)) return "angular-core";
|
|
1163
|
+
}
|
|
1164
|
+
if (importMap.named.has(normalizedToken)) return "angular-core";
|
|
1165
|
+
}
|
|
1166
|
+
normalizeTokenForOrigin(token) {
|
|
1167
|
+
return token.split("<")[0].replace(/\[\]$/, "");
|
|
1035
1168
|
}
|
|
1036
1169
|
/**
|
|
1037
1170
|
* Determine NodeKind from Angular decorator
|
|
@@ -1370,7 +1503,8 @@ var AngularParser = class {
|
|
|
1370
1503
|
*/
|
|
1371
1504
|
parseConstructorParameter(param) {
|
|
1372
1505
|
const parameterName = param.getName();
|
|
1373
|
-
const
|
|
1506
|
+
const sourceFile = param.getSourceFile();
|
|
1507
|
+
const filePath = sourceFile.getFilePath();
|
|
1374
1508
|
const lineNumber = param.getStartLineNumber();
|
|
1375
1509
|
const columnNumber = param.getStart() - param.getStartLinePos();
|
|
1376
1510
|
const startTime = performance.now();
|
|
@@ -1383,7 +1517,8 @@ var AngularParser = class {
|
|
|
1383
1517
|
if (token) return {
|
|
1384
1518
|
token,
|
|
1385
1519
|
flags,
|
|
1386
|
-
parameterName
|
|
1520
|
+
parameterName,
|
|
1521
|
+
origin: this.resolveDependencyOrigin(token, sourceFile)
|
|
1387
1522
|
};
|
|
1388
1523
|
}
|
|
1389
1524
|
const initializer = param.getInitializer();
|
|
@@ -1394,7 +1529,8 @@ var AngularParser = class {
|
|
|
1394
1529
|
return {
|
|
1395
1530
|
token: injectResult.token,
|
|
1396
1531
|
flags: finalFlags,
|
|
1397
|
-
parameterName
|
|
1532
|
+
parameterName,
|
|
1533
|
+
origin: this.resolveDependencyOrigin(injectResult.token, sourceFile)
|
|
1398
1534
|
};
|
|
1399
1535
|
}
|
|
1400
1536
|
}
|
|
@@ -1404,7 +1540,8 @@ var AngularParser = class {
|
|
|
1404
1540
|
if (token) return {
|
|
1405
1541
|
token,
|
|
1406
1542
|
flags,
|
|
1407
|
-
parameterName
|
|
1543
|
+
parameterName,
|
|
1544
|
+
origin: this.resolveDependencyOrigin(token, sourceFile)
|
|
1408
1545
|
};
|
|
1409
1546
|
}
|
|
1410
1547
|
const type = param.getType();
|
|
@@ -1431,7 +1568,8 @@ var AngularParser = class {
|
|
|
1431
1568
|
if (resolvedToken) return {
|
|
1432
1569
|
token: resolvedToken,
|
|
1433
1570
|
flags,
|
|
1434
|
-
parameterName
|
|
1571
|
+
parameterName,
|
|
1572
|
+
origin: this.resolveDependencyOrigin(resolvedToken, sourceFile)
|
|
1435
1573
|
};
|
|
1436
1574
|
return null;
|
|
1437
1575
|
} finally {
|
|
@@ -1552,7 +1690,8 @@ var AngularParser = class {
|
|
|
1552
1690
|
return {
|
|
1553
1691
|
token,
|
|
1554
1692
|
flags,
|
|
1555
|
-
parameterName: propertyName
|
|
1693
|
+
parameterName: propertyName,
|
|
1694
|
+
origin: this.resolveDependencyOrigin(token, property.getSourceFile())
|
|
1556
1695
|
};
|
|
1557
1696
|
} catch (error) {
|
|
1558
1697
|
if (this._options.verbose) {
|
|
@@ -1681,12 +1820,15 @@ var AngularParser = class {
|
|
|
1681
1820
|
isAngularInjectImported(sourceFile) {
|
|
1682
1821
|
try {
|
|
1683
1822
|
const importDeclarations = sourceFile.getImportDeclarations();
|
|
1684
|
-
for (const importDecl of importDeclarations)
|
|
1685
|
-
const
|
|
1686
|
-
|
|
1687
|
-
const
|
|
1688
|
-
const
|
|
1689
|
-
|
|
1823
|
+
for (const importDecl of importDeclarations) {
|
|
1824
|
+
const moduleSpecifier = importDecl.getModuleSpecifierValue();
|
|
1825
|
+
if (this.isAngularCoreModuleSpecifier(moduleSpecifier)) {
|
|
1826
|
+
const namedImports = importDecl.getNamedImports();
|
|
1827
|
+
for (const namedImport of namedImports) {
|
|
1828
|
+
const importName = namedImport.getName();
|
|
1829
|
+
const alias = namedImport.getAliasNode();
|
|
1830
|
+
if (importName === "inject" || alias && alias.getText() === "inject") return true;
|
|
1831
|
+
}
|
|
1690
1832
|
}
|
|
1691
1833
|
}
|
|
1692
1834
|
return false;
|
|
@@ -1899,7 +2041,11 @@ var JsonFormatter = class {
|
|
|
1899
2041
|
nodeCount: graph.nodes.length,
|
|
1900
2042
|
edgeCount: graph.edges.length
|
|
1901
2043
|
});
|
|
1902
|
-
const
|
|
2044
|
+
const sanitizedGraph = {
|
|
2045
|
+
...graph,
|
|
2046
|
+
nodes: graph.nodes.map(({ origin, ...node }) => node)
|
|
2047
|
+
};
|
|
2048
|
+
const result = JSON.stringify(sanitizedGraph, null, 2);
|
|
1903
2049
|
const elapsed = this._logger?.timeEnd("json-format") ?? 0;
|
|
1904
2050
|
this._logger?.info(LogCategory.PERFORMANCE, "JSON output complete", {
|
|
1905
2051
|
outputSize: result.length,
|
|
@@ -1974,6 +2120,79 @@ var MermaidFormatter = class {
|
|
|
1974
2120
|
}
|
|
1975
2121
|
};
|
|
1976
2122
|
|
|
2123
|
+
//#endregion
|
|
2124
|
+
//#region src/formatters/text-formatter.ts
|
|
2125
|
+
var TextFormatter = class {
|
|
2126
|
+
constructor(context = {}, logger) {
|
|
2127
|
+
this._context = context;
|
|
2128
|
+
this._logger = logger;
|
|
2129
|
+
}
|
|
2130
|
+
format(graph) {
|
|
2131
|
+
this._logger?.time("text-format");
|
|
2132
|
+
this._logger?.info(LogCategory.PERFORMANCE, "Generating text output", {
|
|
2133
|
+
nodeCount: graph.nodes.length,
|
|
2134
|
+
edgeCount: graph.edges.length
|
|
2135
|
+
});
|
|
2136
|
+
const warningCount = this._context.warningCount ?? this._context.warnings?.length ?? 0;
|
|
2137
|
+
const circularDependencyCount = graph.circularDependencies.length;
|
|
2138
|
+
const lines = [];
|
|
2139
|
+
if (this._context.projectPath) lines.push(`Project: ${this._context.projectPath}`);
|
|
2140
|
+
if (this._context.direction) {
|
|
2141
|
+
const entry = this._context.entry && this._context.entry.length > 0 ? this._context.entry.join(", ") : "all";
|
|
2142
|
+
lines.push(`Scope: direction=${this._context.direction}, entry=${entry}`);
|
|
2143
|
+
}
|
|
2144
|
+
lines.push(`Files: ${this._context.processedFileCount ?? 0} (skipped: ${this._context.skippedFileCount ?? 0})`);
|
|
2145
|
+
if (warningCount > 0) lines.push(`Warnings: ${warningCount}`);
|
|
2146
|
+
if (circularDependencyCount > 0) lines.push(`Circular dependencies: ${circularDependencyCount}`);
|
|
2147
|
+
const dependencyGroups = this.getDependencyGroups(graph, 10);
|
|
2148
|
+
if (dependencyGroups.length > 0) {
|
|
2149
|
+
lines.push("");
|
|
2150
|
+
lines.push("Dependencies (A depends on B):");
|
|
2151
|
+
lines.push(...dependencyGroups);
|
|
2152
|
+
}
|
|
2153
|
+
const result = `${lines.join("\n")}\n`;
|
|
2154
|
+
const elapsed = this._logger?.timeEnd("text-format") ?? 0;
|
|
2155
|
+
this._logger?.info(LogCategory.PERFORMANCE, "Text output complete", {
|
|
2156
|
+
outputSize: result.length,
|
|
2157
|
+
elapsed
|
|
2158
|
+
});
|
|
2159
|
+
return result;
|
|
2160
|
+
}
|
|
2161
|
+
getDependencyGroups(graph, limit) {
|
|
2162
|
+
const uniqueEdges = /* @__PURE__ */ new Map();
|
|
2163
|
+
for (const edge of graph.edges) {
|
|
2164
|
+
const key = `${edge.from}->${edge.to}`;
|
|
2165
|
+
if (!uniqueEdges.has(key)) uniqueEdges.set(key, {
|
|
2166
|
+
from: edge.from,
|
|
2167
|
+
to: edge.to
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
const sortedEdges = [...uniqueEdges.values()].sort((a, b) => {
|
|
2171
|
+
const fromOrder = a.from.localeCompare(b.from);
|
|
2172
|
+
return fromOrder !== 0 ? fromOrder : a.to.localeCompare(b.to);
|
|
2173
|
+
}).slice(0, limit);
|
|
2174
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
2175
|
+
for (const edge of sortedEdges) {
|
|
2176
|
+
const deps = grouped.get(edge.from);
|
|
2177
|
+
if (deps) deps.push(edge.to);
|
|
2178
|
+
else grouped.set(edge.from, [edge.to]);
|
|
2179
|
+
}
|
|
2180
|
+
const lines = [];
|
|
2181
|
+
const groups = [...grouped.entries()];
|
|
2182
|
+
for (let i = 0; i < groups.length; i++) {
|
|
2183
|
+
const [from, deps] = groups[i];
|
|
2184
|
+
lines.push(from);
|
|
2185
|
+
for (let depIndex = 0; depIndex < deps.length; depIndex++) {
|
|
2186
|
+
const dep = deps[depIndex];
|
|
2187
|
+
const prefix = depIndex === deps.length - 1 ? "โโ" : "โโ";
|
|
2188
|
+
lines.push(`${prefix} ${dep}`);
|
|
2189
|
+
}
|
|
2190
|
+
if (i < groups.length - 1) lines.push("");
|
|
2191
|
+
}
|
|
2192
|
+
return lines;
|
|
2193
|
+
}
|
|
2194
|
+
};
|
|
2195
|
+
|
|
1977
2196
|
//#endregion
|
|
1978
2197
|
//#region src/cli/project-resolver.ts
|
|
1979
2198
|
const TSCONFIG_NAME = "tsconfig.json";
|
|
@@ -2168,7 +2387,7 @@ function enforceMinimumNodeVersion() {
|
|
|
2168
2387
|
enforceMinimumNodeVersion();
|
|
2169
2388
|
const program = new commander.Command();
|
|
2170
2389
|
program.name("ng-di-graph").description("Angular DI dependency graph CLI tool").version("0.1.0");
|
|
2171
|
-
program.argument("[filePaths...]", "TypeScript files to analyze (alias for --files)").option("-p, --project <path>", "tsconfig.json path (auto-discovered if omitted)").option("--files <paths...>", "specific file paths to analyze (similar to eslint targets)").option("-f, --format <format>", "output format: json | mermaid", "
|
|
2390
|
+
program.argument("[filePaths...]", "TypeScript files to analyze (alias for --files)").option("-p, --project <path>", "tsconfig.json path (auto-discovered if omitted)").option("--files <paths...>", "specific file paths to analyze (similar to eslint targets)").option("-f, --format <format>", "output format: text | json | mermaid", "text").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("--include-angular-core", "include @angular/core nodes", false).option("--out <file>", "output file (stdout if omitted)").option("-v, --verbose", "show detailed parsing information", false);
|
|
2172
2391
|
program.action(async (filePaths = [], options) => {
|
|
2173
2392
|
try {
|
|
2174
2393
|
const mergedFiles = mergeFileTargets(filePaths, options.files);
|
|
@@ -2184,7 +2403,11 @@ program.action(async (filePaths = [], options) => {
|
|
|
2184
2403
|
"downstream",
|
|
2185
2404
|
"both"
|
|
2186
2405
|
].includes(options.direction)) throw ErrorHandler.createError(`Invalid direction: ${options.direction}. Must be 'upstream', 'downstream', or 'both'`, "INVALID_ARGUMENTS");
|
|
2187
|
-
if (options.format && ![
|
|
2406
|
+
if (options.format && ![
|
|
2407
|
+
"json",
|
|
2408
|
+
"mermaid",
|
|
2409
|
+
"text"
|
|
2410
|
+
].includes(options.format)) throw ErrorHandler.createError(`Invalid format: ${options.format}. Must be 'text', 'json', or 'mermaid'`, "INVALID_ARGUMENTS");
|
|
2188
2411
|
const tsconfigLikePositional = filePaths.find((filePath) => /(?:^|[\\/])tsconfig(?:\.[^/\\]+)?\.json$/.test(filePath));
|
|
2189
2412
|
if (tsconfigLikePositional) throw ErrorHandler.createError(`Positional argument "${tsconfigLikePositional}" looks like a tsconfig. Use --project "${tsconfigLikePositional}" instead.`, "INVALID_ARGUMENTS");
|
|
2190
2413
|
const cliOptions = {
|
|
@@ -2194,6 +2417,7 @@ program.action(async (filePaths = [], options) => {
|
|
|
2194
2417
|
entry: options.entry,
|
|
2195
2418
|
direction: options.direction,
|
|
2196
2419
|
includeDecorators: options.includeDecorators,
|
|
2420
|
+
includeAngularCore: options.includeAngularCore,
|
|
2197
2421
|
out: options.out,
|
|
2198
2422
|
verbose: options.verbose
|
|
2199
2423
|
};
|
|
@@ -2222,14 +2446,25 @@ program.action(async (filePaths = [], options) => {
|
|
|
2222
2446
|
console.log(`โ
Graph built: ${graph.nodes.length} nodes, ${graph.edges.length} edges`);
|
|
2223
2447
|
if (graph.circularDependencies.length > 0) console.log(`โ ๏ธ Detected ${graph.circularDependencies.length} circular dependencies`);
|
|
2224
2448
|
}
|
|
2225
|
-
if (cliOptions.entry && cliOptions.entry.length > 0) {
|
|
2226
|
-
if (cliOptions.verbose) console.log(`๐ Filtering graph by entry points: ${cliOptions.entry.join(", ")}`);
|
|
2449
|
+
if (cliOptions.entry && cliOptions.entry.length > 0 || !cliOptions.includeAngularCore) {
|
|
2450
|
+
if (cliOptions.verbose && cliOptions.entry && cliOptions.entry.length > 0) console.log(`๐ Filtering graph by entry points: ${cliOptions.entry.join(", ")}`);
|
|
2227
2451
|
graph = filterGraph(graph, cliOptions, logger);
|
|
2228
|
-
if (cliOptions.verbose) console.log(`โ
Filtered graph: ${graph.nodes.length} nodes, ${graph.edges.length} edges`);
|
|
2452
|
+
if (cliOptions.verbose && cliOptions.entry && cliOptions.entry.length > 0) console.log(`โ
Filtered graph: ${graph.nodes.length} nodes, ${graph.edges.length} edges`);
|
|
2229
2453
|
}
|
|
2230
2454
|
let formatter;
|
|
2231
2455
|
if (cliOptions.format === "mermaid") formatter = new MermaidFormatter(logger);
|
|
2232
|
-
else formatter = new JsonFormatter(logger);
|
|
2456
|
+
else if (cliOptions.format === "json") formatter = new JsonFormatter(logger);
|
|
2457
|
+
else {
|
|
2458
|
+
const processingStats = parser.getProcessingStats();
|
|
2459
|
+
formatter = new TextFormatter({
|
|
2460
|
+
projectPath: cliOptions.project,
|
|
2461
|
+
direction: cliOptions.direction,
|
|
2462
|
+
entry: cliOptions.entry,
|
|
2463
|
+
processedFileCount: processingStats.processedFileCount,
|
|
2464
|
+
skippedFileCount: processingStats.skippedFileCount,
|
|
2465
|
+
warningCount: parser.getStructuredWarnings().totalCount
|
|
2466
|
+
}, logger);
|
|
2467
|
+
}
|
|
2233
2468
|
const formattedOutput = formatter.format(graph);
|
|
2234
2469
|
await new OutputHandler().writeOutput(formattedOutput, cliOptions.out);
|
|
2235
2470
|
if (cliOptions.verbose && cliOptions.out) console.log(`โ
Output written to: ${cliOptions.out}`);
|