depgraph-core 1.0.2 → 1.5.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/.vscode/depgraph-output.json +4634 -0
- package/README.md +208 -105
- package/depgraph-output.json +1609 -212
- package/depgraph.js +1666 -162
- package/docs/README.md +56 -0
- package/docs/architecture.md +89 -0
- package/docs/data-types.md +218 -0
- package/docs/language-registry.md +205 -0
- package/docs/stage-collector.md +109 -0
- package/docs/stage-graph.md +157 -0
- package/docs/stage-impact.md +154 -0
- package/docs/stage-metrics.md +113 -0
- package/docs/stage-output.md +144 -0
- package/docs/stage-parser.md +144 -0
- package/package.json +4 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Stage 3 — Graph Builder
|
|
2
|
+
|
|
3
|
+
> **File**: `src/stages/graph.ts`
|
|
4
|
+
> **Exports**: `buildGraph(parsedFiles: ParsedFile[]): DepGraph`
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## What It Does
|
|
9
|
+
|
|
10
|
+
The graph builder takes the flat list of `ParsedFile` objects and turns it into a **directed dependency graph**: a collection of **nodes** (entities) connected by **edges** (import relationships).
|
|
11
|
+
|
|
12
|
+
After this stage, you know exactly which entity depends on which other entity, across the entire codebase.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## The Three-Step Build Process
|
|
17
|
+
|
|
18
|
+
### Step 1 — Create a node for every entity
|
|
19
|
+
|
|
20
|
+
Every `RawEntity` found in every file becomes a `DepNode` in the graph:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
for (const file of parsedFiles) {
|
|
24
|
+
const fileBase = path.basename(file.filePath, path.extname(file.filePath));
|
|
25
|
+
|
|
26
|
+
for (const entity of file.entities) {
|
|
27
|
+
const id = makeId(entity.name, fileBase); // e.g. "getUserById__userService"
|
|
28
|
+
|
|
29
|
+
if (nodes.has(id)) continue; // skip duplicates
|
|
30
|
+
|
|
31
|
+
nodes.set(id, {
|
|
32
|
+
id,
|
|
33
|
+
name: entity.name,
|
|
34
|
+
type: entity.type,
|
|
35
|
+
file: file.filePath,
|
|
36
|
+
line: entity.line,
|
|
37
|
+
lang: file.lang,
|
|
38
|
+
complexity: entity.complexity,
|
|
39
|
+
inDegree: 0,
|
|
40
|
+
outDegree: 0,
|
|
41
|
+
centralityScore: 0,
|
|
42
|
+
connections: [],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Node IDs** are composite strings: `entityName__fileBasename`.
|
|
49
|
+
For example, the function `getUserById` in `src/services/userService.ts` gets ID `getUserById__userService`.
|
|
50
|
+
|
|
51
|
+
This scheme prevents collisions when two files have a function with the same name.
|
|
52
|
+
|
|
53
|
+
### Step 2 — Build a file path lookup map
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
const fileMap = new Map<string, ParsedFile>();
|
|
57
|
+
for (const file of parsedFiles) {
|
|
58
|
+
fileMap.set(file.filePath, file);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This allows O(1) lookup from a resolved file path → its `ParsedFile` data.
|
|
63
|
+
|
|
64
|
+
### Step 3 — Connect nodes through imports
|
|
65
|
+
|
|
66
|
+
This is where the real graph-building happens. For every file:
|
|
67
|
+
|
|
68
|
+
1. Iterate over its `imports`.
|
|
69
|
+
2. Skip **external** imports (packages like `express`, `lodash`) — only local imports matter.
|
|
70
|
+
3. **Resolve** the import path to an actual file on disk (see path resolution below).
|
|
71
|
+
4. For each imported name, find the matching entity in the target file.
|
|
72
|
+
5. Create an edge from **every entity in the importing file** to the **matching target entity**.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
edges.push({
|
|
76
|
+
from: fromId,
|
|
77
|
+
to: toId,
|
|
78
|
+
type: 'imports',
|
|
79
|
+
description: `${fromEntity.name} imports ${importedName} from ${path.basename(resolvedPath)}`,
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Duplicate edges are guarded against before insertion.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Path Resolution
|
|
88
|
+
|
|
89
|
+
Import paths like `"./userService"` don't include an extension. The `resolvePath` helper tries these candidates in priority order:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
./userService
|
|
93
|
+
./userService.ts
|
|
94
|
+
./userService.tsx
|
|
95
|
+
./userService.js
|
|
96
|
+
./userService.jsx
|
|
97
|
+
./userService/index.ts
|
|
98
|
+
./userService/index.js
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
It finds the first candidate that matches a known file in `parsedFiles` and returns that path. If nothing matches, the import is silently skipped.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Output Shape — `DepGraph`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
interface DepGraph {
|
|
109
|
+
nodes: Map<string, DepNode>;
|
|
110
|
+
edges: DepEdge[];
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
At this point, all `inDegree`, `outDegree`, and `centralityScore` fields on each node are still `0`. The **Metrics stage** fills these in (see [stage-metrics.md](./stage-metrics.md)).
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Edge Direction Convention
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
FROM → TO
|
|
122
|
+
(importer) (imported)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
If `routes/users.ts` imports `getUserById` from `services/userService.ts`, the edge is:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
GET /api/users__users → getUserById__userService
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
This forward-direction convention means: **following edges = following dependencies**.
|
|
132
|
+
For impact analysis (who depends on me?), edges are traversed **in reverse** (see [stage-impact.md](./stage-impact.md)).
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## The `makeId` Helper
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
function makeId(name: string, fileBase: string): string {
|
|
140
|
+
const cleanName = name.replace(/[^a-zA-Z0-9]/g, '_');
|
|
141
|
+
const cleanBase = fileBase.replace(/[^a-zA-Z0-9]/g, '_');
|
|
142
|
+
return `${cleanName}__${cleanBase}`;
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Non-alphanumeric characters (e.g. spaces, slashes in route paths like `GET /api/users`) are replaced with underscores. This keeps IDs safe for use as JSON keys and stable across runs.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Common Gotchas
|
|
151
|
+
|
|
152
|
+
| Situation | Behaviour |
|
|
153
|
+
|---|---|
|
|
154
|
+
| Two files export the same function name | First one wins; second is skipped (duplicate `id` guard) |
|
|
155
|
+
| Import resolves to a file with no entities | The import is dropped — nothing to link to |
|
|
156
|
+
| Circular imports (A imports B, B imports A) | Both edges are created; no infinite loop — the graph is built in a single linear pass |
|
|
157
|
+
| External package imports | Skipped via the `isLocal` flag on `RawImport` |
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Stage 5 — Impact Simulator
|
|
2
|
+
|
|
3
|
+
> **File**: `src/stages/impact.ts`
|
|
4
|
+
> **Exports**: `simulateImpact(graph, targetName, changeDescription): ImpactReport`
|
|
5
|
+
> **Triggered by**: `--impact <name> <description>` CLI flag
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What It Does
|
|
10
|
+
|
|
11
|
+
The impact simulator answers the question: **"If I change this entity, what else breaks?"**
|
|
12
|
+
|
|
13
|
+
It performs a **reverse Breadth-First Search (BFS)** starting from the target node, walking *against* the edge direction to find every node that (directly or transitively) depends on the target. It then scores each affected node by impact severity and produces a risk report with testing recommendations.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## The Four-Step Algorithm
|
|
18
|
+
|
|
19
|
+
### Step 1 — Find the target node
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const targetNode = [...graph.nodes.values()]
|
|
23
|
+
.find(n => n.name === targetName);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Searches by `name` (not `id`). If the target is not found, an empty report is returned immediately.
|
|
27
|
+
|
|
28
|
+
### Step 2 — Reverse BFS
|
|
29
|
+
|
|
30
|
+
A standard BFS, but instead of following edges *forward* (A→B means A depends on B), we follow edges *backward* (find all edges where `edge.to === nodeId`):
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
function getDirectDependents(graph, nodeId) {
|
|
34
|
+
return graph.edges
|
|
35
|
+
.filter(e => e.to === nodeId)
|
|
36
|
+
.map(e => e.from);
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The BFS seeds the queue with **direct dependents** (depth 1) and expands outward, level by level, until:
|
|
41
|
+
- A node has already been visited (`visited` Set), or
|
|
42
|
+
- The depth exceeds `MAX_BFS_DEPTH` (default: `10`)
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
target node
|
|
46
|
+
▲
|
|
47
|
+
│ (direct dependents — depth 1)
|
|
48
|
+
[A, B, C]
|
|
49
|
+
▲
|
|
50
|
+
│ (dependents of A, B, C — depth 2)
|
|
51
|
+
[D, E, F, G]
|
|
52
|
+
▲ ...and so on up to depth 10
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Step 3 — Score each affected node
|
|
56
|
+
|
|
57
|
+
Impact level is determined by **BFS depth**:
|
|
58
|
+
|
|
59
|
+
| Depth | Impact Level | Breaking Change? |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| 1 | `critical` | ✅ Yes |
|
|
62
|
+
| 2 | `high` | ✅ Yes |
|
|
63
|
+
| 3–4 | `medium` | ❌ No |
|
|
64
|
+
| 5–10 | `low` | ❌ No |
|
|
65
|
+
|
|
66
|
+
Nodes at depth ≤ 2 are flagged as `breakingChange: true` because they directly consume the target's interface.
|
|
67
|
+
|
|
68
|
+
### Step 4 — Compute the risk score
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const score = C * 30 + H * 15 + M * 7 + L * 2 + inDegree * 3;
|
|
72
|
+
return Math.min(100, score);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Where `C`, `H`, `M`, `L` are the counts of critical/high/medium/low affected nodes, and `inDegree` is the target node's own in-degree.
|
|
76
|
+
|
|
77
|
+
The score is capped at 100.
|
|
78
|
+
|
|
79
|
+
**Risk level thresholds:**
|
|
80
|
+
|
|
81
|
+
| Score | Risk Level |
|
|
82
|
+
|---|---|
|
|
83
|
+
| ≥ 75 | `CRITICAL` |
|
|
84
|
+
| ≥ 50 | `HIGH` |
|
|
85
|
+
| ≥ 25 | `MEDIUM` |
|
|
86
|
+
| < 25 | `LOW` |
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Output Shape — `ImpactReport`
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
interface ImpactReport {
|
|
94
|
+
targetNode: string; // the ID of the changed node
|
|
95
|
+
changeDescription: string; // from CLI --impact flag
|
|
96
|
+
riskScore: number; // 0–100
|
|
97
|
+
riskLevel: string; // LOW / MEDIUM / HIGH / CRITICAL
|
|
98
|
+
affectedNodes: AffectedNode[];
|
|
99
|
+
breakingChanges: AffectedNode[]; // subset of affectedNodes where breakingChange=true
|
|
100
|
+
testingPlan: string[];
|
|
101
|
+
recommendations: string[];
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### `AffectedNode`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
interface AffectedNode {
|
|
109
|
+
nodeId: string;
|
|
110
|
+
name: string;
|
|
111
|
+
file: string;
|
|
112
|
+
depth: number; // BFS distance from target
|
|
113
|
+
impact: string; // critical / high / medium / low
|
|
114
|
+
reason: string; // human-readable why
|
|
115
|
+
changeRequired: string; // what action is needed
|
|
116
|
+
breakingChange: boolean;
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Recommendations Logic
|
|
123
|
+
|
|
124
|
+
Recommendations are generated automatically based on the risk score:
|
|
125
|
+
|
|
126
|
+
| Risk Score | Recommendations |
|
|
127
|
+
|---|---|
|
|
128
|
+
| ≥ 75 | Full team review, phased rollout, full regression suite |
|
|
129
|
+
| ≥ 50 | Tech lead review, feature flag the change |
|
|
130
|
+
| ≥ 25 | Code review, test all affected modules |
|
|
131
|
+
| < 25 | Standard PR process sufficient |
|
|
132
|
+
|
|
133
|
+
If any breaking changes exist, an additional note is always appended.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Usage Example
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
node depgraph.js ./src --impact "getUserById" "removing userId parameter"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
This runs the full pipeline and then simulates changing `getUserById`, returning a detailed report of everything that would be affected.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Edge Cases
|
|
148
|
+
|
|
149
|
+
| Situation | Behaviour |
|
|
150
|
+
|---|---|
|
|
151
|
+
| Target not found | Returns an empty report with `riskScore: 0` and an explanatory testing plan entry |
|
|
152
|
+
| Target has no dependents | Returns an empty `affectedNodes` array and `LOW` risk |
|
|
153
|
+
| Circular dependencies | The `visited` Set prevents infinite loops |
|
|
154
|
+
| Extremely deep dependency chains | `MAX_BFS_DEPTH = 10` hard-caps traversal |
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Stage 4 — Metrics
|
|
2
|
+
|
|
3
|
+
> **File**: `src/stages/metrics.ts`
|
|
4
|
+
> **Exports**: `computeMetrics`, `getEntryPoints`, `getLeafNodes`, `getIsolatedNodes`, `getCriticalNodes`
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## What It Does
|
|
9
|
+
|
|
10
|
+
The metrics stage takes the raw graph from the builder and **enriches each node** with three numeric properties: `inDegree`, `outDegree`, and `centralityScore`. It also provides four helper functions for categorising nodes.
|
|
11
|
+
|
|
12
|
+
This stage **mutates** the nodes inside the graph in place and returns the same `DepGraph` object.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## `computeMetrics(graph: DepGraph): DepGraph`
|
|
17
|
+
|
|
18
|
+
### Step 1 — Count in-degree and out-degree
|
|
19
|
+
|
|
20
|
+
The function iterates over every edge and increments counters on the two endpoint nodes:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
for (const edge of graph.edges) {
|
|
24
|
+
const fromNode = graph.nodes.get(edge.from);
|
|
25
|
+
const toNode = graph.nodes.get(edge.to);
|
|
26
|
+
|
|
27
|
+
if (fromNode) fromNode.outDegree += 1; // this node imports something
|
|
28
|
+
if (toNode) toNode.inDegree += 1; // this node is imported by something
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Property | Meaning |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `inDegree` | Number of other nodes that import **this** node |
|
|
35
|
+
| `outDegree` | Number of nodes **this** node imports |
|
|
36
|
+
|
|
37
|
+
### Step 2 — Compute centrality score
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
node.centralityScore = node.inDegree * 2 + node.outDegree;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The formula weights **inDegree** more heavily (×2) because being imported by many things is riskier than importing many things. A node with a high inDegree is a "load-bearing" piece of code — changing it breaks many consumers.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Node Categorisation Helpers
|
|
48
|
+
|
|
49
|
+
These four helper functions are used by both the **Output** stage and the **main** entry point to produce the summary section of the report.
|
|
50
|
+
|
|
51
|
+
### `getEntryPoints(graph)`
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
n.inDegree === 0 && n.outDegree > 0
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Nothing imports this node, but it imports others.**
|
|
58
|
+
These are application roots: `main.ts`, route files, top-level scripts. They start the dependency chain.
|
|
59
|
+
|
|
60
|
+
### `getLeafNodes(graph)`
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
n.outDegree === 0 && n.inDegree > 0
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**This node imports nothing, but others import it.**
|
|
67
|
+
These are pure utilities and helpers: `hashPassword`, `formatDate`, shared constants. They're at the bottom of the dependency chain.
|
|
68
|
+
|
|
69
|
+
### `getIsolatedNodes(graph)`
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
n.inDegree === 0 && n.outDegree === 0
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**This node has no connections at all.**
|
|
76
|
+
These are dead code candidates — entities defined but never imported or used by any other tracked entity in the project.
|
|
77
|
+
|
|
78
|
+
### `getCriticalNodes(graph)`
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
n.centralityScore > 20
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Nodes with a centrality score above 20.**
|
|
85
|
+
These are architectural hotspots. Modifying them carries high risk of unintended side-effects across the codebase. The impact simulator (see [stage-impact.md](./stage-impact.md)) was designed specifically for these.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Understanding the Score Threshold
|
|
90
|
+
|
|
91
|
+
The threshold of `20` for critical nodes translates roughly to:
|
|
92
|
+
- A node imported by 10 other nodes (`10 * 2 = 20`), or
|
|
93
|
+
- A node imported by 7 nodes and importing 6 others (`7*2 + 6 = 20`)
|
|
94
|
+
|
|
95
|
+
Adjust `getCriticalNodes` if your project has a different risk tolerance.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Example — Annotated Node After This Stage
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"id": "getUserById__userService",
|
|
104
|
+
"name": "getUserById",
|
|
105
|
+
"type": "function",
|
|
106
|
+
"inDegree": 8,
|
|
107
|
+
"outDegree": 2,
|
|
108
|
+
"centralityScore": 18,
|
|
109
|
+
"connections": ["usersRouter__routes", "adminRouter__routes", "..."]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
This node is imported by 8 other nodes and imports 2. Its centrality score is 18 — just under the critical threshold.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Stage 6 — Output
|
|
2
|
+
|
|
3
|
+
> **File**: `src/stages/output.ts`
|
|
4
|
+
> **Exports**: `writeOutput(graph, parsed, outputPath, impact?): void`
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## What It Does
|
|
9
|
+
|
|
10
|
+
The output stage is the final step in the pipeline. It takes the fully-enriched graph, the parsed file list, and the optional impact report, **serialises everything into a structured JSON document**, and writes it to disk.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## The Four-Step Write Process
|
|
15
|
+
|
|
16
|
+
### Step 1 — Compute the summary
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
const summary = {
|
|
20
|
+
totalNodes: graph.nodes.size,
|
|
21
|
+
totalEdges: graph.edges.length,
|
|
22
|
+
entryPoints: getEntryPoints(graph),
|
|
23
|
+
leafNodes: getLeafNodes(graph),
|
|
24
|
+
isolatedNodes: getIsolatedNodes(graph),
|
|
25
|
+
criticalNodes: getCriticalNodes(graph),
|
|
26
|
+
};
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This calls the four categorisation helpers from the metrics stage (see [stage-metrics.md](./stage-metrics.md)) to produce the summary section.
|
|
30
|
+
|
|
31
|
+
### Step 2 — Assemble the full output object
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
const output: OutputJSON = {
|
|
35
|
+
meta: {
|
|
36
|
+
version: '1.0.0',
|
|
37
|
+
timestamp: new Date().toISOString(),
|
|
38
|
+
totalFiles: parsed.length,
|
|
39
|
+
totalLines: totalLines,
|
|
40
|
+
},
|
|
41
|
+
summary,
|
|
42
|
+
nodes: [...graph.nodes.values()], // Map → Array
|
|
43
|
+
edges: graph.edges,
|
|
44
|
+
files: parsed,
|
|
45
|
+
impact, // undefined if --impact was not used
|
|
46
|
+
};
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The `nodes` Map is converted to an Array here so it serialises correctly as JSON.
|
|
50
|
+
|
|
51
|
+
### Step 3 — Write to disk
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
fs.writeFileSync(
|
|
55
|
+
outputPath,
|
|
56
|
+
JSON.stringify(output, null, 2), // pretty-printed, 2-space indent
|
|
57
|
+
'utf-8'
|
|
58
|
+
);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
If the output directory does not exist, it is created recursively:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const dir = path.dirname(outputPath);
|
|
65
|
+
if (!fs.existsSync(dir)) {
|
|
66
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Step 4 — Print confirmation
|
|
71
|
+
|
|
72
|
+
After a successful write, a summary is printed to stdout:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
✅ Output written to ./depgraph-output.json
|
|
76
|
+
12 files
|
|
77
|
+
87 nodes
|
|
78
|
+
143 edges
|
|
79
|
+
3240 total lines of code
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If an impact report was included:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
💥 Impact Report included
|
|
86
|
+
Target : getUserById__userService
|
|
87
|
+
Risk Level : HIGH
|
|
88
|
+
Risk Score : 62
|
|
89
|
+
Affected : 9 nodes
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Output File Schema — `OutputJSON`
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
interface OutputJSON {
|
|
98
|
+
meta: {
|
|
99
|
+
version: string; // tool version, e.g. "1.0.0"
|
|
100
|
+
timestamp: string; // ISO 8601 datetime
|
|
101
|
+
totalFiles: number;
|
|
102
|
+
totalLines: number;
|
|
103
|
+
};
|
|
104
|
+
summary: {
|
|
105
|
+
totalNodes: number;
|
|
106
|
+
totalEdges: number;
|
|
107
|
+
entryPoints: string[]; // node IDs
|
|
108
|
+
leafNodes: string[]; // node IDs
|
|
109
|
+
isolatedNodes: string[]; // node IDs (dead code candidates)
|
|
110
|
+
criticalNodes: string[]; // node IDs (centrality > 20)
|
|
111
|
+
};
|
|
112
|
+
nodes: DepNode[];
|
|
113
|
+
edges: DepEdge[];
|
|
114
|
+
files: ParsedFile[];
|
|
115
|
+
impact?: ImpactReport; // only present if --impact was used
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
See [data-types.md](./data-types.md) for the full definitions of each nested type.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Default Output Path
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
./depgraph-output.json
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Override with `--output <path>`:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
node depgraph.js ./src --output ./reports/my-graph.json
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Using the Output File
|
|
138
|
+
|
|
139
|
+
The JSON output is designed to be consumed by:
|
|
140
|
+
|
|
141
|
+
- **Visualisation tools** — feed `nodes` and `edges` into a graph renderer (e.g. D3, Cytoscape, Sigma.js)
|
|
142
|
+
- **CI checks** — inspect `summary.criticalNodes` or `impact.riskLevel` in a script
|
|
143
|
+
- **IDE plugins** — parse `files` to annotate code with entity metadata
|
|
144
|
+
- **AI tools** — pass the graph as context for code review or refactoring suggestions
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Stage 2 — Parser
|
|
2
|
+
|
|
3
|
+
> **File**: `src/stages/parser.ts`
|
|
4
|
+
> **Exports**: `parseFile(filePath: string): ParsedFile | null`, `parseFiles(filePaths: string[]): ParsedFile[]`
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## What It Does
|
|
9
|
+
|
|
10
|
+
The parser takes each file path from the collector and:
|
|
11
|
+
|
|
12
|
+
1. Reads the raw source code from disk.
|
|
13
|
+
2. Looks up the correct **language parser** from the registry based on the file extension.
|
|
14
|
+
3. Strips single-line `//` comments from the code before analysis.
|
|
15
|
+
4. Calls the language parser to extract **entities**, **imports**, and **exports**.
|
|
16
|
+
5. Returns a structured `ParsedFile` object.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## The `parseFile` Function — Step by Step
|
|
21
|
+
|
|
22
|
+
### Step 1: Read the file
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
const code = fs.readFileSync(filePath, 'utf-8');
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
If the file can't be read, it logs a warning and returns `null`.
|
|
29
|
+
|
|
30
|
+
### Step 2: Resolve the language parser
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
34
|
+
const parser = getLanguageParser(ext);
|
|
35
|
+
if (!parser) return null;
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`getLanguageParser` queries the language registry (see [language-registry.md](./language-registry.md)). If no parser is registered for the extension, the file is silently skipped.
|
|
39
|
+
|
|
40
|
+
### Step 3: Strip single-line comments
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const cleanCode = code
|
|
44
|
+
.split('\n')
|
|
45
|
+
.map(line => {
|
|
46
|
+
const commentIndex = line.indexOf('//');
|
|
47
|
+
if (commentIndex === -1) return line;
|
|
48
|
+
// make sure // is not inside a string
|
|
49
|
+
const before = line.slice(0, commentIndex);
|
|
50
|
+
const inString = (before.match(/"/g) || []).length % 2 !== 0
|
|
51
|
+
|| (before.match(/'/g) || []).length % 2 !== 0;
|
|
52
|
+
return inString ? line : line.slice(0, commentIndex);
|
|
53
|
+
})
|
|
54
|
+
.join('\n');
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This is done before passing code to any parser to prevent `//` inside comments from confusing regex patterns. The string-detection guard prevents false positives on lines like:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const url = "https://example.com"; // this would break without the guard
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
> **Limitation**: The guard only checks for unbalanced `"` or `'` quotes on the *same line*. Multi-line strings or template literals are not fully handled.
|
|
64
|
+
|
|
65
|
+
### Step 4: Extract data via the language parser
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const lines = code.split('\n').length; // count from original code
|
|
69
|
+
const entities = parser.extractEntities(cleanCode, filePath);
|
|
70
|
+
const imports = parser.extractImports(cleanCode);
|
|
71
|
+
const exports = parser.extractExports(cleanCode);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Note that line counting uses the **original** code (before stripping) to get accurate line numbers. Extraction uses the **clean** code.
|
|
75
|
+
|
|
76
|
+
### Step 5: Return the result
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
return { filePath, lang: parser.lang, lines, entities, imports, exports };
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## The `parseFiles` Function
|
|
85
|
+
|
|
86
|
+
A simple iterator over `parseFile`. Files that return `null` are silently dropped:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
for (const filePath of filePaths) {
|
|
90
|
+
const parsed = parseFile(filePath);
|
|
91
|
+
if (parsed) results.push(parsed);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Output Shape — `ParsedFile`
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
interface ParsedFile {
|
|
101
|
+
filePath: string; // e.g. "/project/src/auth.ts"
|
|
102
|
+
lang: string; // e.g. "js" — from the parser's lang field
|
|
103
|
+
lines: number; // total line count
|
|
104
|
+
entities: RawEntity[];
|
|
105
|
+
imports: RawImport[];
|
|
106
|
+
exports: string[];
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
See [data-types.md](./data-types.md) for the full definitions of `RawEntity` and `RawImport`.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## What "entities" means
|
|
115
|
+
|
|
116
|
+
An entity is any named code construct the parser can recognise:
|
|
117
|
+
|
|
118
|
+
- Functions, async functions, arrow functions
|
|
119
|
+
- Classes, interfaces, types (TypeScript)
|
|
120
|
+
- React components and hooks
|
|
121
|
+
- Express/Fastify API route declarations
|
|
122
|
+
- Python classes and `def` functions
|
|
123
|
+
|
|
124
|
+
Every entity has a `name`, `type`, `line`, and `complexity` rating.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Complexity Estimation
|
|
129
|
+
|
|
130
|
+
Each language parser estimates complexity by counting branching keywords in the entity's body:
|
|
131
|
+
|
|
132
|
+
| Branch count | Rating |
|
|
133
|
+
|---|---|
|
|
134
|
+
| 0–3 | `"low"` |
|
|
135
|
+
| 4–8 | `"medium"` |
|
|
136
|
+
| 9+ | `"high"` |
|
|
137
|
+
|
|
138
|
+
Thresholds are set in `COMPLEXITY_THRESHOLDS` in [`src/constants.ts`](../src/constants.ts).
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Adding Support for a New Language
|
|
143
|
+
|
|
144
|
+
You don't touch the parser stage at all. Instead, register a new language parser — see [language-registry.md](./language-registry.md).
|