universal-ast-mapper 0.5.3 → 0.8.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 CHANGED
@@ -4,7 +4,27 @@ An **MCP server + CLI tool** that turns source code into structured, machine-rea
4
4
 
5
5
  Built on [tree-sitter](https://tree-sitter.github.io/) WASM grammars. Zero regex guessing — real AST parsing.
6
6
 
7
- **Supported languages:** TypeScript · TSX · JavaScript (ESM/CJS) · Python · Go
7
+ **Supported languages:** TypeScript · TSX · JavaScript (ESM/CJS) · Python · Go · Rust · Java · C# · C · C++ · Kotlin · Swift
8
+
9
+ | Capability | TS/JS | Python | Go | Rust | Java | C# | C | C++ | Kt | Swift |
10
+ |--------------------------|:-----:|:------:|:---:|:----:|:----:|:---:|:---:|:---:|:---:|:-----:|
11
+ | Symbol extraction | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
12
+ | Imports parsing | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
13
+ | Graph `imports` edges | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — |
14
+ | `resolve_imports` enrich | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — |
15
+ | Call graph callee origin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — |
16
+ | Reverse `calledBy` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — |
17
+
18
+ > v0.8.0 introduces **symbol extraction + imports parsing** for C / C++ / Kotlin / Swift. Graph/resolver/callgraph dispatch for these four lands in a follow-up release. (Ruby grammar in `tree-sitter-wasms@0.1.13` is unstable and was skipped.)
19
+
20
+ Each language uses the resolution strategy that fits it:
21
+ - **TS/JS/Python** — relative paths (`./foo`, `..mod`) resolved against the importing file's directory, with TS-ESM `.js` → `.ts` rewriting.
22
+ - **Go** — `go.mod` ancestor lookup → module path prefix → package directory → all `.go` files (skips `_test.go`).
23
+ - **Rust** — `Cargo.toml` ancestor → `crate::` / `self::` / `super::` walks; supports `mod.rs` + Rust-2018 sibling-dir style.
24
+ - **Java** — project-wide FQCN index (`package + "." + className → file`) built lazily on first cross-lang call; supports wildcard imports.
25
+ - **C#** — namespace-to-files index plus a `<ns>.<TypeName>` index so `using App.Models` + `new Inventory()` resolves to the right file.
26
+
27
+ For C# and Go (where imports don't name the called symbol), reverse `calledBy` falls back to **call-site scanning** of candidate files.
8
28
 
9
29
  ---
10
30
 
@@ -269,8 +289,8 @@ Parse a function body → extract every call expression, resolve callees via the
269
289
  }
270
290
  ```
271
291
 
272
- Supports TypeScript, JavaScript, Python, Go.
273
- Handles destructured aliases: `const { sign } = jwt` `sign` correctly resolves to `jsonwebtoken`.
292
+ Supports all 8 languages with per-language call extraction (TS/JS `member_expression`, Rust `field_expression`/`scoped_identifier`, Java `method_invocation`, C# `invocation_expression`, etc.) and constructor calls (`new Foo`).
293
+ Handles TS/JS destructured aliases (`const { sign } = jwt`), Java FQCN imports, C# `using` namespaces (via project-wide type index), Rust `use crate::path::Item`, Go `pkg.Func` (via go.mod module path). Reverse `calledBy` uses call-site scanning for C# and Go where import statements don't name the called symbol.
274
294
 
275
295
  **Params:** `path`, `function`, `scanDir`
276
296
 
@@ -435,8 +455,9 @@ src/
435
455
  ├── registry.ts — language detection + extractor registry
436
456
  ├── parser.ts — tree-sitter WASM loader + AST node helpers
437
457
  ├── skeleton.ts — buildSkeleton(), collectSourceFiles() + parse cache
438
- ├── resolver.ts — resolveImportPath(), resolveFileImports()
439
- ├── graph.ts buildSymbolGraph()
458
+ ├── resolver.ts — resolveImportPath(), resolveFileImports() (TS/JS/Python relative)
459
+ ├── crosslang.ts Java FQCN / C# namespace / Rust crate / Go module resolvers + index cache
460
+ ├── graph.ts — buildSymbolGraph() (language-aware second pass)
440
461
  ├── graph-analysis.ts — findDeadExports(), findCircularDeps(), getChangeImpact(),
441
462
  │ getFileDeps(), getTopSymbols()
442
463
  ├── callgraph.ts — buildCallGraph() — AST-level call extraction
@@ -447,7 +468,10 @@ src/
447
468
  ├── common.ts — makeSymbol(), toOutline()
448
469
  ├── typescript.ts — TS/JS/TSX: symbols + imports + re-exports
449
470
  ├── python.ts — Python: symbols + relative import resolution
450
- └── go.ts — Go: symbols + imports
471
+ ├── go.ts — Go: symbols + imports
472
+ ├── rust.ts — Rust: struct/trait/enum/impl + `use` imports
473
+ ├── java.ts — Java: class/interface/enum/method/field + package + imports
474
+ └── csharp.ts — C#: namespace recursion + class/struct/interface/property + `using`
451
475
  ```
452
476
 
453
477
  ---
@@ -456,6 +480,10 @@ src/
456
480
 
457
481
  | Version | What changed |
458
482
  |---------|--------------|
483
+ | **0.8.0** | **4 new languages: C · C++ · Kotlin · Swift** — symbol extraction + imports parsing. C++ tracks access_specifier through class bodies. Kotlin handles `package`/`object`/`data class`. Swift handles `class`/`struct`/`enum` (all under `class_declaration`) and `protocol_declaration`. Ruby grammar in tree-sitter-wasms@0.1.13 is unstable — skipped. |
484
+ | **0.7.0** | Go full resolution (reads `go.mod`, resolves package-as-directory) · C# reverse `calledBy` via call-site scanning · `csharpTypes` index lets `using` directives resolve to specific types · 4-suite test harness (smoke + graph-smoke + resolver-smoke + callgraph-smoke) |
485
+ | **0.6.0** | **3 new languages: Rust · Java · C#** (extractors + import parsing) · cross-language resolver in `crosslang.ts` (Java FQCN index, C# namespace index, Rust `crate::` module walk) · symbol-graph `imports` edges + `resolveFileImports` enrichment + `get_call_graph` callee resolution rewired through it · Java `package` and C# `namespace` captured as directives |
486
+ | **0.5.3** | Auto-install `/ast-map` Claude Code skill on `npm install` · `postinstall` writes `~/.claude/skills/ast-map/SKILL.md` + registers trigger in `CLAUDE.md` (idempotent, CI-safe) |
459
487
  | **0.5.2** | Iterative DFS in `findCircularDeps` (eliminates stack overflow on large codebases) · `build_symbol_graph` inline size guard (>2000 nodes → stats + warning) · integration test suite (`test/analysis.mjs`) |
460
488
  | **0.5.1** | Re-export tracking (`export { X } from './foo'`, barrel files) · `export const` surfaced as symbols · `const X = class {}` support · Python relative import fix · parser instance cache |
461
489
  | **0.5.0** | Call graph destructuring aliases · in-process parse cache · `.ast-map.config.json` · general validation rules (large-file, too-many-imports, god-export) |
package/dist/callgraph.js CHANGED
@@ -4,28 +4,89 @@ import { parseSource } from "./parser.js";
4
4
  import { buildSkeleton } from "./skeleton.js";
5
5
  import { resolveOptions, loadProjectConfig } from "./config.js";
6
6
  import { detectLanguage } from "./registry.js";
7
- import { resolveImportPath } from "./resolver.js";
8
- /** Recursively collect call expressions from a subtree. */
7
+ import { resolveImportPath, getOrBuildCrossLangIndex } from "./resolver.js";
8
+ import { resolveCrossLangTarget } from "./crosslang.js";
9
+ const CROSS_LANG = new Set(["java", "csharp", "rust", "go"]);
10
+ function pushCall(out, callee, anchor) {
11
+ if (callee && anchor)
12
+ out.push({ callee, line: anchor.startPosition.row + 1 });
13
+ }
9
14
  function collectCalls(node, out) {
10
- // TypeScript / JavaScript / Go use "call_expression"; Python uses "call"
11
- if (node.type === "call_expression" || node.type === "call") {
15
+ const t = node.type;
16
+ // ── call_expression: TS/JS (member_expression) | Python "call" (attribute) |
17
+ // Go (selector_expression) | Rust (field_expression, scoped_identifier)
18
+ if (t === "call_expression" || t === "call") {
12
19
  const fn = node.childForFieldName("function");
13
20
  if (fn) {
14
21
  let callee = null;
15
- if (fn.type === "identifier") {
16
- callee = fn.text;
22
+ switch (fn.type) {
23
+ case "identifier":
24
+ callee = fn.text;
25
+ break;
26
+ case "member_expression":
27
+ case "attribute": {
28
+ const obj = fn.childForFieldName("object");
29
+ const prop = fn.childForFieldName("property") ?? fn.childForFieldName("attribute");
30
+ if (prop)
31
+ callee = obj ? `${obj.text}.${prop.text}` : prop.text;
32
+ break;
33
+ }
34
+ case "field_expression": {
35
+ // Rust: inv.reserve — fields are `value` and `field`
36
+ const obj = fn.childForFieldName("value");
37
+ const fld = fn.childForFieldName("field");
38
+ if (fld)
39
+ callee = obj ? `${obj.text}.${fld.text}` : fld.text;
40
+ break;
41
+ }
42
+ case "scoped_identifier":
43
+ // Rust: String::from / helpers::format — keep full path
44
+ callee = fn.text;
45
+ break;
46
+ case "selector_expression": {
47
+ // Go: pkg.Func
48
+ const obj = fn.childForFieldName("operand");
49
+ const fld = fn.childForFieldName("field");
50
+ if (fld)
51
+ callee = obj ? `${obj.text}.${fld.text}` : fld.text;
52
+ break;
53
+ }
17
54
  }
18
- else if (fn.type === "member_expression" || fn.type === "attribute") {
19
- // TS/JS: member_expression with "object"/"property" fields
20
- // Python: attribute with "object"/"attribute" fields
21
- const obj = fn.childForFieldName("object");
22
- const prop = fn.childForFieldName("property") ?? fn.childForFieldName("attribute");
23
- if (prop)
24
- callee = obj ? `${obj.text}.${prop.text}` : prop.text;
55
+ pushCall(out, callee, fn);
56
+ }
57
+ }
58
+ // ── Java method invocation
59
+ else if (t === "method_invocation") {
60
+ const name = node.childForFieldName("name");
61
+ const obj = node.childForFieldName("object");
62
+ if (name)
63
+ pushCall(out, obj ? `${obj.text}.${name.text}` : name.text, name);
64
+ }
65
+ // ── C# invocation expression
66
+ else if (t === "invocation_expression") {
67
+ const fn = node.childForFieldName("function");
68
+ if (fn)
69
+ pushCall(out, fn.text, fn);
70
+ }
71
+ // ── Java + C# constructor call: new Foo(...)
72
+ else if (t === "object_creation_expression") {
73
+ let typeNode = node.childForFieldName("type");
74
+ if (!typeNode) {
75
+ for (let i = 0; i < node.namedChildCount; i++) {
76
+ const c = node.namedChild(i);
77
+ if (c &&
78
+ (c.type === "identifier" ||
79
+ c.type === "type_identifier" ||
80
+ c.type === "scoped_identifier" ||
81
+ c.type === "qualified_name" ||
82
+ c.type === "generic_type")) {
83
+ typeNode = c;
84
+ break;
85
+ }
25
86
  }
26
- if (callee)
27
- out.push({ callee, line: fn.startPosition.row + 1 });
28
87
  }
88
+ if (typeNode)
89
+ pushCall(out, `new ${typeNode.text}`, typeNode);
29
90
  }
30
91
  for (let i = 0; i < node.namedChildCount; i++) {
31
92
  const c = node.namedChild(i);
@@ -33,33 +94,27 @@ function collectCalls(node, out) {
33
94
  collectCalls(c, out);
34
95
  }
35
96
  }
36
- /**
37
- * Walk the AST and return the first function/method node whose declared name
38
- * matches `name`. Handles:
39
- * - function declarations (TS/JS/Go)
40
- * - const/let arrow functions and function expressions (TS/JS)
41
- * - method definitions inside classes (TS/JS)
42
- * - function_definition (Python)
43
- * - method_declaration (Go)
44
- */
97
+ // ─── Function-node finder ─────────────────────────────────────────────────────
98
+ const FUNCTION_NODE_TYPES = new Set([
99
+ "function_declaration", // TS / JS / Go
100
+ "generator_function_declaration",
101
+ "method_definition", // TS / JS class member
102
+ "method_signature",
103
+ "abstract_method_signature",
104
+ "function_definition", // Python
105
+ "async_function_definition", // Python async
106
+ "method_declaration", // Go / Java / C#
107
+ "constructor_declaration", // Java / C#
108
+ "function_item", // Rust
109
+ ]);
45
110
  function findFunctionNode(root, name) {
46
111
  function walk(node) {
47
- const t = node.type;
48
- // Direct named functions / methods
49
- if (t === "function_declaration" ||
50
- t === "generator_function_declaration" ||
51
- t === "method_definition" ||
52
- t === "method_signature" ||
53
- t === "abstract_method_signature" ||
54
- t === "function_definition" || // Python
55
- t === "async_function_definition" || // Python async
56
- t === "method_declaration" // Go
57
- ) {
112
+ if (FUNCTION_NODE_TYPES.has(node.type)) {
58
113
  if (node.childForFieldName("name")?.text === name)
59
114
  return node;
60
115
  }
61
- // const foo = () => ... or const foo = function() ...
62
- if (t === "variable_declarator") {
116
+ // const foo = () => ... | const foo = function() ...
117
+ if (node.type === "variable_declarator") {
63
118
  const declName = node.childForFieldName("name")?.text;
64
119
  const value = node.childForFieldName("value");
65
120
  if (declName === name &&
@@ -70,7 +125,6 @@ function findFunctionNode(root, name) {
70
125
  return value;
71
126
  }
72
127
  }
73
- // Recurse
74
128
  for (let i = 0; i < node.namedChildCount; i++) {
75
129
  const c = node.namedChild(i);
76
130
  if (c) {
@@ -83,16 +137,7 @@ function findFunctionNode(root, name) {
83
137
  }
84
138
  return walk(root);
85
139
  }
86
- // ─── Destructuring alias tracker ─────────────────────────────────────────────
87
- /**
88
- * Walk a subtree and collect variable destructuring patterns where the source
89
- * is a known import. Handles:
90
- * const { sign, verify } = jwt; → sign/verify → (jwt's source)
91
- * const { readFile: rf } = fs; → rf → (fs's source)
92
- * let { a, b } = someNamespace.nested; → a/b → (someNamespace's source)
93
- *
94
- * Returns a map of localAlias → moduleSpecifier (same format as importMap).
95
- */
140
+ // ─── Destructuring alias tracker (TS/JS only) ─────────────────────────────────
96
141
  function collectDestructuredAliases(node, importMap) {
97
142
  const aliases = new Map();
98
143
  function walk(n) {
@@ -100,20 +145,18 @@ function collectDestructuredAliases(node, importMap) {
100
145
  const nameNode = n.childForFieldName("name");
101
146
  const valueNode = n.childForFieldName("value");
102
147
  if (nameNode && valueNode && nameNode.type === "object_pattern") {
103
- // value might be `jwt` or `jwt.utils` — base is the first identifier
104
148
  const baseName = valueNode.text.split(".")[0];
105
- const origin = importMap.get(baseName) ?? aliases.get(baseName);
149
+ const originRef = importMap.get(baseName);
150
+ const origin = originRef?.from ?? aliases.get(baseName);
106
151
  if (origin) {
107
152
  for (let i = 0; i < nameNode.namedChildCount; i++) {
108
153
  const prop = nameNode.namedChild(i);
109
154
  if (!prop)
110
155
  continue;
111
- // { sign } — shorthand
112
156
  if (prop.type === "shorthand_property_identifier_pattern" ||
113
157
  prop.type === "shorthand_property_identifier") {
114
158
  aliases.set(prop.text, origin);
115
159
  }
116
- // { readFile: rf } — renamed
117
160
  if (prop.type === "pair_pattern") {
118
161
  const val = prop.childForFieldName("value");
119
162
  if (val)
@@ -132,18 +175,52 @@ function collectDestructuredAliases(node, importMap) {
132
175
  walk(node);
133
176
  return aliases;
134
177
  }
135
- // ─── Public API ───────────────────────────────────────────────────────────────
178
+ // ─── Base identifier of a callee expression ───────────────────────────────────
179
+ /** Take the leftmost identifier from "obj.method" / "Pkg::func" / "new Foo". */
180
+ function baseNameOf(callee) {
181
+ let s = callee;
182
+ if (s.startsWith("new "))
183
+ s = s.slice(4);
184
+ return s.split(/::|\./)[0];
185
+ }
186
+ // ─── Cross-language calledBy scan helper ──────────────────────────────────────
187
+ /** Last segment of a member-style callee — "Helper.fmt" -> "fmt", "compute" -> null. */
188
+ function memberOf(callee) {
189
+ const noNew = callee.startsWith("new ") ? callee.slice(4) : callee;
190
+ const parts = noNew.split(/::|\./);
191
+ return parts.length > 1 ? parts[parts.length - 1] : null;
192
+ }
136
193
  /**
137
- * Build the call graph for a single named function.
138
- *
139
- * @param filePath Absolute path to the source file.
140
- * @param funcName Name of the function/method to analyse.
141
- * @param root Project root (for computing relative paths).
142
- * @param allSkeletons Optional: pre-parsed skeletons of the whole project,
143
- * used to find which files import (and thus call) this function.
144
- *
145
- * Returns null if the language is unsupported or the function is not found.
194
+ * Open a file, parse it, and check whether any call expression references
195
+ * `funcName` — either as a bare call `funcName(...)` or as the trailing
196
+ * member of a qualified call `X.funcName(...)` / `X::funcName(...)`.
197
+ * Used for C# / Go reverse calledBy where namespace/package imports do not
198
+ * name the called symbol.
146
199
  */
200
+ async function fileCallsSymbol(fileAbs, funcName) {
201
+ const lang = detectLanguage(fileAbs);
202
+ if (!lang)
203
+ return false;
204
+ let src;
205
+ try {
206
+ src = fs.readFileSync(fileAbs, "utf8");
207
+ }
208
+ catch {
209
+ return false;
210
+ }
211
+ const root = await parseSource(lang.grammar, src);
212
+ const calls = [];
213
+ collectCalls(root, calls);
214
+ for (const c of calls) {
215
+ if (c.callee === funcName)
216
+ return true;
217
+ const m = memberOf(c.callee);
218
+ if (m === funcName)
219
+ return true;
220
+ }
221
+ return false;
222
+ }
223
+ // ─── Public API ───────────────────────────────────────────────────────────────
147
224
  export async function buildCallGraph(filePath, funcName, root, allSkeletons) {
148
225
  const langEntry = detectLanguage(filePath);
149
226
  if (!langEntry)
@@ -154,25 +231,23 @@ export async function buildCallGraph(filePath, funcName, root, allSkeletons) {
154
231
  const funcNode = findFunctionNode(rootNode, funcName);
155
232
  if (!funcNode)
156
233
  return null;
157
- // Use the body subtree for call extraction (avoids counting the signature itself)
158
234
  const body = funcNode.childForFieldName("body") ?? funcNode;
159
235
  const rawCalls = [];
160
236
  collectCalls(body, rawCalls);
161
- // Parse the file's imports to resolve callee origins
162
237
  const opts = resolveOptions({ detail: "outline", emitHtml: false }, loadProjectConfig(root));
163
238
  const skel = await buildSkeleton(filePath, relPath, opts);
164
- // localName module specifier
239
+ // localName -> full ImportRef (so cross-lang resolution has the flags it needs)
165
240
  const importMap = new Map();
166
241
  for (const imp of skel.imports ?? []) {
167
242
  if (imp.symbol !== "*" && !imp.isSideEffect) {
168
- importMap.set(imp.alias ?? imp.symbol, imp.from);
243
+ importMap.set(imp.alias ?? imp.symbol, imp);
169
244
  }
170
245
  }
171
246
  const localNames = new Set(skel.symbols.map((s) => s.name));
172
- // Track destructured aliases within the function body
173
- // e.g. const { sign } = jwt → sign maps to the same source as jwt
174
247
  const destructuredAliases = collectDestructuredAliases(body, importMap);
175
- // Deduplicate by callee+line and resolve origins
248
+ // Build cross-lang index lazily needed for Java/C#/Rust dispatch.
249
+ const isCrossLang = CROSS_LANG.has(skel.language);
250
+ const crossIndex = isCrossLang ? await getOrBuildCrossLangIndex(root) : null;
176
251
  const calls = [];
177
252
  const seen = new Set();
178
253
  for (const { callee, line } of rawCalls) {
@@ -180,48 +255,153 @@ export async function buildCallGraph(filePath, funcName, root, allSkeletons) {
180
255
  if (seen.has(key))
181
256
  continue;
182
257
  seen.add(key);
183
- const baseName = callee.split(".")[0];
184
- // Check import map first, then destructured aliases
185
- const importFrom = importMap.get(baseName) ?? destructuredAliases.get(baseName);
258
+ const base = baseNameOf(callee);
259
+ const importRef = importMap.get(base);
260
+ const aliasOrigin = destructuredAliases.get(base);
186
261
  const call = { callee, line };
187
- if (importFrom) {
188
- if (importFrom.startsWith(".")) {
189
- const resolvedAbs = resolveImportPath(importFrom, filePath);
262
+ if (importRef) {
263
+ if (isCrossLang && crossIndex) {
264
+ const target = resolveCrossLangTarget(importRef, skel, filePath, root, crossIndex);
265
+ if (target) {
266
+ if (target.kind === "symbol")
267
+ call.calleeFileRel = target.file;
268
+ else if (target.files.length > 0)
269
+ call.calleeFileRel = target.files[0];
270
+ }
271
+ else {
272
+ call.isExternal = true;
273
+ call.calleeFileRel = importRef.from;
274
+ }
275
+ }
276
+ else if (importRef.from.startsWith(".")) {
277
+ const resolvedAbs = resolveImportPath(importRef.from, filePath);
278
+ if (resolvedAbs) {
279
+ call.calleeFileRel = path.relative(root, resolvedAbs).split(path.sep).join("/");
280
+ }
281
+ }
282
+ else {
283
+ call.isExternal = true;
284
+ call.calleeFileRel = importRef.from;
285
+ }
286
+ }
287
+ else if (aliasOrigin) {
288
+ // Destructured aliases are TS/JS only (always relative or external).
289
+ if (aliasOrigin.startsWith(".")) {
290
+ const resolvedAbs = resolveImportPath(aliasOrigin, filePath);
190
291
  if (resolvedAbs) {
191
292
  call.calleeFileRel = path.relative(root, resolvedAbs).split(path.sep).join("/");
192
293
  }
193
294
  }
194
295
  else {
195
296
  call.isExternal = true;
196
- call.calleeFileRel = importFrom;
297
+ call.calleeFileRel = aliasOrigin;
197
298
  }
198
299
  }
199
- else if (localNames.has(baseName)) {
300
+ else if (crossIndex && skel.language === "csharp") {
301
+ // C# `using App.Models;` makes types visible without naming them.
302
+ // Try `<usingNs>.<base>` against the type-by-fqn index.
303
+ for (const ns of skel.imports ?? []) {
304
+ if (!ns.isNamespaceImport)
305
+ continue;
306
+ const f = crossIndex.csharpTypes.get(`${ns.from}.${base}`);
307
+ if (f && f !== skel.file) {
308
+ call.calleeFileRel = f;
309
+ break;
310
+ }
311
+ }
312
+ if (!call.calleeFileRel && localNames.has(base))
313
+ call.isLocal = true;
314
+ }
315
+ else if (crossIndex && skel.language === "java") {
316
+ // Java wildcard import: `import com.example.*;` doesn't name the type.
317
+ for (const wc of skel.imports ?? []) {
318
+ if (wc.symbol !== "*")
319
+ continue;
320
+ const f = crossIndex.javaFqcn.get(`${wc.from}.${base}`);
321
+ if (f && f !== skel.file) {
322
+ call.calleeFileRel = f;
323
+ break;
324
+ }
325
+ }
326
+ if (!call.calleeFileRel && localNames.has(base))
327
+ call.isLocal = true;
328
+ }
329
+ else if (localNames.has(base)) {
200
330
  call.isLocal = true;
201
331
  }
202
332
  calls.push(call);
203
333
  }
204
- // calledBy: files that import this function (reverse import lookup)
334
+ // ── calledBy: who imports this function? ────────────────────────────────
205
335
  const calledBy = [];
206
336
  if (allSkeletons) {
207
337
  for (const otherSkel of allSkeletons) {
208
338
  if (otherSkel.file === relPath)
209
339
  continue;
340
+ const otherIsCrossLang = CROSS_LANG.has(otherSkel.language);
341
+ const otherAbs = path.resolve(root, otherSkel.file);
210
342
  for (const imp of otherSkel.imports ?? []) {
211
343
  const importedName = imp.alias ?? imp.symbol;
212
344
  if (importedName !== funcName && imp.symbol !== funcName)
213
345
  continue;
214
- if (!imp.from.startsWith("."))
215
- continue;
216
- const otherAbs = path.resolve(root, otherSkel.file);
217
- const resolvedAbs = resolveImportPath(imp.from, otherAbs);
218
- if (!resolvedAbs)
346
+ if (otherIsCrossLang) {
347
+ // Symbol-level cross-lang match only — file/namespace edges are too
348
+ // broad to claim "this file calls funcName".
349
+ if (!crossIndex)
350
+ continue;
351
+ const target = resolveCrossLangTarget(imp, otherSkel, otherAbs, root, crossIndex);
352
+ if (target && target.kind === "symbol" && target.file === relPath && target.symbol === funcName) {
353
+ calledBy.push({ file: otherSkel.file });
354
+ break;
355
+ }
356
+ }
357
+ else if (imp.from.startsWith(".")) {
358
+ const resolvedAbs = resolveImportPath(imp.from, otherAbs);
359
+ if (!resolvedAbs)
360
+ continue;
361
+ const resolvedRel = path.relative(root, resolvedAbs).split(path.sep).join("/");
362
+ if (resolvedRel === relPath) {
363
+ calledBy.push({ file: otherSkel.file });
364
+ break;
365
+ }
366
+ }
367
+ }
368
+ }
369
+ }
370
+ // Extra pass: for C# / Go, the cross-lang resolver gives file-level targets
371
+ // (namespace / package) so the loop above misses callers that only show up
372
+ // via name-resolution at the call site. Scan candidate files' call sites.
373
+ if (allSkeletons &&
374
+ crossIndex &&
375
+ (skel.language === "csharp" || skel.language === "go")) {
376
+ const seenFiles = new Set(calledBy.map((c) => c.file));
377
+ for (const otherSkel of allSkeletons) {
378
+ if (otherSkel.file === relPath)
379
+ continue;
380
+ if (otherSkel.language !== skel.language)
381
+ continue;
382
+ if (seenFiles.has(otherSkel.file))
383
+ continue;
384
+ const otherAbs = path.resolve(root, otherSkel.file);
385
+ // Confirm this other file imports / uses something that resolves to us.
386
+ let importsUs = false;
387
+ for (const imp of otherSkel.imports ?? []) {
388
+ const target = resolveCrossLangTarget(imp, otherSkel, otherAbs, root, crossIndex);
389
+ if (!target)
219
390
  continue;
220
- const resolvedRel = path.relative(root, resolvedAbs).split(path.sep).join("/");
221
- if (resolvedRel === relPath) {
222
- calledBy.push({ file: otherSkel.file });
391
+ if (target.kind === "file" && target.files.includes(relPath)) {
392
+ importsUs = true;
223
393
  break;
224
394
  }
395
+ if (target.kind === "symbol" && target.file === relPath) {
396
+ importsUs = true;
397
+ break;
398
+ }
399
+ }
400
+ if (!importsUs)
401
+ continue;
402
+ if (await fileCallsSymbol(otherAbs, funcName)) {
403
+ calledBy.push({ file: otherSkel.file });
404
+ seenFiles.add(otherSkel.file);
225
405
  }
226
406
  }
227
407
  }