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 +30 -0
- package/README.md +17 -1
- package/dist/callgraph.js +21 -7
- package/dist/cli.js +2 -1
- package/dist/graph.js +2 -2
- package/dist/index.js +80 -79
- package/dist/resolver.js +29 -0
- package/dist/roots.js +47 -0
- package/dist/tsconfig.js +212 -0
- package/package.json +2 -2
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|
391
|
-
const resolvedAbs =
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
172
|
-
: path.join(
|
|
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:
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
832
|
-
const result = await runQualityGate(abs,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 :
|
|
903
|
-
const pack = await packContext(abs, rel.split(path.sep).join("/"),
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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.
|
|
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
|
+
}
|
package/dist/tsconfig.js
ADDED
|
@@ -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.
|
|
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": {
|