universal-ast-mapper 0.5.2 → 0.7.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/dist/resolver.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { buildSkeleton } from "./skeleton.js";
3
+ import { buildSkeleton, collectSourceFiles } from "./skeleton.js";
4
4
  import { resolveOptions } from "./config.js";
5
5
  import { findSymbol } from "./analysis.js";
6
+ import { buildCrossLangIndex, resolveCrossLangTarget, } from "./crosslang.js";
6
7
  const SRC_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
7
- /** Extract the outermost balanced parenthesised group from a signature string. */
8
8
  function extractParams(sig) {
9
9
  const start = sig.indexOf("(");
10
10
  if (start === -1)
@@ -21,8 +21,6 @@ function extractParams(sig) {
21
21
  }
22
22
  return null;
23
23
  }
24
- // TypeScript ESM: `import from "./foo.js"` actually means `./foo.ts` on disk.
25
- // Map each JS-family extension to the TS-family equivalents we should try first.
26
24
  const JS_TO_TS = {
27
25
  ".js": [".ts", ".tsx", ".js"],
28
26
  ".jsx": [".tsx", ".jsx"],
@@ -30,8 +28,7 @@ const JS_TO_TS = {
30
28
  ".cjs": [".cts", ".cjs"],
31
29
  };
32
30
  /**
33
- * Resolve a relative import path from a source file to an absolute path on disk.
34
- * Handles TypeScript ESM convention (`.js` in source → `.ts` on disk).
31
+ * Resolve a TS/JS-style relative import path to an absolute file path.
35
32
  * Returns null for external packages or when the file cannot be found.
36
33
  */
37
34
  export function resolveImportPath(importFrom, fromAbs) {
@@ -40,7 +37,6 @@ export function resolveImportPath(importFrom, fromAbs) {
40
37
  const fromDir = path.dirname(fromAbs);
41
38
  const candidate = path.resolve(fromDir, importFrom);
42
39
  const declaredExt = path.extname(candidate).toLowerCase();
43
- // If the import has a JS-family extension, try the TS equivalents first
44
40
  if (declaredExt && JS_TO_TS[declaredExt]) {
45
41
  const base = candidate.slice(0, candidate.length - declaredExt.length);
46
42
  for (const ext of JS_TO_TS[declaredExt]) {
@@ -49,22 +45,17 @@ export function resolveImportPath(importFrom, fromAbs) {
49
45
  return p;
50
46
  }
51
47
  }
52
- // Exact match (already has extension or points to a file)
53
48
  try {
54
49
  const stat = fs.statSync(candidate);
55
50
  if (stat.isFile())
56
51
  return candidate;
57
52
  }
58
- catch {
59
- // not found — try with extensions
60
- }
61
- // Try appending source extensions
53
+ catch { /* not found */ }
62
54
  for (const ext of SRC_EXTS) {
63
55
  const p = candidate + ext;
64
56
  if (fs.existsSync(p))
65
57
  return p;
66
58
  }
67
- // Try index file inside the directory
68
59
  for (const ext of SRC_EXTS) {
69
60
  const p = path.join(candidate, `index${ext}`);
70
61
  if (fs.existsSync(p))
@@ -72,60 +63,126 @@ export function resolveImportPath(importFrom, fromAbs) {
72
63
  }
73
64
  return null;
74
65
  }
75
- /**
76
- * For each import in `skel`, resolve the target file and look up the symbol.
77
- * Returns enriched Reference Objects with resolved path, signature, and params.
78
- */
66
+ /* ─── Cross-language index cache ──────────────────────────────────────────── */
67
+ // Java/C# need a project-wide index to resolve fully-qualified imports.
68
+ // Built lazily on first cross-language resolve, then reused for the process
69
+ // lifetime (the MCP server is per-root, so this is safe).
70
+ const indexCache = new Map();
71
+ export async function getOrBuildCrossLangIndex(root) {
72
+ const key = path.resolve(root);
73
+ let p = indexCache.get(key);
74
+ if (p)
75
+ return p;
76
+ p = (async () => {
77
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
78
+ const files = collectSourceFiles(key, opts);
79
+ const skels = [];
80
+ for (const abs of files) {
81
+ const ext = path.extname(abs).toLowerCase();
82
+ // Only Java/C# contribute to the index (Rust resolves via direct
83
+ // module-path walk against the filesystem, no index needed).
84
+ if (ext !== ".java" && ext !== ".cs")
85
+ continue;
86
+ const rel = path.relative(key, abs).split(path.sep).join("/");
87
+ try {
88
+ skels.push(await buildSkeleton(abs, rel, opts));
89
+ }
90
+ catch { /* skip unparsable files */ }
91
+ }
92
+ return buildCrossLangIndex(skels);
93
+ })();
94
+ indexCache.set(key, p);
95
+ return p;
96
+ }
97
+ /** Test/debug hook: drop the cached index (rebuilds on next call). */
98
+ export function clearCrossLangIndexCache() {
99
+ indexCache.clear();
100
+ }
101
+ async function lookupSymbolInTarget(targetAbs, targetRel, symbol) {
102
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
103
+ try {
104
+ const targetSkel = await buildSkeleton(targetAbs, targetRel, opts);
105
+ const sym = findSymbol(targetSkel.symbols, symbol);
106
+ if (sym) {
107
+ const signature = sym.signature ?? null;
108
+ const out = { found: true, kind: sym.kind };
109
+ if (signature !== undefined)
110
+ out.signature = signature;
111
+ if (signature) {
112
+ const params = extractParams(signature);
113
+ if (params)
114
+ out.params = params;
115
+ }
116
+ return out;
117
+ }
118
+ }
119
+ catch { /* unresolvable / parse error */ }
120
+ return { found: false };
121
+ }
122
+ async function enrichRelativeImport(imp, fromAbs, root) {
123
+ const isExternal = !imp.from.startsWith(".");
124
+ const resolvedAbs = isExternal ? null : resolveImportPath(imp.from, fromAbs);
125
+ const resolvedRel = resolvedAbs
126
+ ? path.relative(root, resolvedAbs).split(path.sep).join("/")
127
+ : null;
128
+ let enrichment = { found: false };
129
+ if (resolvedAbs && !imp.isSideEffect && !imp.isNamespaceImport && imp.symbol !== "*") {
130
+ enrichment = await lookupSymbolInTarget(resolvedAbs, resolvedRel, imp.symbol);
131
+ }
132
+ else if (resolvedAbs) {
133
+ enrichment = { found: true };
134
+ }
135
+ return assembleResolved(imp, resolvedAbs, resolvedRel, isExternal, enrichment);
136
+ }
137
+ async function enrichCrossLangImport(imp, skel, fromAbs, root, index) {
138
+ const target = resolveCrossLangTarget(imp, skel, fromAbs, root, index);
139
+ if (!target) {
140
+ return assembleResolved(imp, null, null, true, { found: false });
141
+ }
142
+ if (target.kind === "file") {
143
+ // Namespace-style (Java wildcard / C# using). Point to the first file —
144
+ // useful for navigation; the symbol itself isn't a specific declaration.
145
+ const firstRel = target.files[0];
146
+ const firstAbs = path.resolve(root, firstRel);
147
+ return assembleResolved(imp, firstAbs, firstRel, false, { found: true });
148
+ }
149
+ // Symbol-level (Java FQCN, Rust crate::path::Item)
150
+ const targetAbs = path.resolve(root, target.file);
151
+ const enrichment = await lookupSymbolInTarget(targetAbs, target.file, target.symbol);
152
+ return assembleResolved(imp, targetAbs, target.file, false, enrichment);
153
+ }
154
+ function assembleResolved(imp, resolvedAbs, resolvedRel, isExternal, enrichment) {
155
+ const out = {
156
+ ...imp,
157
+ resolvedPath: resolvedAbs,
158
+ resolvedRel,
159
+ found: enrichment.found,
160
+ importKind: isExternal ? "external" : "relative",
161
+ };
162
+ if (enrichment.kind !== undefined)
163
+ out.kind = enrichment.kind;
164
+ if (enrichment.signature !== undefined)
165
+ out.signature = enrichment.signature;
166
+ if (enrichment.params !== undefined)
167
+ out.params = enrichment.params;
168
+ return out;
169
+ }
170
+ /* ─── Public entry point ──────────────────────────────────────────────────── */
171
+ const CROSS_LANG = new Set(["java", "csharp", "rust", "go"]);
79
172
  export async function resolveFileImports(skel, absPath, root) {
80
173
  if (!skel.imports || skel.imports.length === 0)
81
174
  return [];
82
- const opts = resolveOptions({ detail: "full", emitHtml: false });
83
175
  const results = [];
176
+ // Lazy-build the cross-lang index only when actually needed.
177
+ let indexPromise = null;
178
+ const getIndex = () => (indexPromise ??= getOrBuildCrossLangIndex(root));
84
179
  for (const imp of skel.imports) {
85
- const isExternal = !imp.from.startsWith(".");
86
- const resolvedAbs = isExternal ? null : resolveImportPath(imp.from, absPath);
87
- const resolvedRel = resolvedAbs
88
- ? path.relative(root, resolvedAbs).split(path.sep).join("/")
89
- : null;
90
- let found = false;
91
- let kind;
92
- let signature;
93
- let params;
94
- if (resolvedAbs && !imp.isSideEffect && !imp.isNamespaceImport && imp.symbol !== "*") {
95
- try {
96
- const targetSkel = await buildSkeleton(resolvedAbs, resolvedRel, opts);
97
- const targetSym = findSymbol(targetSkel.symbols, imp.symbol);
98
- if (targetSym) {
99
- found = true;
100
- kind = targetSym.kind;
101
- signature = targetSym.signature ?? null;
102
- if (signature) {
103
- params = extractParams(signature);
104
- }
105
- }
106
- }
107
- catch {
108
- // target file unresolvable or parse error — leave found=false
109
- }
180
+ if (CROSS_LANG.has(skel.language)) {
181
+ results.push(await enrichCrossLangImport(imp, skel, absPath, root, await getIndex()));
110
182
  }
111
- else if (resolvedAbs) {
112
- // Namespace import or side-effect: file exists = success
113
- found = true;
183
+ else {
184
+ results.push(await enrichRelativeImport(imp, absPath, root));
114
185
  }
115
- const resolved = {
116
- ...imp,
117
- resolvedPath: resolvedAbs,
118
- resolvedRel,
119
- found,
120
- importKind: isExternal ? "external" : "relative",
121
- };
122
- if (kind !== undefined)
123
- resolved.kind = kind;
124
- if (signature !== undefined)
125
- resolved.signature = signature;
126
- if (params !== undefined)
127
- resolved.params = params;
128
- results.push(resolved);
129
186
  }
130
187
  return results;
131
188
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-ast-mapper",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server that maps source files into a normalized code skeleton (JSON + HTML) using tree-sitter.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "dist",
13
+ "scripts",
13
14
  "README.md",
14
15
  "BLUEPRINT.md"
15
16
  ],
@@ -17,7 +18,8 @@
17
18
  "build": "tsc",
18
19
  "start": "node dist/index.js",
19
20
  "smoke": "node test/smoke.mjs",
20
- "test": "node test/smoke.mjs && node test/analysis.mjs"
21
+ "test": "node test/smoke.mjs && node test/analysis.mjs",
22
+ "postinstall": "node scripts/install-skill.mjs"
21
23
  },
22
24
  "engines": {
23
25
  "node": ">=18"
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Postinstall: copies the /ast-map Claude Code skill to ~/.claude/skills/ast-map/
4
+ * so it appears in the / command palette automatically after install.
5
+ * Skips silently if Claude Code is not installed or if running in CI.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+
11
+ // ─── Skip in CI / non-interactive environments ────────────────────────────────
12
+ if (
13
+ process.env.CI ||
14
+ process.env.CONTINUOUS_INTEGRATION ||
15
+ process.env.npm_config_ci ||
16
+ process.env.GITHUB_ACTIONS ||
17
+ process.env.GITLAB_CI
18
+ ) {
19
+ process.exit(0);
20
+ }
21
+
22
+ // ─── Skill content ────────────────────────────────────────────────────────────
23
+
24
+ const SKILL_MD = `---
25
+ name: ast-map
26
+ description: "AST-based code analysis using universal-ast-mapper. Use to understand codebase structure, find dead code, detect circular deps, check blast radius, validate architecture. Works on TypeScript, JavaScript, Python, Go."
27
+ trigger: /ast-map
28
+ ---
29
+
30
+ # /ast-map
31
+
32
+ Run AST-based code analysis on the current project using universal-ast-mapper (tree-sitter).
33
+
34
+ ## Usage
35
+
36
+ \`\`\`
37
+ /ast-map # architecture overview: dead code + cycles + top symbols
38
+ /ast-map dead [dir] # find unused exports
39
+ /ast-map cycles [dir] # detect circular import chains
40
+ /ast-map validate [path] # architecture + structural violations
41
+ /ast-map skeleton <file> # show a file's symbols and imports
42
+ /ast-map top [dir] # top N most-imported symbols (God Nodes)
43
+ /ast-map impact <file> <symbol> # blast radius of changing a symbol
44
+ /ast-map calls <file> <fn> # call graph for a function
45
+ /ast-map search <name> [dir] # find a symbol across all files
46
+ /ast-map deps <file> # what this file imports / what imports it
47
+ \`\`\`
48
+
49
+ If no path is given, use \`.\` (current working directory). Do not ask the user for a path.
50
+
51
+ ---
52
+
53
+ ## What You Must Do When Invoked
54
+
55
+ ### Step 1 — Determine mode
56
+
57
+ Parse the arguments after \`/ast-map\`:
58
+
59
+ | Command | Action |
60
+ |---------|--------|
61
+ | _(no args)_ | Run full overview: dead + cycles + validate |
62
+ | \`dead [dir]\` | Find dead exports |
63
+ | \`cycles [dir]\` | Find circular deps |
64
+ | \`validate [path]\` | Architecture + structural check |
65
+ | \`skeleton <file>\` | File skeleton |
66
+ | \`top [dir]\` | Top imported symbols |
67
+ | \`impact <file> <symbol>\` | Change impact |
68
+ | \`calls <file> <fn>\` | Call graph |
69
+ | \`search <name> [dir]\` | Symbol search |
70
+ | \`deps <file>\` | File dependencies |
71
+
72
+ ### Step 2 — Choose execution method
73
+
74
+ **Prefer MCP tools** (faster, no subprocess). If \`mcp__ast-mapper__*\` tools are available in the current session, use them. Otherwise fall back to CLI.
75
+
76
+ **MCP tool → CLI command mapping:**
77
+
78
+ | Command | MCP Tool | CLI fallback |
79
+ |---------|----------|-------------|
80
+ | dead | \`find_dead_code\` | \`ast-map dead <dir>\` |
81
+ | cycles | \`find_circular_deps\` | \`ast-map cycles <dir>\` |
82
+ | validate | \`validate_architecture\` | \`ast-map validate <path>\` |
83
+ | skeleton | \`get_skeleton_json\` | \`ast-map skeleton <file>\` |
84
+ | top | \`get_top_symbols\` | \`ast-map top <dir>\` |
85
+ | impact | \`get_change_impact\` | \`ast-map impact <file> <symbol>\` |
86
+ | calls | \`get_call_graph\` | \`ast-map calls <file> <fn>\` |
87
+ | search | \`search_symbol\` | \`ast-map search <name> <dir>\` |
88
+ | deps | \`get_file_deps\` | \`ast-map deps <file>\` |
89
+
90
+ ### Step 3 — Full overview (no args)
91
+
92
+ If no command was given, run a 3-part overview and present a unified report:
93
+
94
+ 1. **Dead code** — find high-confidence unused exports
95
+ 2. **Circular deps** — detect import cycles
96
+ 3. **Top symbols** — list the 5 most-imported symbols
97
+
98
+ Then summarise:
99
+ \`\`\`
100
+ AST-MCP Overview — [directory]
101
+ Scanned: N files
102
+
103
+ Dead Code (high confidence): X symbols
104
+ [list, grouped by file]
105
+
106
+ Circular Dependencies: Y cycles
107
+ [list each cycle as A → B → C → A]
108
+
109
+ God Nodes (top 5 most imported):
110
+ 1. symbolName (file) — imported by N files
111
+ ...
112
+
113
+ Recommendation: [1-2 sentences on what to look at first]
114
+ \`\`\`
115
+
116
+ ### Step 4 — Present results clearly
117
+
118
+ - **Dead code**: group by file, show symbol name + kind. Note if 0 ("No dead exports found ✓")
119
+ - **Cycles**: show each cycle as \`A.ts → B.ts → C.ts → A.ts\`. Note length.
120
+ - **validate**: group by severity (errors first, then warnings). Show file + rule + message.
121
+ - **skeleton**: show symbols as a tree with line ranges.
122
+ - **impact**: show direct and transitive file lists + totalFiles count.
123
+ - **calls**: show call list with line numbers + whether external.
124
+ - **search**: show file + symbol + kind + line range.
125
+
126
+ Always end with a relevant follow-up offer:
127
+ - Found dead code? → "Want me to verify each one with \`impact\` before deleting?"
128
+ - Found cycles? → "Want me to show which import to break to resolve the shortest cycle?"
129
+ - Found God Nodes? → "Want me to check the blast radius of the top one?"
130
+
131
+ ---
132
+
133
+ ## Examples
134
+
135
+ ### /ast-map
136
+ Runs dead + cycles + top on the current directory — a 30-second architecture health check.
137
+
138
+ ### /ast-map dead src/
139
+ Finds all exported symbols in \`src/\` that are never imported by any other file.
140
+
141
+ ### /ast-map validate src/ --max-lines 300
142
+ Checks for boundary violations, API routes missing try/catch, files over 300 lines.
143
+
144
+ ### /ast-map impact src/lib/auth.ts validateSession
145
+ Shows every file that directly or transitively depends on \`validateSession\`.
146
+ `;
147
+
148
+ // ─── CLAUDE.md entry ──────────────────────────────────────────────────────────
149
+
150
+ const CLAUDE_MD_ENTRY = `
151
+ # ast-map
152
+ - **ast-map** (\`~/.claude/skills/ast-map/SKILL.md\`) - AST-based code analysis: dead code, circular deps, blast radius, architecture validation, symbol search. Trigger: \`/ast-map\`
153
+ When the user types \`/ast-map\`, invoke the Skill tool with \`skill: "ast-map"\` before doing anything else.
154
+ `;
155
+
156
+ // ─── Install ──────────────────────────────────────────────────────────────────
157
+
158
+ function main() {
159
+ const claudeDir = path.join(os.homedir(), ".claude");
160
+
161
+ // Only install if Claude Code config directory exists
162
+ if (!fs.existsSync(claudeDir)) return;
163
+
164
+ try {
165
+ // 1. Write SKILL.md
166
+ const skillDir = path.join(claudeDir, "skills", "ast-map");
167
+ fs.mkdirSync(skillDir, { recursive: true });
168
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), SKILL_MD, "utf8");
169
+
170
+ // 2. Update CLAUDE.md — idempotent (skip if entry already present)
171
+ const claudeMdPath = path.join(claudeDir, "CLAUDE.md");
172
+ const existing = fs.existsSync(claudeMdPath)
173
+ ? fs.readFileSync(claudeMdPath, "utf8")
174
+ : "";
175
+
176
+ if (!existing.includes('skill: "ast-map"')) {
177
+ fs.writeFileSync(claudeMdPath, existing.trimEnd() + "\n" + CLAUDE_MD_ENTRY, "utf8");
178
+ }
179
+
180
+ console.log("✓ /ast-map skill installed to Claude Code (~/.claude/skills/ast-map/)");
181
+ console.log(' Type /ast-map in any Claude Code session to run code analysis.');
182
+ } catch {
183
+ // Non-fatal — skill install is best-effort, don't break npm install
184
+ }
185
+ }
186
+
187
+ main();