universal-ast-mapper 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ import { namedChildren, nameOf, headerSignature, leadingComment } from "../parser.js";
2
+ import { makeSymbol } from "./common.js";
3
+ /**
4
+ * Extract "use client" / "use server" directives from the top of a TS/TSX/JS file.
5
+ * Directives are string-literal expression statements that appear before any other code.
6
+ */
7
+ export function extractDirectivesTS(root, _source) {
8
+ const directives = [];
9
+ for (const child of namedChildren(root)) {
10
+ if (child.type !== "expression_statement")
11
+ break;
12
+ const expr = child.namedChild(0);
13
+ if (!expr || expr.type !== "string")
14
+ break;
15
+ const val = expr.text.replace(/^['"`]|['"`]$/g, "");
16
+ if (val === "use client" || val === "use server") {
17
+ directives.push(val);
18
+ }
19
+ else {
20
+ break;
21
+ }
22
+ }
23
+ return directives;
24
+ }
25
+ /**
26
+ * Extractor shared by TypeScript, TSX and JavaScript.
27
+ * TS-only node types (interface/type/enum) simply never appear in JS sources.
28
+ */
29
+ export function extractTypeScript(root, _source) {
30
+ return collect(namedChildren(root), false);
31
+ }
32
+ function collect(nodes, exported) {
33
+ const out = [];
34
+ for (const n of nodes) {
35
+ const res = handle(n, exported);
36
+ if (Array.isArray(res))
37
+ out.push(...res);
38
+ else if (res)
39
+ out.push(res);
40
+ }
41
+ return out;
42
+ }
43
+ function handle(node, exported) {
44
+ switch (node.type) {
45
+ case "export_statement":
46
+ // `export <decl>` / `export default <decl>` — mark the inner declarations exported.
47
+ return collect(namedChildren(node), true);
48
+ case "class_declaration":
49
+ case "abstract_class_declaration": {
50
+ const name = nameOf(node) ?? "(anonymous class)";
51
+ const body = node.childForFieldName("body");
52
+ const children = body ? collect(namedChildren(body), false) : [];
53
+ return makeSymbol({
54
+ name,
55
+ kind: "class",
56
+ node,
57
+ rawKind: node.type,
58
+ exported,
59
+ doc: leadingComment(node),
60
+ children,
61
+ });
62
+ }
63
+ case "interface_declaration": {
64
+ const name = nameOf(node) ?? "(anonymous interface)";
65
+ const body = node.childForFieldName("body");
66
+ const children = body ? collect(namedChildren(body), false) : [];
67
+ return makeSymbol({
68
+ name,
69
+ kind: "interface",
70
+ node,
71
+ rawKind: node.type,
72
+ exported,
73
+ doc: leadingComment(node),
74
+ children,
75
+ });
76
+ }
77
+ case "function_declaration":
78
+ case "generator_function_declaration": {
79
+ const name = nameOf(node) ?? "(anonymous function)";
80
+ const body = node.childForFieldName("body");
81
+ return makeSymbol({
82
+ name,
83
+ kind: "function",
84
+ node,
85
+ rawKind: node.type,
86
+ signature: headerSignature(node, body),
87
+ exported,
88
+ doc: leadingComment(node),
89
+ });
90
+ }
91
+ case "type_alias_declaration":
92
+ return makeSymbol({
93
+ name: nameOf(node) ?? "(type)",
94
+ kind: "type",
95
+ node,
96
+ rawKind: node.type,
97
+ signature: headerSignature(node, null),
98
+ exported,
99
+ doc: leadingComment(node),
100
+ });
101
+ case "enum_declaration":
102
+ return makeSymbol({
103
+ name: nameOf(node) ?? "(enum)",
104
+ kind: "enum",
105
+ node,
106
+ rawKind: node.type,
107
+ exported,
108
+ doc: leadingComment(node),
109
+ });
110
+ case "lexical_declaration":
111
+ case "variable_declaration":
112
+ return fromVariableDeclaration(node, exported);
113
+ case "method_definition":
114
+ case "method_signature":
115
+ case "abstract_method_signature": {
116
+ const name = nameOf(node) ?? "(method)";
117
+ const body = node.childForFieldName("body");
118
+ return makeSymbol({
119
+ name,
120
+ kind: "method",
121
+ node,
122
+ rawKind: node.type,
123
+ signature: headerSignature(node, body),
124
+ visibility: memberVisibility(node),
125
+ doc: leadingComment(node),
126
+ });
127
+ }
128
+ case "public_field_definition":
129
+ case "field_definition": {
130
+ // Only surface fields that hold an arrow/function (i.e. behave like methods).
131
+ const value = node.childForFieldName("value");
132
+ if (value && (value.type === "arrow_function" || value.type === "function" || value.type === "function_expression")) {
133
+ const name = nameOf(node) ?? "(method)";
134
+ const body = value.childForFieldName("body");
135
+ return makeSymbol({
136
+ name,
137
+ kind: "method",
138
+ node,
139
+ rawKind: node.type,
140
+ signature: headerSignature(node, body),
141
+ visibility: memberVisibility(node),
142
+ doc: leadingComment(node),
143
+ });
144
+ }
145
+ return null;
146
+ }
147
+ default:
148
+ return null;
149
+ }
150
+ }
151
+ function fromVariableDeclaration(node, exported) {
152
+ const out = [];
153
+ for (const decl of namedChildren(node)) {
154
+ if (decl.type !== "variable_declarator")
155
+ continue;
156
+ const value = decl.childForFieldName("value");
157
+ const name = nameOf(decl);
158
+ if (!name)
159
+ continue;
160
+ if (value && (value.type === "arrow_function" || value.type === "function" || value.type === "function_expression")) {
161
+ const body = value.childForFieldName("body");
162
+ out.push(makeSymbol({
163
+ name,
164
+ kind: "function",
165
+ node: decl,
166
+ rawKind: `${node.type}>arrow`,
167
+ signature: headerSignature(value, body),
168
+ exported,
169
+ doc: leadingComment(node),
170
+ }));
171
+ }
172
+ else if (value && (value.type === "class_expression" || value.type === "class")) {
173
+ // const MyClass = class { ... }
174
+ const body = value.childForFieldName("body");
175
+ const children = body ? collect(namedChildren(body), false) : [];
176
+ out.push(makeSymbol({
177
+ name,
178
+ kind: "class",
179
+ node: decl,
180
+ rawKind: `${node.type}>class`,
181
+ exported,
182
+ doc: leadingComment(node),
183
+ children,
184
+ }));
185
+ }
186
+ else if (exported && value) {
187
+ // export const FOO = <any non-function value> — track for dead code detection
188
+ out.push(makeSymbol({
189
+ name,
190
+ kind: "const",
191
+ node: decl,
192
+ rawKind: `${node.type}>const`,
193
+ signature: decl.text.replace(/\s+/g, " ").trim().slice(0, 120),
194
+ exported: true,
195
+ doc: leadingComment(node),
196
+ }));
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+ // ─── Import extraction ────────────────────────────────────────────────────────
202
+ export function extractImportsTS(root, _source) {
203
+ const imports = [];
204
+ for (const child of namedChildren(root)) {
205
+ if (child.type === "import_statement")
206
+ parseImportStatement(child, imports);
207
+ // Re-exports: `export { X } from './foo'` or `export * from './foo'`
208
+ else if (child.type === "export_statement")
209
+ parseReExportStatement(child, imports);
210
+ }
211
+ return imports;
212
+ }
213
+ function parseReExportStatement(node, out) {
214
+ const source = extractModulePath(node.text);
215
+ if (!source)
216
+ return; // no `from` clause — local re-export, not an import
217
+ const isTypeOnly = /^export\s+type\b/.test(node.text);
218
+ // export * from './foo' or export * as Foo from './foo'
219
+ if (/^export\s+\*/.test(node.text)) {
220
+ out.push({ symbol: "*", from: source, isNamespaceImport: true });
221
+ return;
222
+ }
223
+ // export { X, Y as Z } from './foo'
224
+ for (let i = 0; i < node.namedChildCount; i++) {
225
+ const c = node.namedChild(i);
226
+ if (!c || c.type !== "export_clause")
227
+ continue;
228
+ for (let j = 0; j < c.namedChildCount; j++) {
229
+ const spec = c.namedChild(j);
230
+ if (!spec || spec.type !== "export_specifier")
231
+ continue;
232
+ const nameNode = spec.childForFieldName("name");
233
+ const aliasNode = spec.childForFieldName("alias");
234
+ if (nameNode) {
235
+ const imp = { symbol: nameNode.text, from: source };
236
+ if (aliasNode)
237
+ imp.alias = aliasNode.text;
238
+ if (isTypeOnly)
239
+ imp.isTypeOnly = true;
240
+ out.push(imp);
241
+ }
242
+ }
243
+ }
244
+ }
245
+ function parseImportStatement(node, out) {
246
+ const isTypeOnly = /^import\s+type\b/.test(node.text);
247
+ const from = extractModulePath(node.text);
248
+ if (!from)
249
+ return;
250
+ let clauseNode = null;
251
+ for (let i = 0; i < node.namedChildCount; i++) {
252
+ const c = node.namedChild(i);
253
+ if (c && c.type === "import_clause") {
254
+ clauseNode = c;
255
+ break;
256
+ }
257
+ }
258
+ if (!clauseNode) {
259
+ out.push({ symbol: "*", from, isSideEffect: true });
260
+ return;
261
+ }
262
+ for (let i = 0; i < clauseNode.namedChildCount; i++) {
263
+ const c = clauseNode.namedChild(i);
264
+ if (!c)
265
+ continue;
266
+ if (c.type === "identifier") {
267
+ const imp = { symbol: c.text, from, isDefault: true };
268
+ if (isTypeOnly)
269
+ imp.isTypeOnly = true;
270
+ out.push(imp);
271
+ }
272
+ else if (c.type === "namespace_import") {
273
+ const id = c.namedChild(0);
274
+ if (id) {
275
+ const imp = { symbol: id.text, from, isNamespaceImport: true };
276
+ if (isTypeOnly)
277
+ imp.isTypeOnly = true;
278
+ out.push(imp);
279
+ }
280
+ }
281
+ else if (c.type === "named_imports") {
282
+ for (let j = 0; j < c.namedChildCount; j++) {
283
+ const spec = c.namedChild(j);
284
+ if (!spec || spec.type !== "import_specifier")
285
+ continue;
286
+ const nameNode = spec.childForFieldName("name");
287
+ const aliasNode = spec.childForFieldName("alias");
288
+ if (nameNode) {
289
+ const imp = { symbol: nameNode.text, from };
290
+ if (aliasNode)
291
+ imp.alias = aliasNode.text;
292
+ if (isTypeOnly)
293
+ imp.isTypeOnly = true;
294
+ out.push(imp);
295
+ }
296
+ }
297
+ }
298
+ }
299
+ }
300
+ function extractModulePath(importText) {
301
+ const m = importText.match(/from\s+['"`]([^'"`\n]+)['"`]/);
302
+ if (m)
303
+ return m[1];
304
+ const m2 = importText.match(/^import\s+(?:type\s+)?['"`]([^'"`\n]+)['"`]/);
305
+ return m2 ? m2[1] : null;
306
+ }
307
+ // ─── Member visibility ────────────────────────────────────────────────────────
308
+ function memberVisibility(node) {
309
+ for (let i = 0; i < node.childCount; i++) {
310
+ const c = node.child(i);
311
+ if (c && c.type === "accessibility_modifier") {
312
+ return c.text === "private" || c.text === "protected" ? "private" : "public";
313
+ }
314
+ }
315
+ // `#name` ES private fields/methods
316
+ const name = node.childForFieldName("name");
317
+ if (name && name.type === "private_property_identifier")
318
+ return "private";
319
+ return "public";
320
+ }
@@ -0,0 +1,243 @@
1
+ // ─── Dead Code Detection ──────────────────────────────────────────────────────
2
+ /**
3
+ * "High" confidence = functions / classes / consts that are unlikely to be
4
+ * used purely as type annotations.
5
+ * "Low" confidence = interfaces / types / enums — these are often imported
6
+ * implicitly through the TypeScript type system without an explicit import
7
+ * statement, so they may appear "dead" even when they're actively used.
8
+ */
9
+ const HIGH_CONFIDENCE_KINDS = new Set(["function", "class", "const", "var", "method"]);
10
+ /**
11
+ * Return exported symbols that have no incoming "imports" edges within the
12
+ * scanned directory — i.e. nothing inside the scan root depends on them.
13
+ */
14
+ export function findDeadExports(graph) {
15
+ const importedIds = new Set();
16
+ for (const edge of graph.edges) {
17
+ if (edge.edgeType === "imports")
18
+ importedIds.add(edge.to);
19
+ }
20
+ const dead = [];
21
+ for (const node of graph.nodes) {
22
+ if (node.nodeType !== "symbol")
23
+ continue;
24
+ const sym = node;
25
+ if (sym.exported && !importedIds.has(sym.id)) {
26
+ dead.push({
27
+ file: sym.file,
28
+ symbol: sym.symbol,
29
+ kind: sym.kind,
30
+ nodeId: sym.id,
31
+ confidence: HIGH_CONFIDENCE_KINDS.has(sym.kind) ? "high" : "low",
32
+ });
33
+ }
34
+ }
35
+ return dead;
36
+ }
37
+ /**
38
+ * Detect circular import dependencies among the scanned files using DFS.
39
+ * Each reported cycle is canonicalised (rotated to start at the
40
+ * lexicographically smallest node) to avoid duplicates.
41
+ *
42
+ * Re-uses the graph's already-resolved import edges — no path re-resolution needed.
43
+ */
44
+ export function findCircularDeps(graph) {
45
+ const nodeMap = new Map(graph.nodes.map(n => [n.id, n]));
46
+ // Build deduplicated file-level adjacency from the graph's "imports" edges
47
+ const adjSets = new Map();
48
+ for (const node of graph.nodes) {
49
+ if (node.nodeType === "file")
50
+ adjSets.set(node.id, new Set());
51
+ }
52
+ for (const edge of graph.edges) {
53
+ if (edge.edgeType !== "imports")
54
+ continue;
55
+ const toNode = nodeMap.get(edge.to);
56
+ if (!toNode)
57
+ continue;
58
+ const toFile = toNode.nodeType === "file" ? toNode.id : toNode.file;
59
+ if (edge.from !== toFile)
60
+ adjSets.get(edge.from)?.add(toFile);
61
+ }
62
+ const adj = new Map();
63
+ for (const [k, v] of adjSets)
64
+ adj.set(k, [...v]);
65
+ const color = new Map();
66
+ for (const f of adj.keys())
67
+ color.set(f, "white");
68
+ const cycles = [];
69
+ const cycleKeys = new Set();
70
+ for (const startNode of adj.keys()) {
71
+ if (color.get(startNode) !== "white")
72
+ continue;
73
+ const stack = [{ node: startNode, nextIdx: 0 }];
74
+ const path = [startNode];
75
+ color.set(startNode, "gray");
76
+ while (stack.length > 0) {
77
+ const frame = stack[stack.length - 1];
78
+ const neighbors = adj.get(frame.node) ?? [];
79
+ if (frame.nextIdx >= neighbors.length) {
80
+ stack.pop();
81
+ path.pop();
82
+ color.set(frame.node, "black");
83
+ continue;
84
+ }
85
+ const neighbor = neighbors[frame.nextIdx++];
86
+ const nc = color.get(neighbor);
87
+ if (nc === "gray") {
88
+ const start = path.indexOf(neighbor);
89
+ const raw = path.slice(start);
90
+ const canonical = rotateCycle(raw);
91
+ const key = canonical.join("→");
92
+ if (!cycleKeys.has(key)) {
93
+ cycleKeys.add(key);
94
+ cycles.push({ cycle: [...canonical, canonical[0]], length: canonical.length });
95
+ }
96
+ }
97
+ else if (nc === "white") {
98
+ color.set(neighbor, "gray");
99
+ path.push(neighbor);
100
+ stack.push({ node: neighbor, nextIdx: 0 });
101
+ }
102
+ }
103
+ }
104
+ return cycles;
105
+ }
106
+ function rotateCycle(nodes) {
107
+ const minIdx = nodes.reduce((best, n, i) => (n < nodes[best] ? i : best), 0);
108
+ return [...nodes.slice(minIdx), ...nodes.slice(0, minIdx)];
109
+ }
110
+ /**
111
+ * Compute the blast radius of changing a symbol: traverse the import graph in
112
+ * reverse to find every file/symbol that directly or transitively depends on
113
+ * the given node ID.
114
+ *
115
+ * Returns null if the target node ID is not found in the graph.
116
+ */
117
+ export function getChangeImpact(graph, targetNodeId) {
118
+ const nodeMap = new Map(graph.nodes.map((n) => [n.id, n]));
119
+ if (!nodeMap.has(targetNodeId))
120
+ return null;
121
+ // Reverse adjacency: target → [nodes that import it]
122
+ const reverseAdj = new Map();
123
+ for (const edge of graph.edges) {
124
+ if (edge.edgeType === "imports") {
125
+ if (!reverseAdj.has(edge.to))
126
+ reverseAdj.set(edge.to, new Set());
127
+ reverseAdj.get(edge.to).add(edge.from);
128
+ }
129
+ }
130
+ const visited = new Set([targetNodeId]);
131
+ const directSet = new Set();
132
+ for (const dep of reverseAdj.get(targetNodeId) ?? []) {
133
+ if (!visited.has(dep)) {
134
+ visited.add(dep);
135
+ directSet.add(dep);
136
+ }
137
+ }
138
+ const transitiveSet = new Set();
139
+ const queue = [...directSet];
140
+ while (queue.length > 0) {
141
+ const current = queue.shift();
142
+ for (const dep of reverseAdj.get(current) ?? []) {
143
+ if (!visited.has(dep)) {
144
+ visited.add(dep);
145
+ transitiveSet.add(dep);
146
+ queue.push(dep);
147
+ }
148
+ }
149
+ }
150
+ function toImpactNode(id) {
151
+ const n = nodeMap.get(id);
152
+ if (!n)
153
+ return { nodeId: id, file: id };
154
+ if (n.nodeType === "file")
155
+ return { nodeId: id, file: n.id };
156
+ const sym = n;
157
+ return { nodeId: id, file: sym.file, symbol: sym.symbol };
158
+ }
159
+ const direct = [...directSet].map(toImpactNode);
160
+ const transitive = [...transitiveSet].map(toImpactNode);
161
+ const allFiles = new Set([...direct.map((e) => e.file), ...transitive.map((e) => e.file)]);
162
+ return { targetNodeId, direct, transitive, totalFiles: allFiles.size };
163
+ }
164
+ /**
165
+ * Show the import relationships for a single file:
166
+ * - `imports` — what this file pulls in (outgoing edges)
167
+ * - `importedBy` — who depends on this file (incoming edges)
168
+ *
169
+ * Returns null if the file node is not in the graph.
170
+ */
171
+ export function getFileDeps(graph, fileId) {
172
+ if (!graph.nodes.some((n) => n.id === fileId && n.nodeType === "file"))
173
+ return null;
174
+ const nodeMap = new Map(graph.nodes.map((n) => [n.id, n]));
175
+ // fileId → set of imported symbol names
176
+ const outgoing = new Map();
177
+ // fileId → set of symbols they import from us
178
+ const incoming = new Map();
179
+ for (const edge of graph.edges) {
180
+ if (edge.edgeType !== "imports")
181
+ continue;
182
+ const toNode = nodeMap.get(edge.to);
183
+ if (!toNode)
184
+ continue;
185
+ const toFile = toNode.nodeType === "file"
186
+ ? toNode.id
187
+ : toNode.file;
188
+ const symbolName = toNode.nodeType === "symbol"
189
+ ? toNode.symbol
190
+ : null;
191
+ if (edge.from === fileId && toFile !== fileId) {
192
+ if (!outgoing.has(toFile))
193
+ outgoing.set(toFile, new Set());
194
+ if (symbolName)
195
+ outgoing.get(toFile).add(symbolName);
196
+ }
197
+ if (toFile === fileId && edge.from !== fileId) {
198
+ if (!incoming.has(edge.from))
199
+ incoming.set(edge.from, new Set());
200
+ if (symbolName)
201
+ incoming.get(edge.from).add(symbolName);
202
+ }
203
+ }
204
+ return {
205
+ file: fileId,
206
+ imports: [...outgoing.entries()].map(([file, syms]) => ({ file, symbols: [...syms].sort() })),
207
+ importedBy: [...incoming.entries()].map(([file, syms]) => ({ file, symbols: [...syms].sort() })),
208
+ };
209
+ }
210
+ /**
211
+ * Return the most-imported symbols in the graph, sorted descending by
212
+ * the number of distinct files that depend on them. These are "God Nodes" —
213
+ * high-risk symbols where a breaking change has maximum blast radius.
214
+ */
215
+ export function getTopSymbols(graph, limit = 10) {
216
+ // nodeId → set of importing file IDs
217
+ const importers = new Map();
218
+ for (const edge of graph.edges) {
219
+ if (edge.edgeType !== "imports")
220
+ continue;
221
+ if (!importers.has(edge.to))
222
+ importers.set(edge.to, new Set());
223
+ importers.get(edge.to).add(edge.from);
224
+ }
225
+ const results = [];
226
+ for (const node of graph.nodes) {
227
+ if (node.nodeType !== "symbol")
228
+ continue;
229
+ const sym = node;
230
+ const fileImporters = importers.get(sym.id);
231
+ if (!fileImporters || fileImporters.size === 0)
232
+ continue;
233
+ results.push({
234
+ nodeId: sym.id,
235
+ file: sym.file,
236
+ symbol: sym.symbol,
237
+ kind: sym.kind,
238
+ importCount: fileImporters.size,
239
+ importedByFiles: [...fileImporters].sort(),
240
+ });
241
+ }
242
+ return results.sort((a, b) => b.importCount - a.importCount).slice(0, limit);
243
+ }
package/dist/graph.js ADDED
@@ -0,0 +1,118 @@
1
+ import path from "node:path";
2
+ import { resolveImportPath } from "./resolver.js";
3
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
4
+ function collectSymbolNodes(symbols, parentId, file, nodes, edges) {
5
+ for (const sym of symbols) {
6
+ // Nested symbols use dot-notation: "src/foo.ts::MyClass.methodName"
7
+ const nodeId = `${parentId}.${sym.name}`;
8
+ nodes.push({
9
+ id: nodeId,
10
+ nodeType: "symbol",
11
+ file,
12
+ symbol: sym.name,
13
+ kind: sym.kind,
14
+ exported: sym.exported ?? false,
15
+ range: sym.range,
16
+ ...(sym.signature ? { signature: sym.signature } : {}),
17
+ });
18
+ edges.push({ from: parentId, to: nodeId, edgeType: "contains" });
19
+ if (sym.children.length > 0) {
20
+ collectSymbolNodes(sym.children, nodeId, file, nodes, edges);
21
+ }
22
+ }
23
+ }
24
+ // ─── Public builder ──────────────────────────────────────────────────────────
25
+ /**
26
+ * Build a symbol-level dependency graph from an array of pre-parsed skeletons.
27
+ *
28
+ * Node IDs:
29
+ * - File node: "<relPath>" e.g. "src/utils.ts"
30
+ * - Top symbol: "<relPath>::<Name>" e.g. "src/utils.ts::sanitize"
31
+ * - Nested: "<relPath>::<Parent>.<Child>" e.g. "src/utils.ts::MyClass.render"
32
+ *
33
+ * Edge types:
34
+ * - "contains" file → symbol (and parent-symbol → child-symbol)
35
+ * - "imports" importing-file → imported-symbol-node
36
+ */
37
+ export function buildSymbolGraph(skeletons, root) {
38
+ const nodes = [];
39
+ const edges = [];
40
+ // exportedSymbolMap: relFile → (symbolName → nodeId)
41
+ // Used in the second pass to resolve import targets.
42
+ const exportedSymbolMap = new Map();
43
+ // ── First pass: build all file + symbol nodes ────────────────────────────
44
+ for (const skel of skeletons) {
45
+ // File node
46
+ nodes.push({
47
+ id: skel.file,
48
+ nodeType: "file",
49
+ language: skel.language,
50
+ symbolCount: skel.symbolCount,
51
+ });
52
+ const fileExports = new Map();
53
+ exportedSymbolMap.set(skel.file, fileExports);
54
+ for (const sym of skel.symbols) {
55
+ const nodeId = `${skel.file}::${sym.name}`;
56
+ nodes.push({
57
+ id: nodeId,
58
+ nodeType: "symbol",
59
+ file: skel.file,
60
+ symbol: sym.name,
61
+ kind: sym.kind,
62
+ exported: sym.exported ?? false,
63
+ range: sym.range,
64
+ ...(sym.signature ? { signature: sym.signature } : {}),
65
+ });
66
+ edges.push({ from: skel.file, to: nodeId, edgeType: "contains" });
67
+ if (sym.exported)
68
+ fileExports.set(sym.name, nodeId);
69
+ // Collect nested symbols
70
+ if (sym.children.length > 0) {
71
+ const childNodes = [];
72
+ const childEdges = [];
73
+ collectSymbolNodes(sym.children, nodeId, skel.file, childNodes, childEdges);
74
+ nodes.push(...childNodes);
75
+ edges.push(...childEdges);
76
+ }
77
+ }
78
+ }
79
+ // ── Second pass: wire import edges ───────────────────────────────────────
80
+ for (const skel of skeletons) {
81
+ if (!skel.imports || skel.imports.length === 0)
82
+ continue;
83
+ const fromFileAbs = path.resolve(root, skel.file);
84
+ for (const imp of skel.imports) {
85
+ if (!imp.from.startsWith("."))
86
+ continue; // skip external packages
87
+ if (imp.isSideEffect)
88
+ continue;
89
+ const resolvedAbs = resolveImportPath(imp.from, fromFileAbs);
90
+ if (!resolvedAbs)
91
+ continue;
92
+ const resolvedRel = path.relative(root, resolvedAbs).split(path.sep).join("/");
93
+ if (imp.isNamespaceImport || imp.symbol === "*") {
94
+ // Namespace import — link to the file node itself
95
+ if (exportedSymbolMap.has(resolvedRel)) {
96
+ edges.push({ from: skel.file, to: resolvedRel, edgeType: "imports" });
97
+ }
98
+ }
99
+ else {
100
+ const fileExports = exportedSymbolMap.get(resolvedRel);
101
+ const targetNodeId = fileExports?.get(imp.symbol);
102
+ if (targetNodeId) {
103
+ edges.push({ from: skel.file, to: targetNodeId, edgeType: "imports" });
104
+ }
105
+ }
106
+ }
107
+ }
108
+ const symbolNodeCount = nodes.filter((n) => n.nodeType === "symbol").length;
109
+ return {
110
+ nodes,
111
+ edges,
112
+ stats: {
113
+ fileCount: skeletons.length,
114
+ symbolNodeCount,
115
+ edgeCount: edges.length,
116
+ },
117
+ };
118
+ }