universal-ast-mapper 1.22.1 → 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,36 @@ 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
+
26
+ ## [1.23.0] — 2026-06-10 · Configurable root boundary (multi-root + unlocked)
27
+ - **`AST_MAP_ROOT` accepts multiple roots**, separated by the OS path delimiter
28
+ (`;` Windows / `:` POSIX). The first root is primary; absolute paths inside any
29
+ listed root are allowed.
30
+ - **`AST_MAP_UNLOCKED=1`** — opt-in: the MCP server analyzes **any existing absolute
31
+ path** the client asks for. Default behavior is unchanged (locked to the root list).
32
+ - Every tool now computes rel-paths and graph roots against the **matched** root, so
33
+ reports/graphs on outside-root projects come out correct.
34
+ - Clearer boundary error message (suggests both escape hatches).
35
+ - New module `roots` (`parseRootsFromEnv`, `resolvePathInRoots`); CLI shares the parser.
36
+ - Tests: new `test/roots-smoke.mjs` (13 checks) + end-to-end verified over MCP stdio
37
+ (locked rejects / unlocked analyzes an outside project).
38
+
9
39
  ## [1.22.1] — 2026-06-10 · Docs
10
40
  - README refreshed to match v1.20–1.22: 28 tools / 30 commands, PHP+Ruby capability
11
41
  columns, `cache`/`check` CLI + config + env-var docs, `check_quality_gate` reference,
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.
@@ -79,6 +79,20 @@ node dist/cli.js dead src/
79
79
 
80
80
  > `AST_MAP_ROOT` is the security boundary — the server only reads files inside this path.
81
81
 
82
+ Since **v1.23.0** the boundary is configurable:
83
+
84
+ - **Multi-root** — list several projects in `AST_MAP_ROOT`, separated by the OS path delimiter (`;` on Windows, `:` on macOS/Linux). The first root is the primary (relative paths resolve against it):
85
+
86
+ ```json
87
+ "env": { "AST_MAP_ROOT": "C:\\proj\\app;C:\\proj\\chem_sc_su" }
88
+ ```
89
+
90
+ - **Unlocked** — set `AST_MAP_UNLOCKED: "1"` to let the server analyze **any absolute path** the client asks for (relative paths still resolve against the primary root). Use this for a personal "analyze anything" setup; keep it off for shared/untrusted environments:
91
+
92
+ ```json
93
+ "env": { "AST_MAP_ROOT": "C:\\proj\\app", "AST_MAP_UNLOCKED": "1" }
94
+ ```
95
+
82
96
  ---
83
97
 
84
98
  ## CLI Reference
@@ -787,6 +801,8 @@ Not part of the public API: the internal `src/` module layout and the generated
787
801
 
788
802
  | Version | What changed |
789
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%. |
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. |
790
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**. |
791
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`. |
792
808
  | **1.20.0** | **Incremental cache + parallel parsing** — persistent content-hash parse cache in `.ast-map/cache` (on by default, never stale, warm hits ~60× faster on large files; `ast-map cache stats|clear`, `AST_MAP_NO_CACHE`, `"cache": false`) + worker-thread **parallel parsing** for bulk scans (auto-sized, `AST_MAP_WORKERS` override, sequential fallback). |
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/cli.js CHANGED
@@ -27,7 +27,8 @@ import { findLayerViolations } from "./layers.js";
27
27
  import { computeModuleCoupling } from "./modulecoupling.js";
28
28
  import { buildCallGraph } from "./callgraph.js";
29
29
  import { searchSymbols } from "./search.js";
30
- const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
30
+ import { parseRootsFromEnv } from "./roots.js";
31
+ const ROOT = parseRootsFromEnv().roots[0]; // CLI is local — no boundary, primary root only
31
32
  // Persistent parse cache (disable with AST_MAP_NO_CACHE=1 or "cache": false in config).
32
33
  if (process.env.AST_MAP_NO_CACHE !== "1" && loadProjectConfig(ROOT).cache !== false) {
33
34
  initDiskCache(defaultCacheDir(ROOT));
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/index.js CHANGED
@@ -30,20 +30,20 @@ import { computeCoupling } from "./coupling.js";
30
30
  import { findLayerViolations } from "./layers.js";
31
31
  import { computeModuleCoupling } from "./modulecoupling.js";
32
32
  import { registerPrompts } from "./prompts.js";
33
- /** Files may only be read inside this root (override with AST_MAP_ROOT). */
34
- const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
33
+ import { parseRootsFromEnv, resolvePathInRoots } from "./roots.js";
34
+ /**
35
+ * Security boundary. AST_MAP_ROOT may list several roots (path-delimiter
36
+ * separated); AST_MAP_UNLOCKED=1 allows any absolute path. The first root is
37
+ * the primary — relative inputs resolve against it.
38
+ */
39
+ const ROOTS = parseRootsFromEnv();
40
+ const ROOT = ROOTS.roots[0];
35
41
  // Persistent parse cache (disable with AST_MAP_NO_CACHE=1 or "cache": false in config).
36
42
  if (process.env.AST_MAP_NO_CACHE !== "1" && loadProjectConfig(ROOT).cache !== false) {
37
43
  initDiskCache(defaultCacheDir(ROOT));
38
44
  }
39
45
  function resolveInRoot(input) {
40
- const abs = path.resolve(ROOT, input);
41
- const rel = path.relative(ROOT, abs);
42
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
43
- throw new Error(`Path "${input}" is outside the allowed root (${ROOT}). ` +
44
- `Set the AST_MAP_ROOT environment variable to the project you want to map.`);
45
- }
46
- return { abs, rel: rel === "" ? path.basename(abs) : rel };
46
+ return resolvePathInRoots(input, ROOTS);
47
47
  }
48
48
  function htmlPathFor(rel, opts) {
49
49
  const outDir = opts.outputDir ? path.resolve(ROOT, opts.outputDir) : path.join(ROOT, ".ast-map");
@@ -99,7 +99,7 @@ server.registerTool("get_skeleton_json", {
99
99
  },
100
100
  }, async ({ path: input, detail }) => {
101
101
  try {
102
- const { abs, rel } = resolveInRoot(input);
102
+ const { abs, rel, root } = resolveInRoot(input);
103
103
  if (fs.statSync(abs).isDirectory()) {
104
104
  return errorText(`"${input}" is a directory. Use generate_skeleton for directories.`);
105
105
  }
@@ -135,7 +135,7 @@ server.registerTool("generate_skeleton", {
135
135
  }, async ({ path: input, detail, emitHtml, combineHtml, outputDir }) => {
136
136
  try {
137
137
  const opts = resolveOptions({ detail, emitHtml, combineHtml, outputDir });
138
- const { abs, rel } = resolveInRoot(input);
138
+ const { abs, rel, root } = resolveInRoot(input);
139
139
  const stat = fs.statSync(abs);
140
140
  if (stat.isDirectory()) {
141
141
  const files = collectSourceFiles(abs, opts);
@@ -144,7 +144,7 @@ server.registerTool("generate_skeleton", {
144
144
  let totalSymbols = 0;
145
145
  const items = files.map((file) => ({
146
146
  abs: file,
147
- rel: path.relative(ROOT, file).split(path.sep).join("/"),
147
+ rel: path.relative(root, file).split(path.sep).join("/"),
148
148
  }));
149
149
  const built = await buildSkeletonsBulk(items, opts);
150
150
  for (let i = 0; i < built.length; i++) {
@@ -168,15 +168,15 @@ server.registerTool("generate_skeleton", {
168
168
  let combinedHtmlPath = null;
169
169
  if (opts.combineHtml && successSkeletons.length > 0) {
170
170
  const outDir = opts.outputDir
171
- ? path.resolve(ROOT, opts.outputDir)
172
- : path.join(ROOT, ".ast-map");
171
+ ? path.resolve(root, opts.outputDir)
172
+ : path.join(root, ".ast-map");
173
173
  fs.mkdirSync(outDir, { recursive: true });
174
174
  combinedHtmlPath = path.join(outDir, "index.html");
175
175
  fs.writeFileSync(combinedHtmlPath, renderCombinedHtml(successSkeletons), "utf8");
176
176
  }
177
177
  return jsonText({
178
178
  mode: "directory",
179
- root: ROOT,
179
+ root: root,
180
180
  directory: rel.split(path.sep).join("/"),
181
181
  fileCount: files.length,
182
182
  totalSymbols,
@@ -214,7 +214,7 @@ server.registerTool("get_symbol_context", {
214
214
  },
215
215
  }, async ({ path: input, symbol, kind, includeRelated }) => {
216
216
  try {
217
- const { abs, rel } = resolveInRoot(input);
217
+ const { abs, rel, root } = resolveInRoot(input);
218
218
  if (fs.statSync(abs).isDirectory()) {
219
219
  return errorText(`"${input}" is a directory. Provide a single file path.`);
220
220
  }
@@ -269,8 +269,8 @@ server.registerTool("validate_architecture", {
269
269
  },
270
270
  }, async ({ path: input, maxLines, maxImports, maxExports }) => {
271
271
  try {
272
- const { abs } = resolveInRoot(input);
273
- const projectConfig = loadProjectConfig(ROOT);
272
+ const { abs, root } = resolveInRoot(input);
273
+ const projectConfig = loadProjectConfig(root);
274
274
  const opts = resolveOptions({ detail: "full", emitHtml: false }, projectConfig);
275
275
  const stat = fs.statSync(abs);
276
276
  const filesToCheck = stat.isDirectory()
@@ -284,7 +284,7 @@ server.registerTool("validate_architecture", {
284
284
  };
285
285
  const violations = [];
286
286
  for (const file of filesToCheck) {
287
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
287
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
288
288
  let source;
289
289
  try {
290
290
  source = fs.readFileSync(file, "utf8");
@@ -357,13 +357,13 @@ server.registerTool("resolve_imports", {
357
357
  },
358
358
  }, async ({ path: input }) => {
359
359
  try {
360
- const { abs, rel } = resolveInRoot(input);
360
+ const { abs, rel, root } = resolveInRoot(input);
361
361
  if (fs.statSync(abs).isDirectory()) {
362
362
  return errorText(`"${input}" is a directory. Provide a single file path.`);
363
363
  }
364
364
  const opts = resolveOptions({ detail: "full", emitHtml: false });
365
365
  const skel = await buildSkeleton(abs, rel, opts);
366
- const resolved = await resolveFileImports(skel, abs, ROOT);
366
+ const resolved = await resolveFileImports(skel, abs, root);
367
367
  return jsonText({
368
368
  file: rel,
369
369
  importCount: resolved.length,
@@ -402,7 +402,7 @@ server.registerTool("build_symbol_graph", {
402
402
  },
403
403
  }, async ({ path: input, detail, outputFile }) => {
404
404
  try {
405
- const { abs, rel } = resolveInRoot(input);
405
+ const { abs, rel, root } = resolveInRoot(input);
406
406
  if (!fs.statSync(abs).isDirectory()) {
407
407
  return errorText(`"${input}" is not a directory. build_symbol_graph requires a directory.`);
408
408
  }
@@ -411,7 +411,7 @@ server.registerTool("build_symbol_graph", {
411
411
  const skeletons = [];
412
412
  const errors = [];
413
413
  for (const file of files) {
414
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
414
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
415
415
  try {
416
416
  skeletons.push(await buildSkeleton(file, fileRel, opts));
417
417
  }
@@ -419,7 +419,7 @@ server.registerTool("build_symbol_graph", {
419
419
  errors.push({ file: fileRel, error: describeError(err) });
420
420
  }
421
421
  }
422
- const graph = buildSymbolGraph(skeletons, ROOT);
422
+ const graph = buildSymbolGraph(skeletons, root);
423
423
  if (outputFile) {
424
424
  const { abs: outAbs } = resolveInRoot(outputFile);
425
425
  fs.mkdirSync(path.dirname(outAbs), { recursive: true });
@@ -474,7 +474,7 @@ server.registerTool("find_dead_code", {
474
474
  },
475
475
  }, async ({ path: input, detail }) => {
476
476
  try {
477
- const { abs, rel } = resolveInRoot(input);
477
+ const { abs, rel, root } = resolveInRoot(input);
478
478
  if (!fs.statSync(abs).isDirectory()) {
479
479
  return errorText(`"${input}" is not a directory. find_dead_code requires a directory.`);
480
480
  }
@@ -483,7 +483,7 @@ server.registerTool("find_dead_code", {
483
483
  const skeletons = [];
484
484
  const errors = [];
485
485
  for (const file of files) {
486
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
486
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
487
487
  try {
488
488
  skeletons.push(await buildSkeleton(file, fileRel, opts));
489
489
  }
@@ -491,7 +491,7 @@ server.registerTool("find_dead_code", {
491
491
  errors.push({ file: fileRel, error: describeError(err) });
492
492
  }
493
493
  }
494
- const graph = buildSymbolGraph(skeletons, ROOT);
494
+ const graph = buildSymbolGraph(skeletons, root);
495
495
  const dead = findDeadExports(graph);
496
496
  return jsonText({
497
497
  directory: rel.split(path.sep).join("/"),
@@ -517,7 +517,7 @@ server.registerTool("find_circular_deps", {
517
517
  },
518
518
  }, async ({ path: input }) => {
519
519
  try {
520
- const { abs, rel } = resolveInRoot(input);
520
+ const { abs, rel, root } = resolveInRoot(input);
521
521
  if (!fs.statSync(abs).isDirectory()) {
522
522
  return errorText(`"${input}" is not a directory. find_circular_deps requires a directory.`);
523
523
  }
@@ -526,7 +526,7 @@ server.registerTool("find_circular_deps", {
526
526
  const skeletons = [];
527
527
  const errors = [];
528
528
  for (const file of files) {
529
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
529
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
530
530
  try {
531
531
  skeletons.push(await buildSkeleton(file, fileRel, opts));
532
532
  }
@@ -534,7 +534,7 @@ server.registerTool("find_circular_deps", {
534
534
  errors.push({ file: fileRel, error: describeError(err) });
535
535
  }
536
536
  }
537
- const graph = buildSymbolGraph(skeletons, ROOT);
537
+ const graph = buildSymbolGraph(skeletons, root);
538
538
  const cycles = findCircularDeps(graph);
539
539
  return jsonText({
540
540
  directory: rel.split(path.sep).join("/"),
@@ -561,7 +561,7 @@ server.registerTool("find_duplicate_symbols", {
561
561
  },
562
562
  }, async ({ path: input }) => {
563
563
  try {
564
- const { abs, rel } = resolveInRoot(input);
564
+ const { abs, rel, root } = resolveInRoot(input);
565
565
  if (!fs.statSync(abs).isDirectory()) {
566
566
  return errorText(`"${input}" is not a directory. find_duplicate_symbols requires a directory.`);
567
567
  }
@@ -570,7 +570,7 @@ server.registerTool("find_duplicate_symbols", {
570
570
  const skeletons = [];
571
571
  const errors = [];
572
572
  for (const file of files) {
573
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
573
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
574
574
  try {
575
575
  skeletons.push(await buildSkeleton(file, fileRel, opts));
576
576
  }
@@ -578,7 +578,7 @@ server.registerTool("find_duplicate_symbols", {
578
578
  errors.push({ file: fileRel, error: describeError(err) });
579
579
  }
580
580
  }
581
- const graph = buildSymbolGraph(skeletons, ROOT);
581
+ const graph = buildSymbolGraph(skeletons, root);
582
582
  const duplicates = findDuplicateSymbols(graph);
583
583
  return jsonText({
584
584
  directory: rel.split(path.sep).join("/"),
@@ -604,7 +604,7 @@ server.registerTool("get_complexity", {
604
604
  },
605
605
  }, async ({ path: input }) => {
606
606
  try {
607
- const { abs, rel } = resolveInRoot(input);
607
+ const { abs, rel, root } = resolveInRoot(input);
608
608
  const stat = fs.statSync(abs);
609
609
  if (stat.isDirectory()) {
610
610
  const opts = resolveOptions({ detail: "outline", emitHtml: false });
@@ -612,7 +612,7 @@ server.registerTool("get_complexity", {
612
612
  const results = [];
613
613
  const errors = [];
614
614
  for (const file of files) {
615
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
615
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
616
616
  try {
617
617
  const fc = await computeFileComplexity(file, fileRel);
618
618
  if (fc)
@@ -654,7 +654,7 @@ server.registerTool("find_unused_params", {
654
654
  },
655
655
  }, async ({ path: input }) => {
656
656
  try {
657
- const { abs, rel } = resolveInRoot(input);
657
+ const { abs, rel, root } = resolveInRoot(input);
658
658
  const stat = fs.statSync(abs);
659
659
  if (stat.isDirectory()) {
660
660
  const opts = resolveOptions({ detail: "outline", emitHtml: false });
@@ -662,7 +662,7 @@ server.registerTool("find_unused_params", {
662
662
  const results = [];
663
663
  const errors = [];
664
664
  for (const file of files) {
665
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
665
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
666
666
  try {
667
667
  const r = await findUnusedParams(file, fileRel);
668
668
  if (r && r.functions.length > 0)
@@ -702,7 +702,7 @@ server.registerTool("trace_type", {
702
702
  },
703
703
  }, async ({ type: typeName, path: input }) => {
704
704
  try {
705
- const { abs, rel } = resolveInRoot(input);
705
+ const { abs, rel, root } = resolveInRoot(input);
706
706
  if (!fs.statSync(abs).isDirectory()) {
707
707
  return errorText(`"${input}" is not a directory. trace_type requires a directory.`);
708
708
  }
@@ -711,7 +711,7 @@ server.registerTool("trace_type", {
711
711
  const refs = [];
712
712
  const errors = [];
713
713
  for (const file of files) {
714
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
714
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
715
715
  try {
716
716
  refs.push(...(await traceTypeInFile(file, fileRel, typeName)));
717
717
  }
@@ -747,7 +747,7 @@ server.registerTool("analyze_workspace", {
747
747
  },
748
748
  }, async ({ path: input }) => {
749
749
  try {
750
- const { abs, rel } = resolveInRoot(input ?? ".");
750
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
751
751
  if (!fs.statSync(abs).isDirectory()) {
752
752
  return errorText(`"${input}" is not a directory. analyze_workspace requires a directory.`);
753
753
  }
@@ -777,7 +777,7 @@ server.registerTool("read_source_map", {
777
777
  },
778
778
  }, async ({ path: input }) => {
779
779
  try {
780
- const { abs, rel } = resolveInRoot(input);
780
+ const { abs, rel, root } = resolveInRoot(input);
781
781
  const info = readSourceMap(abs, rel.split(path.sep).join("/"));
782
782
  if (!info)
783
783
  return errorText(`No source map found for "${input}".`);
@@ -798,11 +798,11 @@ server.registerTool("get_codebase_report", {
798
798
  },
799
799
  }, async ({ path: input }) => {
800
800
  try {
801
- const { abs, rel } = resolveInRoot(input ?? ".");
801
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
802
802
  if (!fs.statSync(abs).isDirectory()) {
803
803
  return errorText(`"${input}" is not a directory. get_codebase_report requires a directory.`);
804
804
  }
805
- const data = await buildReport(abs, ROOT);
805
+ const data = await buildReport(abs, root);
806
806
  return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...data });
807
807
  }
808
808
  catch (err) {
@@ -824,12 +824,12 @@ server.registerTool("check_quality_gate", {
824
824
  },
825
825
  }, async ({ path: input, baseline, updateBaseline }) => {
826
826
  try {
827
- const { abs, rel } = resolveInRoot(input ?? ".");
827
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
828
828
  if (!fs.statSync(abs).isDirectory()) {
829
829
  return errorText(`"${input}" is not a directory. check_quality_gate requires a directory.`);
830
830
  }
831
- const thresholds = loadProjectConfig(ROOT).check;
832
- const result = await runQualityGate(abs, ROOT, {
831
+ const thresholds = loadProjectConfig(root).check;
832
+ const result = await runQualityGate(abs, root, {
833
833
  baselinePath: baseline,
834
834
  thresholds,
835
835
  updateBaseline,
@@ -852,10 +852,10 @@ server.registerTool("get_diff", {
852
852
  },
853
853
  }, async ({ base, path: input }) => {
854
854
  try {
855
- if (!isGitRepo(ROOT))
855
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
856
+ if (!isGitRepo(root))
856
857
  return errorText("Not a git repository (or git is unavailable).");
857
- const { abs, rel } = resolveInRoot(input ?? ".");
858
- const data = await computeDiff(abs, ROOT, base ?? "HEAD");
858
+ const data = await computeDiff(abs, root, base ?? "HEAD");
859
859
  return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...data });
860
860
  }
861
861
  catch (err) {
@@ -873,10 +873,10 @@ server.registerTool("get_risk_map", {
873
873
  },
874
874
  }, async ({ path: input }) => {
875
875
  try {
876
- if (!isGitRepo(ROOT))
876
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
877
+ if (!isGitRepo(root))
877
878
  return errorText("Not a git repository (or git is unavailable).");
878
- const { abs, rel } = resolveInRoot(input ?? ".");
879
- const files = await computeRisk(abs, ROOT);
879
+ const files = await computeRisk(abs, root);
880
880
  return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: files.length, files: files.slice(0, 50) });
881
881
  }
882
882
  catch (err) {
@@ -896,11 +896,11 @@ server.registerTool("pack_context", {
896
896
  },
897
897
  }, async ({ path: input, symbol, scan }) => {
898
898
  try {
899
- const { abs, rel } = resolveInRoot(input);
899
+ const { abs, rel, root } = resolveInRoot(input);
900
900
  if (fs.statSync(abs).isDirectory())
901
901
  return errorText(`"${input}" is a directory; pass a file.`);
902
- const scanAbs = scan ? resolveInRoot(scan).abs : ROOT;
903
- const pack = await packContext(abs, rel.split(path.sep).join("/"), ROOT, symbol, scanAbs);
902
+ const scanAbs = scan ? resolveInRoot(scan).abs : root;
903
+ const pack = await packContext(abs, rel.split(path.sep).join("/"), root, symbol, scanAbs);
904
904
  return jsonText(pack);
905
905
  }
906
906
  catch (err) {
@@ -918,7 +918,7 @@ server.registerTool("get_coupling", {
918
918
  },
919
919
  }, async ({ path: input }) => {
920
920
  try {
921
- const { abs, rel } = resolveInRoot(input ?? ".");
921
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
922
922
  if (!fs.statSync(abs).isDirectory()) {
923
923
  return errorText(`"${input}" is not a directory. get_coupling requires a directory.`);
924
924
  }
@@ -926,13 +926,13 @@ server.registerTool("get_coupling", {
926
926
  const files = collectSourceFiles(abs, opts);
927
927
  const skels = [];
928
928
  for (const f of files) {
929
- const r = path.relative(ROOT, f).split(path.sep).join("/");
929
+ const r = path.relative(root, f).split(path.sep).join("/");
930
930
  try {
931
931
  skels.push(await buildSkeleton(f, r, opts));
932
932
  }
933
933
  catch { /* skip */ }
934
934
  }
935
- const metrics = computeCoupling(buildSymbolGraph(skels, ROOT));
935
+ const metrics = computeCoupling(buildSymbolGraph(skels, root));
936
936
  return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: metrics.length, files: metrics });
937
937
  }
938
938
  catch (err) {
@@ -952,7 +952,7 @@ server.registerTool("get_layer_violations", {
952
952
  },
953
953
  }, async ({ path: input, minGap }) => {
954
954
  try {
955
- const { abs, rel } = resolveInRoot(input ?? ".");
955
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
956
956
  if (!fs.statSync(abs).isDirectory()) {
957
957
  return errorText(`"${input}" is not a directory. get_layer_violations requires a directory.`);
958
958
  }
@@ -960,13 +960,13 @@ server.registerTool("get_layer_violations", {
960
960
  const files = collectSourceFiles(abs, opts);
961
961
  const skels = [];
962
962
  for (const f of files) {
963
- const r = path.relative(ROOT, f).split(path.sep).join("/");
963
+ const r = path.relative(root, f).split(path.sep).join("/");
964
964
  try {
965
965
  skels.push(await buildSkeleton(f, r, opts));
966
966
  }
967
967
  catch { /* skip */ }
968
968
  }
969
- const violations = findLayerViolations(buildSymbolGraph(skels, ROOT), minGap ?? 0);
969
+ const violations = findLayerViolations(buildSymbolGraph(skels, root), minGap ?? 0);
970
970
  return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: violations.length, violations });
971
971
  }
972
972
  catch (err) {
@@ -985,7 +985,7 @@ server.registerTool("get_module_coupling", {
985
985
  },
986
986
  }, async ({ path: input }) => {
987
987
  try {
988
- const { abs, rel } = resolveInRoot(input ?? ".");
988
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
989
989
  if (!fs.statSync(abs).isDirectory()) {
990
990
  return errorText(`"${input}" is not a directory. get_module_coupling requires a directory.`);
991
991
  }
@@ -993,13 +993,13 @@ server.registerTool("get_module_coupling", {
993
993
  const files = collectSourceFiles(abs, opts);
994
994
  const skels = [];
995
995
  for (const f of files) {
996
- const r = path.relative(ROOT, f).split(path.sep).join("/");
996
+ const r = path.relative(root, f).split(path.sep).join("/");
997
997
  try {
998
998
  skels.push(await buildSkeleton(f, r, opts));
999
999
  }
1000
1000
  catch { /* skip */ }
1001
1001
  }
1002
- const mc = computeModuleCoupling(buildSymbolGraph(skels, ROOT));
1002
+ const mc = computeModuleCoupling(buildSymbolGraph(skels, root));
1003
1003
  return jsonText({ directory: rel.split(path.sep).join("/") || ".", moduleCount: mc.modules.length, ...mc });
1004
1004
  }
1005
1005
  catch (err) {
@@ -1025,7 +1025,7 @@ server.registerTool("get_change_impact", {
1025
1025
  },
1026
1026
  }, async ({ path: input, symbol, scanDir }) => {
1027
1027
  try {
1028
- const { abs, rel } = resolveInRoot(input);
1028
+ const { abs, rel, root } = resolveInRoot(input);
1029
1029
  if (fs.statSync(abs).isDirectory()) {
1030
1030
  return errorText(`"${input}" is a directory. Provide a single file path.`);
1031
1031
  }
@@ -1034,7 +1034,7 @@ server.registerTool("get_change_impact", {
1034
1034
  const files = collectSourceFiles(scanRoot, opts);
1035
1035
  const skeletons = [];
1036
1036
  for (const file of files) {
1037
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
1037
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
1038
1038
  try {
1039
1039
  skeletons.push(await buildSkeleton(file, fileRel, opts));
1040
1040
  }
@@ -1042,7 +1042,7 @@ server.registerTool("get_change_impact", {
1042
1042
  // skip parse errors
1043
1043
  }
1044
1044
  }
1045
- const graph = buildSymbolGraph(skeletons, ROOT);
1045
+ const graph = buildSymbolGraph(skeletons, root);
1046
1046
  const targetNodeId = `${rel.split(path.sep).join("/")}::${symbol}`;
1047
1047
  const impact = getChangeImpact(graph, targetNodeId);
1048
1048
  if (!impact) {
@@ -1076,7 +1076,7 @@ server.registerTool("get_call_graph", {
1076
1076
  },
1077
1077
  }, async ({ path: input, function: funcName, scanDir }) => {
1078
1078
  try {
1079
- const { abs, rel } = resolveInRoot(input);
1079
+ const { abs, rel, root } = resolveInRoot(input);
1080
1080
  if (fs.statSync(abs).isDirectory()) {
1081
1081
  return errorText(`"${input}" is a directory. Provide a single file path.`);
1082
1082
  }
@@ -1086,7 +1086,7 @@ server.registerTool("get_call_graph", {
1086
1086
  const files = collectSourceFiles(scanRoot, opts);
1087
1087
  const skeletons = [];
1088
1088
  for (const file of files) {
1089
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
1089
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
1090
1090
  try {
1091
1091
  skeletons.push(await buildSkeleton(file, fileRel, opts));
1092
1092
  }
@@ -1094,7 +1094,7 @@ server.registerTool("get_call_graph", {
1094
1094
  // skip
1095
1095
  }
1096
1096
  }
1097
- const result = await buildCallGraph(abs, funcName, ROOT, skeletons);
1097
+ const result = await buildCallGraph(abs, funcName, root, skeletons);
1098
1098
  if (!result) {
1099
1099
  return errorText(`Function "${funcName}" not found in "${rel}", or the file language is unsupported.`);
1100
1100
  }
@@ -1130,11 +1130,11 @@ server.registerTool("search_symbol", {
1130
1130
  },
1131
1131
  }, async ({ path: input, name, matchType, kind, exportedOnly }) => {
1132
1132
  try {
1133
- const { abs, rel } = resolveInRoot(input);
1133
+ const { abs, rel, root } = resolveInRoot(input);
1134
1134
  if (!fs.statSync(abs).isDirectory()) {
1135
1135
  return errorText(`"${input}" is not a directory. search_symbol requires a directory.`);
1136
1136
  }
1137
- const matches = await searchSymbols(abs, name, ROOT, { matchType, kind, exportedOnly });
1137
+ const matches = await searchSymbols(abs, name, root, { matchType, kind, exportedOnly });
1138
1138
  return jsonText({
1139
1139
  directory: rel.split(path.sep).join("/"),
1140
1140
  pattern: name,
@@ -1162,7 +1162,7 @@ server.registerTool("get_file_deps", {
1162
1162
  },
1163
1163
  }, async ({ path: input, scanDir }) => {
1164
1164
  try {
1165
- const { abs, rel } = resolveInRoot(input);
1165
+ const { abs, rel, root } = resolveInRoot(input);
1166
1166
  if (fs.statSync(abs).isDirectory()) {
1167
1167
  return errorText(`"${input}" is a directory. Provide a single file path.`);
1168
1168
  }
@@ -1171,13 +1171,13 @@ server.registerTool("get_file_deps", {
1171
1171
  const files = collectSourceFiles(scanRoot, opts);
1172
1172
  const skeletons = [];
1173
1173
  for (const file of files) {
1174
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
1174
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
1175
1175
  try {
1176
1176
  skeletons.push(await buildSkeleton(file, fileRel, opts));
1177
1177
  }
1178
1178
  catch { /* skip */ }
1179
1179
  }
1180
- const graph = buildSymbolGraph(skeletons, ROOT);
1180
+ const graph = buildSymbolGraph(skeletons, root);
1181
1181
  const fileId = rel.split(path.sep).join("/");
1182
1182
  const result = getFileDeps(graph, fileId);
1183
1183
  if (!result) {
@@ -1210,7 +1210,7 @@ server.registerTool("get_top_symbols", {
1210
1210
  },
1211
1211
  }, async ({ path: input, limit }) => {
1212
1212
  try {
1213
- const { abs, rel } = resolveInRoot(input);
1213
+ const { abs, rel, root } = resolveInRoot(input);
1214
1214
  if (!fs.statSync(abs).isDirectory()) {
1215
1215
  return errorText(`"${input}" is not a directory. get_top_symbols requires a directory.`);
1216
1216
  }
@@ -1218,13 +1218,13 @@ server.registerTool("get_top_symbols", {
1218
1218
  const files = collectSourceFiles(abs, opts);
1219
1219
  const skeletons = [];
1220
1220
  for (const file of files) {
1221
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
1221
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
1222
1222
  try {
1223
1223
  skeletons.push(await buildSkeleton(file, fileRel, opts));
1224
1224
  }
1225
1225
  catch { /* skip */ }
1226
1226
  }
1227
- const graph = buildSymbolGraph(skeletons, ROOT);
1227
+ const graph = buildSymbolGraph(skeletons, root);
1228
1228
  const top = getTopSymbols(graph, limit ?? 10);
1229
1229
  return jsonText({
1230
1230
  directory: rel.split(path.sep).join("/"),
@@ -1313,7 +1313,8 @@ async function main() {
1313
1313
  const transport = new StdioServerTransport();
1314
1314
  await server.connect(transport);
1315
1315
  // stderr is safe for logging; stdout is reserved for the MCP protocol.
1316
- process.stderr.write(`universal-ast-mapper running. root=${ROOT}\n`);
1316
+ process.stderr.write(`universal-ast-mapper running. roots=${ROOTS.roots.join(path.delimiter)}` +
1317
+ (ROOTS.unlocked ? " (UNLOCKED: any absolute path allowed)" : "") + "\n");
1317
1318
  }
1318
1319
  main().catch((err) => {
1319
1320
  process.stderr.write(`Fatal: ${err instanceof Error ? err.stack : String(err)}\n`);
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;
package/dist/roots.js ADDED
@@ -0,0 +1,47 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export function parseRootsFromEnv(env = process.env) {
4
+ const raw = env.AST_MAP_ROOT ?? process.cwd();
5
+ const roots = raw
6
+ .split(path.delimiter)
7
+ .map((p) => p.trim())
8
+ .filter((p) => p.length > 0)
9
+ .map((p) => path.resolve(p));
10
+ return {
11
+ roots: roots.length > 0 ? roots : [path.resolve(process.cwd())],
12
+ unlocked: env.AST_MAP_UNLOCKED === "1",
13
+ };
14
+ }
15
+ function within(root, abs) {
16
+ const rel = path.relative(root, abs);
17
+ if (rel === "")
18
+ return path.basename(abs);
19
+ if (rel.startsWith("..") || path.isAbsolute(rel))
20
+ return null;
21
+ return rel;
22
+ }
23
+ /**
24
+ * Resolve a client-supplied path against the allowed roots.
25
+ * Throws when the path escapes every root and unlocked mode is off.
26
+ */
27
+ export function resolvePathInRoots(input, cfg) {
28
+ const primary = cfg.roots[0];
29
+ const abs = path.resolve(primary, input);
30
+ for (const root of cfg.roots) {
31
+ const rel = within(root, abs);
32
+ if (rel !== null)
33
+ return { abs, rel, root };
34
+ }
35
+ if (cfg.unlocked) {
36
+ if (!fs.existsSync(abs)) {
37
+ throw new Error(`Path "${input}" does not exist (resolved to ${abs}).`);
38
+ }
39
+ const stat = fs.statSync(abs);
40
+ const root = stat.isDirectory() ? abs : path.dirname(abs);
41
+ return { abs, rel: path.basename(abs), root };
42
+ }
43
+ throw new Error(`Path "${input}" is outside the allowed root${cfg.roots.length > 1 ? "s" : ""} ` +
44
+ `(${cfg.roots.join(", ")}). Either set AST_MAP_ROOT to that project ` +
45
+ `(multiple roots allowed, separated by "${path.delimiter}"), or set ` +
46
+ `AST_MAP_UNLOCKED=1 to allow any absolute path.`);
47
+ }
@@ -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.22.1",
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",
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": {