universal-ast-mapper 1.28.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/BLUEPRINT.md +230 -230
  2. package/CHANGELOG.md +466 -338
  3. package/README.md +878 -878
  4. package/package.json +48 -47
  5. package/scripts/install-skill.mjs +187 -187
  6. package/dist/analysis.js +0 -134
  7. package/dist/callgraph.js +0 -467
  8. package/dist/check.js +0 -112
  9. package/dist/cli.js +0 -1275
  10. package/dist/complexity.js +0 -98
  11. package/dist/config.js +0 -53
  12. package/dist/contextpack.js +0 -79
  13. package/dist/coupling.js +0 -35
  14. package/dist/crosslang.js +0 -425
  15. package/dist/diskcache.js +0 -97
  16. package/dist/explorer.js +0 -123
  17. package/dist/extractors/c.js +0 -204
  18. package/dist/extractors/common.js +0 -56
  19. package/dist/extractors/cpp.js +0 -272
  20. package/dist/extractors/csharp.js +0 -209
  21. package/dist/extractors/go.js +0 -212
  22. package/dist/extractors/java.js +0 -152
  23. package/dist/extractors/kotlin.js +0 -159
  24. package/dist/extractors/php.js +0 -208
  25. package/dist/extractors/python.js +0 -153
  26. package/dist/extractors/ruby.js +0 -146
  27. package/dist/extractors/rust.js +0 -249
  28. package/dist/extractors/swift.js +0 -192
  29. package/dist/extractors/typescript.js +0 -577
  30. package/dist/gitdiff.js +0 -178
  31. package/dist/graph-analysis.js +0 -279
  32. package/dist/graph.js +0 -165
  33. package/dist/html.js +0 -326
  34. package/dist/index.js +0 -1408
  35. package/dist/layers.js +0 -36
  36. package/dist/modulecoupling.js +0 -0
  37. package/dist/parser.js +0 -84
  38. package/dist/pool.js +0 -114
  39. package/dist/prompts.js +0 -67
  40. package/dist/registry.js +0 -87
  41. package/dist/report.js +0 -232
  42. package/dist/resolver.js +0 -222
  43. package/dist/roots.js +0 -47
  44. package/dist/search.js +0 -68
  45. package/dist/semantic.js +0 -365
  46. package/dist/sfc.js +0 -27
  47. package/dist/skeleton.js +0 -132
  48. package/dist/sourcemap.js +0 -60
  49. package/dist/testmap.js +0 -167
  50. package/dist/tsconfig.js +0 -212
  51. package/dist/typeflow.js +0 -124
  52. package/dist/types.js +0 -5
  53. package/dist/unused-params.js +0 -127
  54. package/dist/worker.js +0 -27
  55. package/dist/workspace.js +0 -330
package/dist/gitdiff.js DELETED
@@ -1,178 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { buildSkeleton, collectSourceFiles } from "./skeleton.js";
6
- import { resolveOptions } from "./config.js";
7
- import { buildSymbolGraph } from "./graph.js";
8
- import { getChangeImpact } from "./graph-analysis.js";
9
- import { detectLanguage } from "./registry.js";
10
- import { computeFileComplexity } from "./complexity.js";
11
- function git(args, cwd) {
12
- return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
13
- }
14
- export function isGitRepo(root) {
15
- try {
16
- git(["rev-parse", "--is-inside-work-tree"], root);
17
- return true;
18
- }
19
- catch {
20
- return false;
21
- }
22
- }
23
- function changedFiles(root, base) {
24
- let out;
25
- try {
26
- out = git(["diff", "--name-status", base, "--"], root);
27
- }
28
- catch {
29
- return [];
30
- }
31
- const res = [];
32
- for (const line of out.split(/\r?\n/)) {
33
- if (!line.trim())
34
- continue;
35
- const m = line.match(/^([AMD])\t(.+)$/);
36
- if (m) {
37
- res.push({ status: m[1], file: m[2] });
38
- continue;
39
- }
40
- const r = line.match(/^R\d+\t\S+\t(.+)$/); // rename → treat new path as modified
41
- if (r)
42
- res.push({ status: "M", file: r[1] });
43
- }
44
- // Untracked files are new since any ref — treat them as added.
45
- try {
46
- const untracked = git(["ls-files", "--others", "--exclude-standard"], root);
47
- for (const f of untracked.split(/\r?\n/)) {
48
- if (f.trim() && !res.some((x) => x.file === f))
49
- res.push({ status: "A", file: f });
50
- }
51
- }
52
- catch { /* ignore */ }
53
- return res.filter((f) => detectLanguage(f.file));
54
- }
55
- function oldContent(root, base, rel) {
56
- try {
57
- return git(["show", `${base}:${rel}`], root);
58
- }
59
- catch {
60
- return null;
61
- }
62
- }
63
- async function skeletonFromSource(source, rel) {
64
- const ext = path.extname(rel);
65
- const tmp = path.join(os.tmpdir(), `astdiff-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
66
- try {
67
- fs.writeFileSync(tmp, source);
68
- return await buildSkeleton(tmp, rel, resolveOptions({ detail: "full", emitHtml: false }));
69
- }
70
- catch {
71
- return null;
72
- }
73
- finally {
74
- try {
75
- fs.unlinkSync(tmp);
76
- }
77
- catch { /* ignore */ }
78
- }
79
- }
80
- function flatten(syms, prefix, acc) {
81
- for (const s of syms) {
82
- const q = prefix ? prefix + "." + s.name : s.name;
83
- acc.set(q, s);
84
- flatten(s.children, q, acc);
85
- }
86
- return acc;
87
- }
88
- const sc = (s) => ({ symbol: s.name, kind: s.kind, exported: s.exported ?? false });
89
- export async function computeDiff(absDir, root, base) {
90
- const absDirNorm = path.resolve(absDir);
91
- const changed = changedFiles(root, base).filter((f) => path.resolve(root, f.file).startsWith(absDirNorm));
92
- const files = [];
93
- const breaking = [];
94
- for (const cf of changed) {
95
- const rel = cf.file;
96
- const newSkel = cf.status === "D" ? null : await safeBuildFromDisk(path.resolve(root, rel), rel);
97
- const oldSrc = cf.status === "A" ? null : oldContent(root, base, rel);
98
- const oldSkel = oldSrc != null ? await skeletonFromSource(oldSrc, rel) : null;
99
- const oldMap = oldSkel ? flatten(oldSkel.symbols, "", new Map()) : new Map();
100
- const newMap = newSkel ? flatten(newSkel.symbols, "", new Map()) : new Map();
101
- const added = [], removed = [], modified = [];
102
- for (const [q, s] of newMap)
103
- if (!oldMap.has(q))
104
- added.push(sc(s));
105
- for (const [q, s] of oldMap)
106
- if (!newMap.has(q))
107
- removed.push(sc(s));
108
- for (const [q, s] of newMap) {
109
- const o = oldMap.get(q);
110
- if (o && (o.signature ?? "") !== (s.signature ?? ""))
111
- modified.push(sc(s));
112
- }
113
- const status = cf.status === "A" ? "added" : cf.status === "D" ? "deleted" : "modified";
114
- files.push({ file: rel, status, added, removed, modified });
115
- for (const r of removed)
116
- if (r.exported && !r.symbol.includes(" ")) {
117
- breaking.push({ file: rel, symbol: r.symbol, reason: cf.status === "D" ? "file deleted" : "export removed" });
118
- }
119
- for (const m of modified)
120
- if (m.exported)
121
- breaking.push({ file: rel, symbol: m.symbol, reason: "signature changed" });
122
- }
123
- // Blast radius of breaking changes (top-level symbols only).
124
- const impacted = new Set();
125
- if (breaking.length > 0) {
126
- const opts = resolveOptions({ detail: "outline", emitHtml: false });
127
- const skels = [];
128
- for (const f of collectSourceFiles(absDirNorm, opts)) {
129
- const r = path.relative(root, f).split(path.sep).join("/");
130
- try {
131
- skels.push(await buildSkeleton(f, r, opts));
132
- }
133
- catch { /* skip */ }
134
- }
135
- const graph = buildSymbolGraph(skels, root);
136
- for (const b of breaking) {
137
- const imp = getChangeImpact(graph, `${b.file}::${b.symbol}`);
138
- if (imp)
139
- for (const d of [...imp.direct, ...imp.transitive])
140
- if (d.file !== b.file)
141
- impacted.add(d.file);
142
- }
143
- }
144
- const sum = files.reduce((a, f) => ({ added: a.added + f.added.length, removed: a.removed + f.removed.length, modified: a.modified + f.modified.length }), { added: 0, removed: 0, modified: 0 });
145
- return {
146
- base,
147
- files,
148
- breaking,
149
- impactedFiles: [...impacted].sort(),
150
- summary: { filesChanged: files.length, ...sum, breaking: breaking.length, impactedFiles: impacted.size },
151
- };
152
- }
153
- async function safeBuildFromDisk(abs, rel) {
154
- try {
155
- return await buildSkeleton(abs, rel, resolveOptions({ detail: "full", emitHtml: false }));
156
- }
157
- catch {
158
- return null;
159
- }
160
- }
161
- export async function computeRisk(absDir, root) {
162
- const opts = resolveOptions({ detail: "outline", emitHtml: false });
163
- const out = [];
164
- for (const f of collectSourceFiles(absDir, opts)) {
165
- const rel = path.relative(root, f).split(path.sep).join("/");
166
- let churn = 0;
167
- try {
168
- churn = parseInt(git(["rev-list", "--count", "HEAD", "--", rel], root).trim(), 10) || 0;
169
- }
170
- catch {
171
- churn = 0;
172
- }
173
- const fc = await computeFileComplexity(f, rel);
174
- const maxC = fc ? fc.maxComplexity : 0;
175
- out.push({ file: rel, churn, maxComplexity: maxC, risk: churn * maxC });
176
- }
177
- return out.filter((r) => r.risk > 0).sort((a, b) => b.risk - a.risk);
178
- }
@@ -1,279 +0,0 @@
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
- }
244
- /**
245
- * Find symbol names that are exported from more than one file. These are often
246
- * accidental collisions (copy-paste, parallel implementations) that make a
247
- * codebase harder to navigate and can cause the wrong import to be auto-suggested.
248
- *
249
- * Only exported symbols are considered, and a name must appear in at least two
250
- * distinct files to count as a duplicate.
251
- */
252
- export function findDuplicateSymbols(graph) {
253
- const byName = new Map();
254
- for (const node of graph.nodes) {
255
- if (node.nodeType !== "symbol")
256
- continue;
257
- const sym = node;
258
- if (!sym.exported)
259
- continue;
260
- const arr = byName.get(sym.symbol) ?? [];
261
- arr.push(sym);
262
- byName.set(sym.symbol, arr);
263
- }
264
- const out = [];
265
- for (const [name, syms] of byName) {
266
- // Collapse to one location per file (a file may declare the name once).
267
- const perFile = new Map();
268
- for (const s of syms)
269
- if (!perFile.has(s.file))
270
- perFile.set(s.file, s);
271
- if (perFile.size < 2)
272
- continue;
273
- const locations = [...perFile.values()]
274
- .map((s) => ({ file: s.file, kind: s.kind, nodeId: s.id }))
275
- .sort((a, b) => a.file.localeCompare(b.file));
276
- out.push({ symbol: name, count: perFile.size, locations });
277
- }
278
- return out.sort((a, b) => b.count - a.count || a.symbol.localeCompare(b.symbol));
279
- }
package/dist/graph.js DELETED
@@ -1,165 +0,0 @@
1
- import path from "node:path";
2
- import { resolveImportPath, resolveAliasedImport } from "./resolver.js";
3
- import { resolveWorkspaceImportCached } from "./workspace.js";
4
- import { buildCrossLangIndex, resolveCrossLangTarget, } from "./crosslang.js";
5
- // ─── Internal helpers ─────────────────────────────────────────────────────────
6
- function collectSymbolNodes(symbols, parentId, file, nodes, edges) {
7
- for (const sym of symbols) {
8
- const nodeId = `${parentId}.${sym.name}`;
9
- nodes.push({
10
- id: nodeId,
11
- nodeType: "symbol",
12
- file,
13
- symbol: sym.name,
14
- kind: sym.kind,
15
- exported: sym.exported ?? false,
16
- range: sym.range,
17
- ...(sym.signature ? { signature: sym.signature } : {}),
18
- });
19
- edges.push({ from: parentId, to: nodeId, edgeType: "contains" });
20
- if (sym.children.length > 0) {
21
- collectSymbolNodes(sym.children, nodeId, file, nodes, edges);
22
- }
23
- }
24
- }
25
- // Returns true for path-based import languages (TS/JS/Python/Go).
26
- function isPathBasedLanguage(language) {
27
- return (language === "typescript" ||
28
- language === "tsx" ||
29
- language === "javascript" ||
30
- language === "python" ||
31
- language === "vue" ||
32
- language === "svelte");
33
- }
34
- // Wire one TS/JS/Python-style relative import.
35
- function wirePathImport(skel, imp, fromFileAbs, root, exportedSymbolMap, edges) {
36
- if (imp.isSideEffect)
37
- return;
38
- // Relative import → path resolve; bare specifier → monorepo workspace package.
39
- const resolvedAbs = imp.from.startsWith(".")
40
- ? resolveImportPath(imp.from, fromFileAbs)
41
- : resolveAliasedImport(imp.from, fromFileAbs) ?? resolveWorkspaceImportCached(imp.from, root);
42
- if (!resolvedAbs)
43
- return;
44
- const resolvedRel = path.relative(root, resolvedAbs).split(path.sep).join("/");
45
- if (imp.isNamespaceImport || imp.symbol === "*") {
46
- if (exportedSymbolMap.has(resolvedRel)) {
47
- edges.push({ from: skel.file, to: resolvedRel, edgeType: "imports" });
48
- }
49
- }
50
- else {
51
- const fileExports = exportedSymbolMap.get(resolvedRel);
52
- const targetNodeId = fileExports?.get(imp.symbol);
53
- if (targetNodeId) {
54
- edges.push({ from: skel.file, to: targetNodeId, edgeType: "imports" });
55
- }
56
- }
57
- }
58
- // Wire one cross-language import (Java/C#/Rust) using the project-wide index.
59
- function wireCrossLangImport(skel, imp, fromFileAbs, root, index, exportedSymbolMap, edges) {
60
- if (imp.isSideEffect)
61
- return;
62
- const target = resolveCrossLangTarget(imp, skel, fromFileAbs, root, index);
63
- if (!target)
64
- return;
65
- if (target.kind === "file") {
66
- for (const f of target.files) {
67
- if (exportedSymbolMap.has(f) && f !== skel.file) {
68
- edges.push({ from: skel.file, to: f, edgeType: "imports" });
69
- }
70
- }
71
- return;
72
- }
73
- // target.kind === "symbol"
74
- const fileExports = exportedSymbolMap.get(target.file);
75
- const targetNodeId = fileExports?.get(target.symbol);
76
- if (targetNodeId) {
77
- edges.push({ from: skel.file, to: targetNodeId, edgeType: "imports" });
78
- }
79
- else if (exportedSymbolMap.has(target.file) && target.file !== skel.file) {
80
- // Symbol not found in the resolved file — fall back to a file-level edge so
81
- // the graph still reflects the cross-file dependency.
82
- edges.push({ from: skel.file, to: target.file, edgeType: "imports" });
83
- }
84
- }
85
- // ─── Public builder ──────────────────────────────────────────────────────────
86
- export function buildSymbolGraph(skeletons, root) {
87
- const nodes = [];
88
- const edges = [];
89
- const exportedSymbolMap = new Map();
90
- // First pass: build file and symbol nodes.
91
- for (const skel of skeletons) {
92
- nodes.push({
93
- id: skel.file,
94
- nodeType: "file",
95
- language: skel.language,
96
- symbolCount: skel.symbolCount,
97
- });
98
- const fileExports = new Map();
99
- exportedSymbolMap.set(skel.file, fileExports);
100
- for (const sym of skel.symbols) {
101
- const nodeId = `${skel.file}::${sym.name}`;
102
- nodes.push({
103
- id: nodeId,
104
- nodeType: "symbol",
105
- file: skel.file,
106
- symbol: sym.name,
107
- kind: sym.kind,
108
- exported: sym.exported ?? false,
109
- range: sym.range,
110
- ...(sym.signature ? { signature: sym.signature } : {}),
111
- });
112
- edges.push({ from: skel.file, to: nodeId, edgeType: "contains" });
113
- // Index exported (and visible-by-convention) top-level symbols so that
114
- // imports can find them. Languages like Java/C# may have visible types
115
- // without an explicit "exported" flag — fall back to indexing all
116
- // top-level symbols for those languages.
117
- if (sym.exported)
118
- fileExports.set(sym.name, nodeId);
119
- else if (skel.language === "java" || skel.language === "csharp") {
120
- fileExports.set(sym.name, nodeId);
121
- }
122
- if (sym.children.length > 0) {
123
- const childNodes = [];
124
- const childEdges = [];
125
- collectSymbolNodes(sym.children, nodeId, skel.file, childNodes, childEdges);
126
- nodes.push(...childNodes);
127
- edges.push(...childEdges);
128
- }
129
- }
130
- }
131
- // Build cross-language indexes once (Java FQCN, C# namespaces).
132
- const crossIndex = buildCrossLangIndex(skeletons);
133
- // Second pass: wire import edges, dispatched by language.
134
- for (const skel of skeletons) {
135
- if (!skel.imports || skel.imports.length === 0)
136
- continue;
137
- const fromFileAbs = path.resolve(root, skel.file);
138
- const pathBased = isPathBasedLanguage(skel.language);
139
- for (const imp of skel.imports) {
140
- if (pathBased) {
141
- wirePathImport(skel, imp, fromFileAbs, root, exportedSymbolMap, edges);
142
- }
143
- else if (skel.language === "java" ||
144
- skel.language === "csharp" ||
145
- skel.language === "rust" ||
146
- skel.language === "go" ||
147
- skel.language === "kotlin" ||
148
- skel.language === "c" ||
149
- skel.language === "cpp" ||
150
- skel.language === "swift") {
151
- wireCrossLangImport(skel, imp, fromFileAbs, root, crossIndex, exportedSymbolMap, edges);
152
- }
153
- }
154
- }
155
- const symbolNodeCount = nodes.filter((n) => n.nodeType === "symbol").length;
156
- return {
157
- nodes,
158
- edges,
159
- stats: {
160
- fileCount: skeletons.length,
161
- symbolNodeCount,
162
- edgeCount: edges.length,
163
- },
164
- };
165
- }