universal-ast-mapper 1.23.0 → 1.24.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/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ since 1.0.0, guarantees a stable MCP tool / CLI surface across the 1.x line.
6
6
 
7
7
  ---
8
8
 
9
+ ## [1.24.0] — 2026-06-10 · TS path-alias resolution
10
+ - Bare imports like `@/components/Button` now resolve through **`tsconfig.json` /
11
+ `jsconfig.json` `compilerOptions.paths`** (+ `baseUrl`): nearest-config lookup above
12
+ the importing file (monorepo-safe, per-process cached), relative `extends` chains
13
+ (child `paths` replace the parent's, per TS semantics), longest-prefix pattern
14
+ matching, candidate probing with the usual extension/index logic.
15
+ - **String-aware JSONC parser** — comments/trailing commas are stripped with a
16
+ character walk, not regex (naive stripping corrupts Next.js configs where `"@/*"`
17
+ pairs with the `*/` inside `"**/*.ts"` include globs).
18
+ - Wired into `resolve_imports` (aliased imports report `importKind: "relative"` +
19
+ resolved file), `build_symbol_graph` (alias edges before workspace-package fallback),
20
+ and the call graph (callee origin + reverse `calledBy`).
21
+ - Real-world effect (Next.js app, 186 files): import graph 31 → **324 edges**;
22
+ dead exports 210 → 153; god nodes now reflect true usage.
23
+ - New module `tsconfig` (`aliasCandidates`, `clearAliasCaches`) + `resolveAliasedImport`
24
+ in the resolver. Tests: new `test/tsalias-smoke.mjs` (15 checks), wired into `npm test`.
25
+
9
26
  ## [1.23.0] — 2026-06-10 · Configurable root boundary (multi-root + unlocked)
10
27
  - **`AST_MAP_ROOT` accepts multiple roots**, separated by the OS path delimiter
11
28
  (`;` Windows / `:` POSIX). The first root is primary; absolute paths inside any
package/README.md CHANGED
@@ -20,7 +20,7 @@ Built on [tree-sitter](https://tree-sitter.github.io/) WASM grammars. Zero regex
20
20
  > 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. (PHP & Ruby landed in v1.22.0 — symbol extraction + imports; cross-file graph wiring for them is the next step. Ruby was unblocked by upgrading `web-tree-sitter` to 0.21.0.)
21
21
 
22
22
  Each language uses the resolution strategy that fits it:
23
- - **TS/JS/Python** — relative paths (`./foo`, `..mod`) resolved against the importing file's directory, with TS-ESM `.js` → `.ts` rewriting.
23
+ - **TS/JS/Python** — relative paths (`./foo`, `..mod`) resolved against the importing file's directory, with TS-ESM `.js` → `.ts` rewriting. **Path aliases** (`@/*` etc.) resolve via the nearest `tsconfig.json`/`jsconfig.json` (`paths` + `baseUrl`, relative `extends`). *(v1.24.0)*
24
24
  - **Go** — `go.mod` ancestor lookup → module path prefix → package directory → all `.go` files (skips `_test.go`).
25
25
  - **Rust** — `Cargo.toml` ancestor → `crate::` / `self::` / `super::` walks; supports `mod.rs` + Rust-2018 sibling-dir style.
26
26
  - **Java** — project-wide FQCN index (`package + "." + className → file`) built lazily on first cross-lang call; supports wildcard imports.
@@ -801,6 +801,7 @@ Not part of the public API: the internal `src/` module layout and the generated
801
801
 
802
802
  | Version | What changed |
803
803
  |---------|--------------|
804
+ | **1.24.0** | **TS path-alias resolution** — bare imports like `@/components/Button` now resolve via the **nearest** `tsconfig.json`/`jsconfig.json` (`compilerOptions.paths` + `baseUrl`, relative `extends` chains, longest-prefix matching, string-aware JSONC parser). Wired into `resolve_imports`, the symbol graph, and the call graph — on a real Next.js app this took the import graph from 31 to **324 edges** and cut false dead-exports by ~30%. |
804
805
  | **1.23.0** | **Configurable root boundary** — `AST_MAP_ROOT` accepts **multiple roots** (path-delimiter separated) and `AST_MAP_UNLOCKED=1` allows analyzing **any absolute path** on request (default stays locked). Analysis/graph/report rel-paths now computed against the matched root, so cross-root results are correct. New `roots` module + 13-check test suite. |
805
806
  | **1.22.0** | **PHP & Ruby support** — `.php` (classes, interfaces, traits, enums, methods with visibility, `use` imports incl. grouped, require/include) and `.rb`/`.rake` (classes, modules, methods, `self.` singleton methods, `private` section tracking, require/require_relative). Unblocked by upgrading `web-tree-sitter` 0.20.8 → 0.21.0 (all existing grammars re-verified). **16 languages**. |
806
807
  | **1.21.0** | **Quality gate** — `ast-map check` fails CI when quality regresses: **baseline ratchet** vs `.ast-map.baseline.json` (cycles · dead exports · SDP · very-high complexity · score; `--update-baseline` re-anchors) + absolute thresholds (flags or config `"check"`). New MCP tool `check_quality_gate` (**28 tools**); GitHub Action gains `mode: check`. |
package/dist/callgraph.js CHANGED
@@ -4,7 +4,7 @@ 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, getOrBuildCrossLangIndex } from "./resolver.js";
7
+ import { resolveImportPath, resolveAliasedImport, getOrBuildCrossLangIndex } from "./resolver.js";
8
8
  import { resolveCrossLangTarget } from "./crosslang.js";
9
9
  const CROSS_LANG = new Set(["java", "csharp", "rust", "go", "kotlin", "c", "cpp", "swift"]);
10
10
  function pushCall(out, callee, anchor) {
@@ -313,8 +313,14 @@ export async function buildCallGraph(filePath, funcName, root, allSkeletons) {
313
313
  }
314
314
  }
315
315
  else {
316
- call.isExternal = true;
317
- call.calleeFileRel = importRef.from;
316
+ const aliasAbs = resolveAliasedImport(importRef.from, filePath);
317
+ if (aliasAbs) {
318
+ call.calleeFileRel = path.relative(root, aliasAbs).split(path.sep).join("/");
319
+ }
320
+ else {
321
+ call.isExternal = true;
322
+ call.calleeFileRel = importRef.from;
323
+ }
318
324
  }
319
325
  }
320
326
  else if (aliasOrigin) {
@@ -326,8 +332,14 @@ export async function buildCallGraph(filePath, funcName, root, allSkeletons) {
326
332
  }
327
333
  }
328
334
  else {
329
- call.isExternal = true;
330
- call.calleeFileRel = aliasOrigin;
335
+ const aliasAbs = resolveAliasedImport(aliasOrigin, filePath);
336
+ if (aliasAbs) {
337
+ call.calleeFileRel = path.relative(root, aliasAbs).split(path.sep).join("/");
338
+ }
339
+ else {
340
+ call.isExternal = true;
341
+ call.calleeFileRel = aliasOrigin;
342
+ }
331
343
  }
332
344
  }
333
345
  else if (crossIndex && skel.language === "csharp") {
@@ -387,8 +399,10 @@ export async function buildCallGraph(filePath, funcName, root, allSkeletons) {
387
399
  break;
388
400
  }
389
401
  }
390
- else if (imp.from.startsWith(".")) {
391
- const resolvedAbs = resolveImportPath(imp.from, otherAbs);
402
+ else {
403
+ const resolvedAbs = imp.from.startsWith(".")
404
+ ? resolveImportPath(imp.from, otherAbs)
405
+ : resolveAliasedImport(imp.from, otherAbs);
392
406
  if (!resolvedAbs)
393
407
  continue;
394
408
  const resolvedRel = path.relative(root, resolvedAbs).split(path.sep).join("/");
package/dist/graph.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { resolveImportPath } from "./resolver.js";
2
+ import { resolveImportPath, resolveAliasedImport } from "./resolver.js";
3
3
  import { resolveWorkspaceImportCached } from "./workspace.js";
4
4
  import { buildCrossLangIndex, resolveCrossLangTarget, } from "./crosslang.js";
5
5
  // ─── Internal helpers ─────────────────────────────────────────────────────────
@@ -38,7 +38,7 @@ function wirePathImport(skel, imp, fromFileAbs, root, exportedSymbolMap, edges)
38
38
  // Relative import → path resolve; bare specifier → monorepo workspace package.
39
39
  const resolvedAbs = imp.from.startsWith(".")
40
40
  ? resolveImportPath(imp.from, fromFileAbs)
41
- : resolveWorkspaceImportCached(imp.from, root);
41
+ : resolveAliasedImport(imp.from, fromFileAbs) ?? resolveWorkspaceImportCached(imp.from, root);
42
42
  if (!resolvedAbs)
43
43
  return;
44
44
  const resolvedRel = path.relative(root, resolvedAbs).split(path.sep).join("/");
package/dist/resolver.js CHANGED
@@ -5,6 +5,7 @@ import { resolveOptions } from "./config.js";
5
5
  import { findSymbol } from "./analysis.js";
6
6
  import { buildCrossLangIndex, resolveCrossLangTarget, } from "./crosslang.js";
7
7
  import { resolveWorkspaceImportCached } from "./workspace.js";
8
+ import { aliasCandidates } from "./tsconfig.js";
8
9
  const SRC_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".vue", ".svelte"];
9
10
  function extractParams(sig) {
10
11
  const start = sig.indexOf("(");
@@ -46,6 +47,10 @@ export function resolveImportPath(importFrom, fromAbs) {
46
47
  return p;
47
48
  }
48
49
  }
50
+ return probeCandidate(candidate);
51
+ }
52
+ /** Probe a path base: exact file → +extensions → /index.<ext>. */
53
+ function probeCandidate(candidate) {
49
54
  try {
50
55
  const stat = fs.statSync(candidate);
51
56
  if (stat.isFile())
@@ -64,6 +69,28 @@ export function resolveImportPath(importFrom, fromAbs) {
64
69
  }
65
70
  return null;
66
71
  }
72
+ /**
73
+ * Resolve a tsconfig/jsconfig path-aliased bare import (e.g. `@/components/X`
74
+ * with `"@/*": ["./src/*"]`) to an absolute file path, using the nearest
75
+ * config above the importing file. Returns null when not an alias.
76
+ */
77
+ export function resolveAliasedImport(importFrom, fromAbs) {
78
+ for (const base of aliasCandidates(importFrom, fromAbs)) {
79
+ const declaredExt = path.extname(base).toLowerCase();
80
+ if (declaredExt && JS_TO_TS[declaredExt]) {
81
+ const stem = base.slice(0, base.length - declaredExt.length);
82
+ for (const ext of JS_TO_TS[declaredExt]) {
83
+ const p = stem + ext;
84
+ if (fs.existsSync(p))
85
+ return p;
86
+ }
87
+ }
88
+ const hit = probeCandidate(base);
89
+ if (hit)
90
+ return hit;
91
+ }
92
+ return null;
93
+ }
67
94
  /* ─── Cross-language index cache ──────────────────────────────────────────── */
68
95
  // Java/C# need a project-wide index to resolve fully-qualified imports.
69
96
  // Built lazily on first cross-language resolve, then reused for the process
@@ -124,6 +151,8 @@ async function enrichRelativeImport(imp, fromAbs, root) {
124
151
  const isBare = !imp.from.startsWith(".");
125
152
  // Relative import → path resolve; bare specifier → try monorepo workspace.
126
153
  let resolvedAbs = isBare ? null : resolveImportPath(imp.from, fromAbs);
154
+ if (!resolvedAbs && isBare)
155
+ resolvedAbs = resolveAliasedImport(imp.from, fromAbs);
127
156
  if (!resolvedAbs && isBare)
128
157
  resolvedAbs = resolveWorkspaceImportCached(imp.from, root);
129
158
  const treatedExternal = isBare && !resolvedAbs;
@@ -0,0 +1,212 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const CONFIG_NAMES = ["tsconfig.json", "jsconfig.json"];
4
+ /**
5
+ * Tolerant JSONC parse. String-aware: comments and trailing commas are removed
6
+ * with a character walk, never with regex — naive stripping corrupts configs
7
+ * whose strings contain comment-like text (e.g. Next.js `"include": ["**\/*.ts"]`
8
+ * pairs the `/*` inside `"@/*"` with the `*\/` inside the glob).
9
+ */
10
+ function parseJsonc(raw) {
11
+ let out = "";
12
+ let i = 0;
13
+ let inStr = false;
14
+ while (i < raw.length) {
15
+ const c = raw[i];
16
+ if (inStr) {
17
+ out += c;
18
+ if (c === "\\") {
19
+ out += raw[i + 1] ?? "";
20
+ i += 2;
21
+ continue;
22
+ }
23
+ if (c === '"')
24
+ inStr = false;
25
+ i++;
26
+ }
27
+ else if (c === '"') {
28
+ inStr = true;
29
+ out += c;
30
+ i++;
31
+ }
32
+ else if (c === "/" && raw[i + 1] === "/") {
33
+ while (i < raw.length && raw[i] !== "\n")
34
+ i++;
35
+ }
36
+ else if (c === "/" && raw[i + 1] === "*") {
37
+ i += 2;
38
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
39
+ i++;
40
+ i += 2;
41
+ }
42
+ else if (c === ",") {
43
+ // trailing comma: skip when the next non-whitespace char closes a scope
44
+ let j = i + 1;
45
+ while (j < raw.length && /\s/.test(raw[j]))
46
+ j++;
47
+ if (raw[j] === "}" || raw[j] === "]")
48
+ i++; // drop the comma
49
+ else {
50
+ out += c;
51
+ i++;
52
+ }
53
+ }
54
+ else {
55
+ out += c;
56
+ i++;
57
+ }
58
+ }
59
+ try {
60
+ return JSON.parse(out);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /** Read a config file, following relative `extends` (child overrides parent). */
67
+ function readConfigChain(configPath, depth = 0) {
68
+ if (depth > 5)
69
+ return null;
70
+ let raw;
71
+ try {
72
+ raw = fs.readFileSync(configPath, "utf8");
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ const json = parseJsonc(raw);
78
+ if (!json || typeof json !== "object")
79
+ return null;
80
+ const dir = path.dirname(configPath);
81
+ let baseUrl;
82
+ let paths;
83
+ let baseDir = dir;
84
+ const ext = json.extends;
85
+ if (typeof ext === "string" && ext.startsWith(".")) {
86
+ let parentPath = path.resolve(dir, ext);
87
+ if (!parentPath.endsWith(".json"))
88
+ parentPath += ".json";
89
+ const parent = readConfigChain(parentPath, depth + 1);
90
+ if (parent) {
91
+ baseUrl = parent.baseUrl;
92
+ paths = parent.paths;
93
+ baseDir = parent.dir; // paths in a parent resolve against the parent's dir
94
+ }
95
+ }
96
+ const co = json.compilerOptions;
97
+ if (co && typeof co === "object") {
98
+ if (typeof co.baseUrl === "string") {
99
+ baseUrl = co.baseUrl;
100
+ baseDir = dir;
101
+ }
102
+ if (co.paths && typeof co.paths === "object") {
103
+ paths = co.paths;
104
+ baseDir = dir;
105
+ }
106
+ }
107
+ return { baseUrl, paths, dir: baseDir };
108
+ }
109
+ function buildAliasConfig(configPath) {
110
+ const merged = readConfigChain(configPath);
111
+ if (!merged || !merged.paths)
112
+ return null;
113
+ const base = path.resolve(merged.dir, merged.baseUrl ?? ".");
114
+ const patterns = [];
115
+ for (const [key, targets] of Object.entries(merged.paths)) {
116
+ if (!Array.isArray(targets) || targets.length === 0)
117
+ continue;
118
+ const star = key.indexOf("*");
119
+ const abs = targets
120
+ .filter((t) => typeof t === "string")
121
+ .map((t) => path.resolve(base, t));
122
+ if (abs.length === 0)
123
+ continue;
124
+ if (star === -1) {
125
+ patterns.push({ prefix: key, suffix: "", exact: true, targets: abs });
126
+ }
127
+ else {
128
+ patterns.push({ prefix: key.slice(0, star), suffix: key.slice(star + 1), exact: false, targets: abs });
129
+ }
130
+ }
131
+ // Longest prefix wins (TypeScript's matching rule).
132
+ patterns.sort((a, b) => b.prefix.length - a.prefix.length);
133
+ return patterns.length > 0 ? { patterns } : null;
134
+ }
135
+ // dir → config path (or null when none found up the tree)
136
+ const configPathCache = new Map();
137
+ // config path → parsed alias config (or null when it has no paths)
138
+ const aliasCache = new Map();
139
+ function findNearestConfig(fromDir) {
140
+ const cached = configPathCache.get(fromDir);
141
+ if (cached !== undefined)
142
+ return cached;
143
+ let dir = fromDir;
144
+ let result = null;
145
+ const visited = [];
146
+ for (;;) {
147
+ const hit = configPathCache.get(dir);
148
+ if (hit !== undefined) {
149
+ result = hit;
150
+ break;
151
+ }
152
+ visited.push(dir);
153
+ let found = null;
154
+ for (const name of CONFIG_NAMES) {
155
+ const p = path.join(dir, name);
156
+ if (fs.existsSync(p)) {
157
+ found = p;
158
+ break;
159
+ }
160
+ }
161
+ if (found) {
162
+ result = found;
163
+ break;
164
+ }
165
+ const parent = path.dirname(dir);
166
+ if (parent === dir || dir.includes("node_modules")) {
167
+ result = null;
168
+ break;
169
+ }
170
+ dir = parent;
171
+ }
172
+ for (const d of visited)
173
+ configPathCache.set(d, result);
174
+ return result;
175
+ }
176
+ /** Test-only: clear the per-process caches. */
177
+ export function clearAliasCaches() {
178
+ configPathCache.clear();
179
+ aliasCache.clear();
180
+ }
181
+ /**
182
+ * Map an aliased bare import to absolute candidate base paths (no extension
183
+ * probing). Empty array = not an alias / no config / no pattern match.
184
+ */
185
+ export function aliasCandidates(importFrom, fromAbs) {
186
+ if (importFrom.startsWith(".") || path.isAbsolute(importFrom))
187
+ return [];
188
+ const configPath = findNearestConfig(path.dirname(fromAbs));
189
+ if (!configPath)
190
+ return [];
191
+ let cfg = aliasCache.get(configPath);
192
+ if (cfg === undefined) {
193
+ cfg = buildAliasConfig(configPath);
194
+ aliasCache.set(configPath, cfg);
195
+ }
196
+ if (!cfg)
197
+ return [];
198
+ for (const p of cfg.patterns) {
199
+ if (p.exact) {
200
+ if (importFrom === p.prefix)
201
+ return p.targets;
202
+ continue;
203
+ }
204
+ if (importFrom.length >= p.prefix.length + p.suffix.length &&
205
+ importFrom.startsWith(p.prefix) &&
206
+ importFrom.endsWith(p.suffix)) {
207
+ const star = importFrom.slice(p.prefix.length, importFrom.length - p.suffix.length);
208
+ return p.targets.map((t) => t.replace("*", star));
209
+ }
210
+ }
211
+ return [];
212
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-ast-mapper",
3
- "version": "1.23.0",
3
+ "version": "1.24.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",
@@ -19,7 +19,7 @@
19
19
  "build": "tsc",
20
20
  "start": "node dist/index.js",
21
21
  "smoke": "node test/smoke.mjs",
22
- "test": "node test/smoke.mjs && node test/analysis.mjs && node test/cache-smoke.mjs && node test/check-smoke.mjs && node test/roots-smoke.mjs",
22
+ "test": "node test/smoke.mjs && node test/analysis.mjs && node test/cache-smoke.mjs && node test/check-smoke.mjs && node test/roots-smoke.mjs && node test/tsalias-smoke.mjs",
23
23
  "postinstall": "node scripts/install-skill.mjs"
24
24
  },
25
25
  "engines": {