universal-ast-mapper 0.8.1 → 0.8.4
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 +30 -6
- package/dist/callgraph.js +1 -1
- package/dist/cli.js +49 -19
- package/dist/crosslang.js +36 -0
- package/dist/extractors/typescript.js +153 -17
- package/dist/graph-analysis.js +36 -0
- package/dist/graph.js +2 -1
- package/dist/index.js +57 -2
- package/dist/resolver.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,12 +10,12 @@ Built on [tree-sitter](https://tree-sitter.github.io/) WASM grammars. Zero regex
|
|
|
10
10
|
|--------------------------|:-----:|:------:|:---:|:----:|:----:|:---:|:---:|:---:|:---:|:-----:|
|
|
11
11
|
| Symbol extraction | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
12
12
|
| Imports parsing | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
13
|
-
| Graph `imports` edges | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
14
|
-
| `resolve_imports` enrich | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
15
|
-
| Call graph callee origin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — |
|
|
16
|
-
| Reverse `calledBy` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — |
|
|
13
|
+
| Graph `imports` edges | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
14
|
+
| `resolve_imports` enrich | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
15
|
+
| Call graph callee origin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — |
|
|
16
|
+
| Reverse `calledBy` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — |
|
|
17
17
|
|
|
18
|
-
> v0.8.0
|
|
18
|
+
> As of v0.8.2, all four v0.8.0 languages have **cross-file graph + resolver** wiring: Kotlin (FQCN/package index), C/C++ (`#include` with header↔impl pairing), and Swift (module = directory under `Sources/`). Call-graph callee origin is resolved for Kotlin; for C/C++/Swift it stays limited because their imports don't name individual symbols. (Ruby grammar in `tree-sitter-wasms@0.1.13` is unstable and was skipped.)
|
|
19
19
|
|
|
20
20
|
Each language uses the resolution strategy that fits it:
|
|
21
21
|
- **TS/JS/Python** — relative paths (`./foo`, `..mod`) resolved against the importing file's directory, with TS-ESM `.js` → `.ts` rewriting.
|
|
@@ -23,6 +23,8 @@ Each language uses the resolution strategy that fits it:
|
|
|
23
23
|
- **Rust** — `Cargo.toml` ancestor → `crate::` / `self::` / `super::` walks; supports `mod.rs` + Rust-2018 sibling-dir style.
|
|
24
24
|
- **Java** — project-wide FQCN index (`package + "." + className → file`) built lazily on first cross-lang call; supports wildcard imports.
|
|
25
25
|
- **C#** — namespace-to-files index plus a `<ns>.<TypeName>` index so `using App.Models` + `new Inventory()` resolves to the right file.
|
|
26
|
+
- **Kotlin** — project-wide FQCN index (`package + "." + ClassName → file`), like Java; wildcard `import pkg.*` pulls every file in the package.
|
|
27
|
+
- **C / C++** — `#include "..."` resolved against the including file's directory; headers auto-paired with same-name `.c`/`.cpp`/`.cc`/`.cxx` impl files. `<system>` includes stay external.
|
|
26
28
|
|
|
27
29
|
For C# and Go (where imports don't name the called symbol), reverse `calledBy` falls back to **call-site scanning** of candidate files.
|
|
28
30
|
|
|
@@ -91,6 +93,7 @@ ast-map graph <dir> [-o graph.json]
|
|
|
91
93
|
ast-map validate <path> [--max-lines N] [--max-imports N] [--max-exports N]
|
|
92
94
|
ast-map dead <dir>
|
|
93
95
|
ast-map cycles <dir>
|
|
96
|
+
ast-map duplicates <dir> [alias: dupes]
|
|
94
97
|
ast-map search <pattern> [dir] [-m contains|exact|regex] [-k kind] [-e]
|
|
95
98
|
ast-map deps <file> [--scan <dir>]
|
|
96
99
|
ast-map top <dir> [-n 10]
|
|
@@ -258,6 +261,24 @@ Each cycle is canonicalised to avoid duplicates.
|
|
|
258
261
|
|
|
259
262
|
---
|
|
260
263
|
|
|
264
|
+
### `find_duplicate_symbols`
|
|
265
|
+
Scan a directory → find symbol names exported from **more than one file** (accidental collisions / parallel implementations). Each result lists every file + kind that declares the name.
|
|
266
|
+
|
|
267
|
+
```json
|
|
268
|
+
{
|
|
269
|
+
"duplicates": [
|
|
270
|
+
{ "symbol": "validate", "count": 2, "locations": [
|
|
271
|
+
{ "file": "src/a.ts", "kind": "function" },
|
|
272
|
+
{ "file": "src/b.ts", "kind": "function" }
|
|
273
|
+
]}
|
|
274
|
+
]
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**Params:** `path`
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
261
282
|
### `get_change_impact`
|
|
262
283
|
Given a file + symbol, reverse-traverse the import graph to compute **blast radius**.
|
|
263
284
|
|
|
@@ -290,7 +311,7 @@ Parse a function body → extract every call expression, resolve callees via the
|
|
|
290
311
|
```
|
|
291
312
|
|
|
292
313
|
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.
|
|
314
|
+
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), Kotlin FQCN/package imports, C/C++ `#include`, and Swift module imports (`import <Module>` → files under `Sources/<Module>/`). Reverse `calledBy` uses call-site scanning for C# and Go where import statements don't name the called symbol.
|
|
294
315
|
|
|
295
316
|
**Params:** `path`, `function`, `scanDir`
|
|
296
317
|
|
|
@@ -480,6 +501,9 @@ src/
|
|
|
480
501
|
|
|
481
502
|
| Version | What changed |
|
|
482
503
|
|---------|--------------|
|
|
504
|
+
| **0.8.4** | **Duplicate symbol detection** — new `find_duplicate_symbols` MCP tool + `ast-map duplicates` (alias `dupes`) CLI command: finds symbol names exported from more than one file, with every file/kind that declares each name. |
|
|
505
|
+
| **0.8.3** | **TSX/React component props** — component symbols now carry extracted prop fields. PascalCase functions/arrows that return JSX or are typed `React.FC<P>`/`FC<P>` get `propsType` (named props type) + `props[]` (name, type, optional), resolved from same-file `interface`/`type` declarations or inline object types. Plus: MCP server now reports its real version from `package.json` (was hardcoded `0.5.3`). |
|
|
506
|
+
| **0.8.2** | **Swift cross-file wiring** — `import <Module>` resolves to that module's files (module = the `Sources/<Module>/` directory, else parent dir), wired into `build_symbol_graph` + `resolve_imports`. System modules (Foundation, UIKit, …) stay external. Completes cross-file graph/resolver support for all four v0.8.0 languages. |
|
|
483
507
|
| **0.8.1** | **Cross-file graph wiring for Kotlin & C/C++** — Kotlin FQCN/package index + C/C++ `#include` resolution (with header↔impl pairing) wired into `build_symbol_graph`, `resolve_imports`, and `get_call_graph`. Fixes a parse-cache rel-path leak (stale `.file` poisoned the cross-lang index → doubled paths) and Kotlin call-graph extraction (`function_declaration` name + field-less `call_expression`). |
|
|
484
508
|
| **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. |
|
|
485
509
|
| **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) |
|
package/dist/callgraph.js
CHANGED
|
@@ -6,7 +6,7 @@ import { resolveOptions, loadProjectConfig } from "./config.js";
|
|
|
6
6
|
import { detectLanguage } from "./registry.js";
|
|
7
7
|
import { resolveImportPath, getOrBuildCrossLangIndex } from "./resolver.js";
|
|
8
8
|
import { resolveCrossLangTarget } from "./crosslang.js";
|
|
9
|
-
const CROSS_LANG = new Set(["java", "csharp", "rust", "go", "kotlin", "c", "cpp"]);
|
|
9
|
+
const CROSS_LANG = new Set(["java", "csharp", "rust", "go", "kotlin", "c", "cpp", "swift"]);
|
|
10
10
|
function pushCall(out, callee, anchor) {
|
|
11
11
|
if (callee && anchor)
|
|
12
12
|
out.push({ callee, line: anchor.startPosition.row + 1 });
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,7 @@ import { supportedLanguages } from "./registry.js";
|
|
|
9
9
|
import { findSymbol, findRelatedSymbols, findServerImports, isApiRoute, findMissingTryCatch, checkGeneralRules, GENERAL_RULE_DEFAULTS } from "./analysis.js";
|
|
10
10
|
import { resolveFileImports } from "./resolver.js";
|
|
11
11
|
import { buildSymbolGraph } from "./graph.js";
|
|
12
|
-
import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols } from "./graph-analysis.js";
|
|
12
|
+
import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols, findDuplicateSymbols } from "./graph-analysis.js";
|
|
13
13
|
import { buildCallGraph } from "./callgraph.js";
|
|
14
14
|
import { searchSymbols } from "./search.js";
|
|
15
15
|
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
@@ -366,6 +366,36 @@ program
|
|
|
366
366
|
}
|
|
367
367
|
console.log();
|
|
368
368
|
});
|
|
369
|
+
// ─── Command: duplicates ──────────────────────────────────────────────────────
|
|
370
|
+
program
|
|
371
|
+
.command("duplicates <dir>")
|
|
372
|
+
.alias("dupes")
|
|
373
|
+
.description("Find symbol names exported from more than one file")
|
|
374
|
+
.option("--json", "Output as JSON")
|
|
375
|
+
.action(async (inputPath, opts) => {
|
|
376
|
+
const { abs, rel } = resolveArg(inputPath);
|
|
377
|
+
if (!fs.statSync(abs).isDirectory())
|
|
378
|
+
die(`"${rel}" is not a directory`);
|
|
379
|
+
const skeletons = await gatherSkeletons(abs);
|
|
380
|
+
const graph = buildSymbolGraph(skeletons, ROOT);
|
|
381
|
+
const duplicates = findDuplicateSymbols(graph);
|
|
382
|
+
if (opts.json)
|
|
383
|
+
return jsonOut({ directory: rel, scanned: skeletons.length, duplicateCount: duplicates.length, duplicates });
|
|
384
|
+
header(`Duplicate Symbols — ${rel}/ ${dim(`(${skeletons.length} files scanned)`)}`);
|
|
385
|
+
if (duplicates.length === 0) {
|
|
386
|
+
console.log(indent(green("✓ No duplicate exported symbols found.")));
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
for (const d of duplicates) {
|
|
390
|
+
console.log(indent(`${yellow(d.symbol)} ${dim(`— exported from ${d.count} files`)}`));
|
|
391
|
+
for (const loc of d.locations) {
|
|
392
|
+
console.log(indent(`${dim(col(loc.kind, 10))} ${loc.file}`, 5));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
console.log(`\n ${yellow(`${duplicates.length} duplicated name(s)`)}`);
|
|
396
|
+
}
|
|
397
|
+
console.log();
|
|
398
|
+
});
|
|
369
399
|
// ─── Command: cycles ──────────────────────────────────────────────────────────
|
|
370
400
|
program
|
|
371
401
|
.command("cycles <dir>")
|
|
@@ -592,24 +622,24 @@ program
|
|
|
592
622
|
.name("ast-map")
|
|
593
623
|
.description("CLI for universal-ast-mapper — structural code analysis tools")
|
|
594
624
|
.version("0.5.3")
|
|
595
|
-
.addHelpText("after", `
|
|
596
|
-
${bold("Examples:")}
|
|
597
|
-
ast-map langs
|
|
598
|
-
ast-map skeleton src/
|
|
599
|
-
ast-map symbol src/utils.ts sanitize --related
|
|
600
|
-
ast-map imports src/pages/login.tsx
|
|
601
|
-
ast-map graph src/ -o graph.json
|
|
602
|
-
ast-map validate src/
|
|
603
|
-
ast-map dead src/
|
|
604
|
-
ast-map cycles src/
|
|
605
|
-
ast-map search validateSession src/ --exported
|
|
606
|
-
ast-map deps src/lib/auth.ts --scan src/
|
|
607
|
-
ast-map top src/ -n 15
|
|
608
|
-
ast-map impact src/utils.ts sanitize --scan src/
|
|
609
|
-
ast-map calls src/utils.ts buildCallGraph --scan src/
|
|
610
|
-
|
|
611
|
-
${bold("Root:")}
|
|
612
|
-
Defaults to cwd. Override with AST_MAP_ROOT=<path> or run from your project root.
|
|
625
|
+
.addHelpText("after", `
|
|
626
|
+
${bold("Examples:")}
|
|
627
|
+
ast-map langs
|
|
628
|
+
ast-map skeleton src/
|
|
629
|
+
ast-map symbol src/utils.ts sanitize --related
|
|
630
|
+
ast-map imports src/pages/login.tsx
|
|
631
|
+
ast-map graph src/ -o graph.json
|
|
632
|
+
ast-map validate src/
|
|
633
|
+
ast-map dead src/
|
|
634
|
+
ast-map cycles src/
|
|
635
|
+
ast-map search validateSession src/ --exported
|
|
636
|
+
ast-map deps src/lib/auth.ts --scan src/
|
|
637
|
+
ast-map top src/ -n 15
|
|
638
|
+
ast-map impact src/utils.ts sanitize --scan src/
|
|
639
|
+
ast-map calls src/utils.ts buildCallGraph --scan src/
|
|
640
|
+
|
|
641
|
+
${bold("Root:")}
|
|
642
|
+
Defaults to cwd. Override with AST_MAP_ROOT=<path> or run from your project root.
|
|
613
643
|
`);
|
|
614
644
|
program.parseAsync(process.argv).catch(err => {
|
|
615
645
|
console.error(red("Fatal: ") + (err instanceof Error ? err.message : String(err)));
|
package/dist/crosslang.js
CHANGED
|
@@ -26,6 +26,7 @@ export function buildCrossLangIndex(skeletons) {
|
|
|
26
26
|
csharpTypes: new Map(),
|
|
27
27
|
kotlinFqcn: new Map(),
|
|
28
28
|
kotlinPackages: new Map(),
|
|
29
|
+
swiftModules: new Map(),
|
|
29
30
|
};
|
|
30
31
|
for (const skel of skeletons) {
|
|
31
32
|
if (skel.language === "java") {
|
|
@@ -68,9 +69,32 @@ export function buildCrossLangIndex(skeletons) {
|
|
|
68
69
|
index.kotlinFqcn.set(`${pkg}.${sym.name}`, skel.file);
|
|
69
70
|
}
|
|
70
71
|
}
|
|
72
|
+
else if (skel.language === "swift") {
|
|
73
|
+
const mod = swiftModuleOf(skel.file);
|
|
74
|
+
if (!mod)
|
|
75
|
+
continue;
|
|
76
|
+
const arr = index.swiftModules.get(mod) ?? [];
|
|
77
|
+
arr.push(skel.file);
|
|
78
|
+
index.swiftModules.set(mod, arr);
|
|
79
|
+
}
|
|
71
80
|
}
|
|
72
81
|
return index;
|
|
73
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Derive a Swift module name from a project-relative file path.
|
|
85
|
+
* SwiftPM convention: sources live under `Sources/<ModuleName>/...`, so the
|
|
86
|
+
* module is the path segment right after `Sources`. For flat layouts we fall
|
|
87
|
+
* back to the immediate parent directory name. Returns null for bare files.
|
|
88
|
+
*/
|
|
89
|
+
function swiftModuleOf(relFile) {
|
|
90
|
+
const parts = relFile.split("/");
|
|
91
|
+
const srcIdx = parts.lastIndexOf("Sources");
|
|
92
|
+
if (srcIdx >= 0 && srcIdx + 1 < parts.length - 1)
|
|
93
|
+
return parts[srcIdx + 1];
|
|
94
|
+
if (parts.length >= 2)
|
|
95
|
+
return parts[parts.length - 2];
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
74
98
|
/* ─── Rust module resolution ──────────────────────────────────────────────── */
|
|
75
99
|
function findCargoRoot(fromAbs, projectRoot) {
|
|
76
100
|
let dir = path.dirname(fromAbs);
|
|
@@ -385,5 +409,17 @@ export function resolveCrossLangTarget(imp, skel, fromAbs, projectRoot, index) {
|
|
|
385
409
|
return null;
|
|
386
410
|
return { kind: "file", files: filtered };
|
|
387
411
|
}
|
|
412
|
+
if (skel.language === "swift") {
|
|
413
|
+
// `import <Module>` brings in another in-project module's public symbols
|
|
414
|
+
// (no symbol named). Resolve to that module's files; unknown modules
|
|
415
|
+
// (Foundation, UIKit, …) are external.
|
|
416
|
+
const files = index.swiftModules.get(imp.from);
|
|
417
|
+
if (files && files.length > 0) {
|
|
418
|
+
const filtered = files.filter((f) => f !== skel.file);
|
|
419
|
+
if (filtered.length > 0)
|
|
420
|
+
return { kind: "file", files: filtered };
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
388
424
|
return null;
|
|
389
425
|
}
|
|
@@ -22,17 +22,14 @@ export function extractDirectivesTS(root, _source) {
|
|
|
22
22
|
}
|
|
23
23
|
return directives;
|
|
24
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
25
|
export function extractTypeScript(root, _source) {
|
|
30
|
-
|
|
26
|
+
const typeIndex = buildTypeIndex(root);
|
|
27
|
+
return collect(namedChildren(root), false, typeIndex);
|
|
31
28
|
}
|
|
32
|
-
function collect(nodes, exported) {
|
|
29
|
+
function collect(nodes, exported, typeIndex) {
|
|
33
30
|
const out = [];
|
|
34
31
|
for (const n of nodes) {
|
|
35
|
-
const res = handle(n, exported);
|
|
32
|
+
const res = handle(n, exported, typeIndex);
|
|
36
33
|
if (Array.isArray(res))
|
|
37
34
|
out.push(...res);
|
|
38
35
|
else if (res)
|
|
@@ -40,16 +37,16 @@ function collect(nodes, exported) {
|
|
|
40
37
|
}
|
|
41
38
|
return out;
|
|
42
39
|
}
|
|
43
|
-
function handle(node, exported) {
|
|
40
|
+
function handle(node, exported, typeIndex) {
|
|
44
41
|
switch (node.type) {
|
|
45
42
|
case "export_statement":
|
|
46
43
|
// `export <decl>` / `export default <decl>` — mark the inner declarations exported.
|
|
47
|
-
return collect(namedChildren(node), true);
|
|
44
|
+
return collect(namedChildren(node), true, typeIndex);
|
|
48
45
|
case "class_declaration":
|
|
49
46
|
case "abstract_class_declaration": {
|
|
50
47
|
const name = nameOf(node) ?? "(anonymous class)";
|
|
51
48
|
const body = node.childForFieldName("body");
|
|
52
|
-
const children = body ? collect(namedChildren(body), false) : [];
|
|
49
|
+
const children = body ? collect(namedChildren(body), false, typeIndex) : [];
|
|
53
50
|
return makeSymbol({
|
|
54
51
|
name,
|
|
55
52
|
kind: "class",
|
|
@@ -63,7 +60,7 @@ function handle(node, exported) {
|
|
|
63
60
|
case "interface_declaration": {
|
|
64
61
|
const name = nameOf(node) ?? "(anonymous interface)";
|
|
65
62
|
const body = node.childForFieldName("body");
|
|
66
|
-
const children = body ? collect(namedChildren(body), false) : [];
|
|
63
|
+
const children = body ? collect(namedChildren(body), false, typeIndex) : [];
|
|
67
64
|
return makeSymbol({
|
|
68
65
|
name,
|
|
69
66
|
kind: "interface",
|
|
@@ -78,7 +75,7 @@ function handle(node, exported) {
|
|
|
78
75
|
case "generator_function_declaration": {
|
|
79
76
|
const name = nameOf(node) ?? "(anonymous function)";
|
|
80
77
|
const body = node.childForFieldName("body");
|
|
81
|
-
|
|
78
|
+
const fnSym = makeSymbol({
|
|
82
79
|
name,
|
|
83
80
|
kind: "function",
|
|
84
81
|
node,
|
|
@@ -87,6 +84,8 @@ function handle(node, exported) {
|
|
|
87
84
|
exported,
|
|
88
85
|
doc: leadingComment(node),
|
|
89
86
|
});
|
|
87
|
+
attachComponentInfo(fnSym, node, null, name, typeIndex);
|
|
88
|
+
return fnSym;
|
|
90
89
|
}
|
|
91
90
|
case "type_alias_declaration":
|
|
92
91
|
return makeSymbol({
|
|
@@ -109,7 +108,7 @@ function handle(node, exported) {
|
|
|
109
108
|
});
|
|
110
109
|
case "lexical_declaration":
|
|
111
110
|
case "variable_declaration":
|
|
112
|
-
return fromVariableDeclaration(node, exported);
|
|
111
|
+
return fromVariableDeclaration(node, exported, typeIndex);
|
|
113
112
|
case "method_definition":
|
|
114
113
|
case "method_signature":
|
|
115
114
|
case "abstract_method_signature": {
|
|
@@ -148,7 +147,7 @@ function handle(node, exported) {
|
|
|
148
147
|
return null;
|
|
149
148
|
}
|
|
150
149
|
}
|
|
151
|
-
function fromVariableDeclaration(node, exported) {
|
|
150
|
+
function fromVariableDeclaration(node, exported, typeIndex) {
|
|
152
151
|
const out = [];
|
|
153
152
|
for (const decl of namedChildren(node)) {
|
|
154
153
|
if (decl.type !== "variable_declarator")
|
|
@@ -159,7 +158,7 @@ function fromVariableDeclaration(node, exported) {
|
|
|
159
158
|
continue;
|
|
160
159
|
if (value && (value.type === "arrow_function" || value.type === "function" || value.type === "function_expression")) {
|
|
161
160
|
const body = value.childForFieldName("body");
|
|
162
|
-
|
|
161
|
+
const arrowSym = makeSymbol({
|
|
163
162
|
name,
|
|
164
163
|
kind: "function",
|
|
165
164
|
node: decl,
|
|
@@ -167,12 +166,14 @@ function fromVariableDeclaration(node, exported) {
|
|
|
167
166
|
signature: headerSignature(value, body),
|
|
168
167
|
exported,
|
|
169
168
|
doc: leadingComment(node),
|
|
170
|
-
})
|
|
169
|
+
});
|
|
170
|
+
attachComponentInfo(arrowSym, value, decl, name, typeIndex);
|
|
171
|
+
out.push(arrowSym);
|
|
171
172
|
}
|
|
172
173
|
else if (value && (value.type === "class_expression" || value.type === "class")) {
|
|
173
174
|
// const MyClass = class { ... }
|
|
174
175
|
const body = value.childForFieldName("body");
|
|
175
|
-
const children = body ? collect(namedChildren(body), false) : [];
|
|
176
|
+
const children = body ? collect(namedChildren(body), false, typeIndex) : [];
|
|
176
177
|
out.push(makeSymbol({
|
|
177
178
|
name,
|
|
178
179
|
kind: "class",
|
|
@@ -318,3 +319,138 @@ function memberVisibility(node) {
|
|
|
318
319
|
return "private";
|
|
319
320
|
return "public";
|
|
320
321
|
}
|
|
322
|
+
// ─── React/TSX component prop extraction ──────────────────────────────────────
|
|
323
|
+
const JSX_NODES = new Set(["jsx_element", "jsx_self_closing_element", "jsx_fragment"]);
|
|
324
|
+
function firstNamed(node) {
|
|
325
|
+
return node.namedChildCount > 0 ? node.namedChild(0) : null;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Index every top-level (and exported) interface / object-type alias by name,
|
|
329
|
+
* mapping it to its prop fields. Used to resolve a component's named props type
|
|
330
|
+
* (e.g. `ButtonProps`) back to its individual props.
|
|
331
|
+
*/
|
|
332
|
+
function buildTypeIndex(root) {
|
|
333
|
+
const idx = new Map();
|
|
334
|
+
const visit = (nodes) => {
|
|
335
|
+
for (const n of nodes) {
|
|
336
|
+
if (n.type === "export_statement") {
|
|
337
|
+
visit(namedChildren(n));
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (n.type === "interface_declaration") {
|
|
341
|
+
const name = nameOf(n);
|
|
342
|
+
const body = n.childForFieldName("body");
|
|
343
|
+
if (name && body)
|
|
344
|
+
idx.set(name, propsFromMembers(body));
|
|
345
|
+
}
|
|
346
|
+
else if (n.type === "type_alias_declaration") {
|
|
347
|
+
const name = nameOf(n);
|
|
348
|
+
const val = n.childForFieldName("value");
|
|
349
|
+
if (name && val && val.type === "object_type")
|
|
350
|
+
idx.set(name, propsFromMembers(val));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
visit(namedChildren(root));
|
|
355
|
+
return idx;
|
|
356
|
+
}
|
|
357
|
+
/** Read `property_signature` members out of an interface_body / object_type. */
|
|
358
|
+
function propsFromMembers(container) {
|
|
359
|
+
const props = [];
|
|
360
|
+
for (const m of namedChildren(container)) {
|
|
361
|
+
if (m.type !== "property_signature")
|
|
362
|
+
continue;
|
|
363
|
+
const nameNode = m.childForFieldName("name");
|
|
364
|
+
if (!nameNode)
|
|
365
|
+
continue;
|
|
366
|
+
const info = { name: nameNode.text };
|
|
367
|
+
const typeAnn = m.childForFieldName("type");
|
|
368
|
+
const typeNode = typeAnn ? firstNamed(typeAnn) : null;
|
|
369
|
+
if (typeNode)
|
|
370
|
+
info.type = typeNode.text.replace(/\s+/g, " ").trim();
|
|
371
|
+
const colon = m.text.indexOf(":");
|
|
372
|
+
const head = colon >= 0 ? m.text.slice(0, colon) : m.text;
|
|
373
|
+
if (head.includes("?"))
|
|
374
|
+
info.optional = true;
|
|
375
|
+
props.push(info);
|
|
376
|
+
}
|
|
377
|
+
return props;
|
|
378
|
+
}
|
|
379
|
+
/** Walk a function body looking for any JSX node (marks it a React component). */
|
|
380
|
+
function returnsJSX(node) {
|
|
381
|
+
if (!node)
|
|
382
|
+
return false;
|
|
383
|
+
let found = false;
|
|
384
|
+
const walk = (n) => {
|
|
385
|
+
if (found)
|
|
386
|
+
return;
|
|
387
|
+
if (JSX_NODES.has(n.type)) {
|
|
388
|
+
found = true;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
for (let i = 0; i < n.namedChildCount; i++) {
|
|
392
|
+
const c = n.namedChild(i);
|
|
393
|
+
if (c)
|
|
394
|
+
walk(c);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
walk(node);
|
|
398
|
+
return found;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* If `typeNode` is `FC<P>` / `React.FC<P>` / `FunctionComponent<P>` (or the
|
|
402
|
+
* React-qualified form), return the first type argument node (the props type).
|
|
403
|
+
*/
|
|
404
|
+
function fcTypeArgument(typeNode) {
|
|
405
|
+
if (!typeNode || typeNode.type !== "generic_type")
|
|
406
|
+
return null;
|
|
407
|
+
const base = typeNode.childForFieldName("name");
|
|
408
|
+
const baseText = base ? base.text : "";
|
|
409
|
+
if (!/(^|\.)(FC|FunctionComponent)$/.test(baseText))
|
|
410
|
+
return null;
|
|
411
|
+
for (let i = 0; i < typeNode.namedChildCount; i++) {
|
|
412
|
+
const c = typeNode.namedChild(i);
|
|
413
|
+
if (c && c.type === "type_arguments")
|
|
414
|
+
return firstNamed(c);
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Detect a React component (PascalCase + returns JSX, or typed as FC) and
|
|
420
|
+
* attach its props. `funcNode` is the function/arrow; `declNode` is the
|
|
421
|
+
* variable_declarator when the component is `const X: React.FC<P> = ...`.
|
|
422
|
+
*/
|
|
423
|
+
function attachComponentInfo(sym, funcNode, declNode, name, idx) {
|
|
424
|
+
if (!/^[A-Z]/.test(name))
|
|
425
|
+
return; // components are PascalCase
|
|
426
|
+
let propsTypeNode = null;
|
|
427
|
+
let fc = false;
|
|
428
|
+
if (declNode) {
|
|
429
|
+
const ta = declNode.childForFieldName("type");
|
|
430
|
+
const arg = ta ? fcTypeArgument(firstNamed(ta)) : null;
|
|
431
|
+
if (arg) {
|
|
432
|
+
propsTypeNode = arg;
|
|
433
|
+
fc = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (!fc && !returnsJSX(funcNode.childForFieldName("body")))
|
|
437
|
+
return; // not a component
|
|
438
|
+
if (!propsTypeNode) {
|
|
439
|
+
const params = funcNode.childForFieldName("parameters");
|
|
440
|
+
const first = params ? firstNamed(params) : null; // required/optional_parameter
|
|
441
|
+
const ta = first ? first.childForFieldName("type") : null;
|
|
442
|
+
if (ta)
|
|
443
|
+
propsTypeNode = firstNamed(ta);
|
|
444
|
+
}
|
|
445
|
+
if (!propsTypeNode)
|
|
446
|
+
return; // component, but untyped props — nothing to extract
|
|
447
|
+
if (propsTypeNode.type === "object_type") {
|
|
448
|
+
sym.props = propsFromMembers(propsTypeNode);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const typeName = propsTypeNode.text.replace(/\s+/g, " ").trim();
|
|
452
|
+
sym.propsType = typeName;
|
|
453
|
+
const resolved = idx.get(typeName);
|
|
454
|
+
if (resolved)
|
|
455
|
+
sym.props = resolved;
|
|
456
|
+
}
|
package/dist/graph-analysis.js
CHANGED
|
@@ -241,3 +241,39 @@ export function getTopSymbols(graph, limit = 10) {
|
|
|
241
241
|
}
|
|
242
242
|
return results.sort((a, b) => b.importCount - a.importCount).slice(0, limit);
|
|
243
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
CHANGED
|
@@ -142,7 +142,8 @@ export function buildSymbolGraph(skeletons, root) {
|
|
|
142
142
|
skel.language === "go" ||
|
|
143
143
|
skel.language === "kotlin" ||
|
|
144
144
|
skel.language === "c" ||
|
|
145
|
-
skel.language === "cpp"
|
|
145
|
+
skel.language === "cpp" ||
|
|
146
|
+
skel.language === "swift") {
|
|
146
147
|
wireCrossLangImport(skel, imp, fromFileAbs, root, crossIndex, exportedSymbolMap, edges);
|
|
147
148
|
}
|
|
148
149
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { resolveOptions, loadProjectConfig } from "./config.js";
|
|
8
9
|
import { buildSkeleton, collectSourceFiles, UnsupportedLanguageError, } from "./skeleton.js";
|
|
9
10
|
import { renderHtml, renderCombinedHtml } from "./html.js";
|
|
@@ -11,7 +12,7 @@ import { supportedLanguages } from "./registry.js";
|
|
|
11
12
|
import { findSymbol, findRelatedSymbols, findServerImports, isApiRoute, findMissingTryCatch, checkGeneralRules, GENERAL_RULE_DEFAULTS, } from "./analysis.js";
|
|
12
13
|
import { resolveFileImports } from "./resolver.js";
|
|
13
14
|
import { buildSymbolGraph } from "./graph.js";
|
|
14
|
-
import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols } from "./graph-analysis.js";
|
|
15
|
+
import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols, findDuplicateSymbols } from "./graph-analysis.js";
|
|
15
16
|
import { buildCallGraph } from "./callgraph.js";
|
|
16
17
|
import { searchSymbols } from "./search.js";
|
|
17
18
|
/** Files may only be read inside this root (override with AST_MAP_ROOT). */
|
|
@@ -44,9 +45,19 @@ function errorText(message) {
|
|
|
44
45
|
content: [{ type: "text", text: message }],
|
|
45
46
|
};
|
|
46
47
|
}
|
|
48
|
+
/** Read the package version at runtime so it never drifts from package.json. */
|
|
49
|
+
const PKG_VERSION = (() => {
|
|
50
|
+
try {
|
|
51
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
return JSON.parse(fs.readFileSync(path.join(dir, "..", "package.json"), "utf8")).version;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "0.0.0";
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
47
58
|
const server = new McpServer({
|
|
48
59
|
name: "universal-ast-mapper",
|
|
49
|
-
version:
|
|
60
|
+
version: PKG_VERSION,
|
|
50
61
|
});
|
|
51
62
|
/* ----------------------- tool: list_supported_languages ----------------------- */
|
|
52
63
|
server.registerTool("list_supported_languages", {
|
|
@@ -512,6 +523,50 @@ server.registerTool("find_circular_deps", {
|
|
|
512
523
|
return errorText(describeError(err));
|
|
513
524
|
}
|
|
514
525
|
});
|
|
526
|
+
/* ─────────────────── tool: find_duplicate_symbols ──────────────────────── */
|
|
527
|
+
server.registerTool("find_duplicate_symbols", {
|
|
528
|
+
title: "Find duplicate exported symbols",
|
|
529
|
+
description: "Scan a directory and return symbol names that are exported from more than one file. " +
|
|
530
|
+
"These are often accidental collisions (copy-paste, parallel implementations) that make " +
|
|
531
|
+
"a codebase harder to navigate. Each result lists every file/kind that declares the name.",
|
|
532
|
+
inputSchema: {
|
|
533
|
+
path: z
|
|
534
|
+
.string()
|
|
535
|
+
.describe("Directory to scan, relative to project root or absolute within it."),
|
|
536
|
+
},
|
|
537
|
+
}, async ({ path: input }) => {
|
|
538
|
+
try {
|
|
539
|
+
const { abs, rel } = resolveInRoot(input);
|
|
540
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
541
|
+
return errorText(`"${input}" is not a directory. find_duplicate_symbols requires a directory.`);
|
|
542
|
+
}
|
|
543
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
544
|
+
const files = collectSourceFiles(abs, opts);
|
|
545
|
+
const skeletons = [];
|
|
546
|
+
const errors = [];
|
|
547
|
+
for (const file of files) {
|
|
548
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
549
|
+
try {
|
|
550
|
+
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
errors.push({ file: fileRel, error: describeError(err) });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const graph = buildSymbolGraph(skeletons, ROOT);
|
|
557
|
+
const duplicates = findDuplicateSymbols(graph);
|
|
558
|
+
return jsonText({
|
|
559
|
+
directory: rel.split(path.sep).join("/"),
|
|
560
|
+
scanned: files.length,
|
|
561
|
+
duplicateCount: duplicates.length,
|
|
562
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
563
|
+
duplicates,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
return errorText(describeError(err));
|
|
568
|
+
}
|
|
569
|
+
});
|
|
515
570
|
/* ─────────────────── tool: get_change_impact ───────────────────────────── */
|
|
516
571
|
server.registerTool("get_change_impact", {
|
|
517
572
|
title: "Get change impact (blast radius)",
|
package/dist/resolver.js
CHANGED
|
@@ -81,7 +81,7 @@ export async function getOrBuildCrossLangIndex(root) {
|
|
|
81
81
|
const ext = path.extname(abs).toLowerCase();
|
|
82
82
|
// Only Java/C# contribute to the index (Rust resolves via direct
|
|
83
83
|
// module-path walk against the filesystem, no index needed).
|
|
84
|
-
if (ext !== ".java" && ext !== ".cs" && ext !== ".kt" && ext !== ".kts")
|
|
84
|
+
if (ext !== ".java" && ext !== ".cs" && ext !== ".kt" && ext !== ".kts" && ext !== ".swift")
|
|
85
85
|
continue;
|
|
86
86
|
const rel = path.relative(key, abs).split(path.sep).join("/");
|
|
87
87
|
try {
|
|
@@ -168,7 +168,7 @@ function assembleResolved(imp, resolvedAbs, resolvedRel, isExternal, enrichment)
|
|
|
168
168
|
return out;
|
|
169
169
|
}
|
|
170
170
|
/* ─── Public entry point ──────────────────────────────────────────────────── */
|
|
171
|
-
const CROSS_LANG = new Set(["java", "csharp", "rust", "go", "kotlin", "c", "cpp"]);
|
|
171
|
+
const CROSS_LANG = new Set(["java", "csharp", "rust", "go", "kotlin", "c", "cpp", "swift"]);
|
|
172
172
|
export async function resolveFileImports(skel, absPath, root) {
|
|
173
173
|
if (!skel.imports || skel.imports.length === 0)
|
|
174
174
|
return [];
|
package/package.json
CHANGED