universal-ast-mapper 0.5.2
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/BLUEPRINT.md +230 -0
- package/README.md +465 -0
- package/dist/analysis.js +134 -0
- package/dist/callgraph.js +238 -0
- package/dist/cli.js +617 -0
- package/dist/config.js +53 -0
- package/dist/extractors/common.js +54 -0
- package/dist/extractors/go.js +212 -0
- package/dist/extractors/python.js +142 -0
- package/dist/extractors/typescript.js +320 -0
- package/dist/graph-analysis.js +243 -0
- package/dist/graph.js +118 -0
- package/dist/html.js +325 -0
- package/dist/index.js +762 -0
- package/dist/parser.js +84 -0
- package/dist/registry.js +40 -0
- package/dist/resolver.js +131 -0
- package/dist/search.js +68 -0
- package/dist/skeleton.js +106 -0
- package/dist/types.js +5 -0
- package/package.json +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { resolveOptions, loadProjectConfig } from "./config.js";
|
|
8
|
+
import { buildSkeleton, collectSourceFiles, UnsupportedLanguageError, } from "./skeleton.js";
|
|
9
|
+
import { renderHtml, renderCombinedHtml } from "./html.js";
|
|
10
|
+
import { supportedLanguages } from "./registry.js";
|
|
11
|
+
import { findSymbol, findRelatedSymbols, findServerImports, isApiRoute, findMissingTryCatch, checkGeneralRules, GENERAL_RULE_DEFAULTS, } from "./analysis.js";
|
|
12
|
+
import { resolveFileImports } from "./resolver.js";
|
|
13
|
+
import { buildSymbolGraph } from "./graph.js";
|
|
14
|
+
import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols } from "./graph-analysis.js";
|
|
15
|
+
import { buildCallGraph } from "./callgraph.js";
|
|
16
|
+
import { searchSymbols } from "./search.js";
|
|
17
|
+
/** Files may only be read inside this root (override with AST_MAP_ROOT). */
|
|
18
|
+
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
19
|
+
function resolveInRoot(input) {
|
|
20
|
+
const abs = path.resolve(ROOT, input);
|
|
21
|
+
const rel = path.relative(ROOT, abs);
|
|
22
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
23
|
+
throw new Error(`Path "${input}" is outside the allowed root (${ROOT}). ` +
|
|
24
|
+
`Set the AST_MAP_ROOT environment variable to the project you want to map.`);
|
|
25
|
+
}
|
|
26
|
+
return { abs, rel: rel === "" ? path.basename(abs) : rel };
|
|
27
|
+
}
|
|
28
|
+
function htmlPathFor(rel, opts) {
|
|
29
|
+
const outDir = opts.outputDir ? path.resolve(ROOT, opts.outputDir) : path.join(ROOT, ".ast-map");
|
|
30
|
+
return path.join(outDir, `${rel}-skeleton.html`);
|
|
31
|
+
}
|
|
32
|
+
function writeHtml(skel, rel, opts) {
|
|
33
|
+
const target = htmlPathFor(rel, opts);
|
|
34
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
35
|
+
fs.writeFileSync(target, renderHtml(skel), "utf8");
|
|
36
|
+
return target;
|
|
37
|
+
}
|
|
38
|
+
function jsonText(value) {
|
|
39
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
40
|
+
}
|
|
41
|
+
function errorText(message) {
|
|
42
|
+
return {
|
|
43
|
+
isError: true,
|
|
44
|
+
content: [{ type: "text", text: message }],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const server = new McpServer({
|
|
48
|
+
name: "universal-ast-mapper",
|
|
49
|
+
version: "0.5.2",
|
|
50
|
+
});
|
|
51
|
+
/* ----------------------- tool: list_supported_languages ----------------------- */
|
|
52
|
+
server.registerTool("list_supported_languages", {
|
|
53
|
+
title: "List supported languages",
|
|
54
|
+
description: "Returns the languages and file extensions this server can map into a code skeleton.",
|
|
55
|
+
inputSchema: {},
|
|
56
|
+
}, async () => jsonText({ root: ROOT, languages: supportedLanguages() }));
|
|
57
|
+
/* --------------------------- tool: get_skeleton_json -------------------------- */
|
|
58
|
+
server.registerTool("get_skeleton_json", {
|
|
59
|
+
title: "Get code skeleton (JSON only)",
|
|
60
|
+
description: "Parse a single source file and return its normalized skeleton as JSON. " +
|
|
61
|
+
"Does NOT write an HTML file. Use this when you only need the structure for reasoning.",
|
|
62
|
+
inputSchema: {
|
|
63
|
+
path: z.string().describe("File path, relative to the project root or absolute within it."),
|
|
64
|
+
detail: z
|
|
65
|
+
.enum(["outline", "full"])
|
|
66
|
+
.optional()
|
|
67
|
+
.describe('"outline" (default) = names+kinds+ranges; "full" adds signatures and docs.'),
|
|
68
|
+
},
|
|
69
|
+
}, async ({ path: input, detail }) => {
|
|
70
|
+
try {
|
|
71
|
+
const { abs, rel } = resolveInRoot(input);
|
|
72
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
73
|
+
return errorText(`"${input}" is a directory. Use generate_skeleton for directories.`);
|
|
74
|
+
}
|
|
75
|
+
const opts = resolveOptions({ detail, emitHtml: false });
|
|
76
|
+
const skel = await buildSkeleton(abs, rel, opts);
|
|
77
|
+
return jsonText(skel);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return errorText(describeError(err));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
/* --------------------------- tool: generate_skeleton -------------------------- */
|
|
84
|
+
server.registerTool("generate_skeleton", {
|
|
85
|
+
title: "Generate code skeleton (JSON + HTML)",
|
|
86
|
+
description: "Map a source FILE or DIRECTORY into a normalized code skeleton. Returns compact JSON " +
|
|
87
|
+
"for the agent and writes a self-contained collapsible HTML view per file (under " +
|
|
88
|
+
"<root>/.ast-map by default). For a single file the full skeleton is returned inline; " +
|
|
89
|
+
"for a directory a summary with per-file HTML paths is returned.",
|
|
90
|
+
inputSchema: {
|
|
91
|
+
path: z.string().describe("File or directory path, relative to the project root or absolute within it."),
|
|
92
|
+
detail: z.enum(["outline", "full"]).optional().describe('Default "outline".'),
|
|
93
|
+
emitHtml: z.boolean().optional().describe("Write per-file HTML views. Default true."),
|
|
94
|
+
combineHtml: z
|
|
95
|
+
.boolean()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("Merge all per-file skeletons into a single <outputDir>/index.html with a sidebar, " +
|
|
98
|
+
"search, and collapsible sections. Only applies to directory scans. Default false."),
|
|
99
|
+
outputDir: z
|
|
100
|
+
.string()
|
|
101
|
+
.optional()
|
|
102
|
+
.describe("Directory for HTML output, relative to root. Default '.ast-map'."),
|
|
103
|
+
},
|
|
104
|
+
}, async ({ path: input, detail, emitHtml, combineHtml, outputDir }) => {
|
|
105
|
+
try {
|
|
106
|
+
const opts = resolveOptions({ detail, emitHtml, combineHtml, outputDir });
|
|
107
|
+
const { abs, rel } = resolveInRoot(input);
|
|
108
|
+
const stat = fs.statSync(abs);
|
|
109
|
+
if (stat.isDirectory()) {
|
|
110
|
+
const files = collectSourceFiles(abs, opts);
|
|
111
|
+
const results = [];
|
|
112
|
+
const successSkeletons = [];
|
|
113
|
+
let totalSymbols = 0;
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
116
|
+
try {
|
|
117
|
+
const skel = await buildSkeleton(file, fileRel, opts);
|
|
118
|
+
totalSymbols += skel.symbolCount;
|
|
119
|
+
const htmlPath = opts.emitHtml ? writeHtml(skel, fileRel, opts) : null;
|
|
120
|
+
successSkeletons.push(skel);
|
|
121
|
+
results.push({
|
|
122
|
+
file: skel.file,
|
|
123
|
+
language: skel.language,
|
|
124
|
+
symbolCount: skel.symbolCount,
|
|
125
|
+
htmlPath,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
results.push({ file: fileRel.split(path.sep).join("/"), error: describeError(err) });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
let combinedHtmlPath = null;
|
|
133
|
+
if (opts.combineHtml && successSkeletons.length > 0) {
|
|
134
|
+
const outDir = opts.outputDir
|
|
135
|
+
? path.resolve(ROOT, opts.outputDir)
|
|
136
|
+
: path.join(ROOT, ".ast-map");
|
|
137
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
138
|
+
combinedHtmlPath = path.join(outDir, "index.html");
|
|
139
|
+
fs.writeFileSync(combinedHtmlPath, renderCombinedHtml(successSkeletons), "utf8");
|
|
140
|
+
}
|
|
141
|
+
return jsonText({
|
|
142
|
+
mode: "directory",
|
|
143
|
+
root: ROOT,
|
|
144
|
+
directory: rel.split(path.sep).join("/"),
|
|
145
|
+
fileCount: files.length,
|
|
146
|
+
totalSymbols,
|
|
147
|
+
combinedHtmlPath,
|
|
148
|
+
results,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// single file
|
|
152
|
+
const skel = await buildSkeleton(abs, rel, opts);
|
|
153
|
+
const htmlPath = opts.emitHtml ? writeHtml(skel, rel, opts) : null;
|
|
154
|
+
return jsonText({ mode: "file", htmlPath, skeleton: skel });
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
return errorText(describeError(err));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
/* ─────────────────── tool: get_symbol_context ─────────────────────────────── */
|
|
161
|
+
server.registerTool("get_symbol_context", {
|
|
162
|
+
title: "Get symbol source context",
|
|
163
|
+
description: "Extract the exact source lines of a specific named symbol (function, class, interface, etc.) " +
|
|
164
|
+
"from a file. Returns the raw code block — ideal for focused AI refactoring without sending the " +
|
|
165
|
+
"whole file. Token-efficient: a 300-line file becomes ~40 lines of relevant code. " +
|
|
166
|
+
"Use includeRelated=true to also receive related types/interfaces referenced in the symbol's signature.",
|
|
167
|
+
inputSchema: {
|
|
168
|
+
path: z.string().describe("File path, relative to the project root or absolute within it."),
|
|
169
|
+
symbol: z.string().describe("Name of the symbol to extract (function/class/interface/type name)."),
|
|
170
|
+
kind: z
|
|
171
|
+
.enum(["function", "class", "interface", "type", "method", "const", "var", "enum"])
|
|
172
|
+
.optional()
|
|
173
|
+
.describe("Narrow by kind when multiple symbols share the same name."),
|
|
174
|
+
includeRelated: z
|
|
175
|
+
.boolean()
|
|
176
|
+
.optional()
|
|
177
|
+
.describe("Also return related types/interfaces referenced in the symbol's signature. Default false."),
|
|
178
|
+
},
|
|
179
|
+
}, async ({ path: input, symbol, kind, includeRelated }) => {
|
|
180
|
+
try {
|
|
181
|
+
const { abs, rel } = resolveInRoot(input);
|
|
182
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
183
|
+
return errorText(`"${input}" is a directory. Provide a single file path.`);
|
|
184
|
+
}
|
|
185
|
+
const source = fs.readFileSync(abs, "utf8");
|
|
186
|
+
const sourceLines = source.split("\n");
|
|
187
|
+
const opts = resolveOptions({ detail: "full", emitHtml: false });
|
|
188
|
+
const skel = await buildSkeleton(abs, rel, opts);
|
|
189
|
+
const found = findSymbol(skel.symbols, symbol, kind);
|
|
190
|
+
if (!found) {
|
|
191
|
+
const available = skel.symbols.map((s) => `${s.name} (${s.kind})`).join(", ");
|
|
192
|
+
return errorText(`Symbol "${symbol}" not found in ${rel}. Top-level symbols: ${available || "(none)"}`);
|
|
193
|
+
}
|
|
194
|
+
const code = sourceLines.slice(found.range.startLine - 1, found.range.endLine).join("\n");
|
|
195
|
+
const result = {
|
|
196
|
+
file: rel,
|
|
197
|
+
symbol: found.name,
|
|
198
|
+
kind: found.kind,
|
|
199
|
+
range: found.range,
|
|
200
|
+
lines: found.range.endLine - found.range.startLine + 1,
|
|
201
|
+
code,
|
|
202
|
+
};
|
|
203
|
+
if (includeRelated) {
|
|
204
|
+
const related = findRelatedSymbols(skel.symbols, found, sourceLines);
|
|
205
|
+
if (related.length > 0)
|
|
206
|
+
result.related = related;
|
|
207
|
+
}
|
|
208
|
+
return jsonText(result);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
return errorText(describeError(err));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
/* ───────────────── tool: validate_architecture ─────────────────────────────── */
|
|
215
|
+
server.registerTool("validate_architecture", {
|
|
216
|
+
title: "Validate architecture — Next.js + general rules",
|
|
217
|
+
description: "Scan files for architecture violations. Two rule sets run together:\n\n" +
|
|
218
|
+
"Next.js App Router rules:\n" +
|
|
219
|
+
" (1) client-server-boundary — 'use client' components importing server-only modules.\n" +
|
|
220
|
+
" (2) api-missing-try-catch — API route handlers with no try/catch.\n\n" +
|
|
221
|
+
"General rules (any project):\n" +
|
|
222
|
+
" (3) large-file — files exceeding maxLines (default 500).\n" +
|
|
223
|
+
" (4) too-many-imports — files with more than maxImports imports (default 15).\n" +
|
|
224
|
+
" (5) god-export — files exporting more than maxExports symbols (default 10).\n\n" +
|
|
225
|
+
"Thresholds can be overridden per-call or set globally in .ast-map.config.json.",
|
|
226
|
+
inputSchema: {
|
|
227
|
+
path: z
|
|
228
|
+
.string()
|
|
229
|
+
.describe("File or directory to scan (relative to root or absolute within it). Use '.' to scan the whole project."),
|
|
230
|
+
maxLines: z.number().int().optional().describe("Override large-file threshold (default 500)."),
|
|
231
|
+
maxImports: z.number().int().optional().describe("Override too-many-imports threshold (default 15)."),
|
|
232
|
+
maxExports: z.number().int().optional().describe("Override god-export threshold (default 10)."),
|
|
233
|
+
},
|
|
234
|
+
}, async ({ path: input, maxLines, maxImports, maxExports }) => {
|
|
235
|
+
try {
|
|
236
|
+
const { abs } = resolveInRoot(input);
|
|
237
|
+
const projectConfig = loadProjectConfig(ROOT);
|
|
238
|
+
const opts = resolveOptions({ detail: "full", emitHtml: false }, projectConfig);
|
|
239
|
+
const stat = fs.statSync(abs);
|
|
240
|
+
const filesToCheck = stat.isDirectory()
|
|
241
|
+
? collectSourceFiles(abs, opts)
|
|
242
|
+
: [abs];
|
|
243
|
+
// Merge thresholds: call param → config file → defaults
|
|
244
|
+
const thresholds = {
|
|
245
|
+
largeFileLines: maxLines ?? projectConfig.rules?.["large-file"]?.maxLines ?? GENERAL_RULE_DEFAULTS.largeFileLines,
|
|
246
|
+
tooManyImports: maxImports ?? projectConfig.rules?.["too-many-imports"]?.maxImports ?? GENERAL_RULE_DEFAULTS.tooManyImports,
|
|
247
|
+
godExportCount: maxExports ?? projectConfig.rules?.["god-export"]?.maxExports ?? GENERAL_RULE_DEFAULTS.godExportCount,
|
|
248
|
+
};
|
|
249
|
+
const violations = [];
|
|
250
|
+
for (const file of filesToCheck) {
|
|
251
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
252
|
+
let source;
|
|
253
|
+
try {
|
|
254
|
+
source = fs.readFileSync(file, "utf8");
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
let skel;
|
|
260
|
+
try {
|
|
261
|
+
skel = await buildSkeleton(file, fileRel, opts);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
// Next.js Rule 1: "use client" boundary (AST-based, no comment false-positives)
|
|
267
|
+
if (skel.directives?.includes("use client")) {
|
|
268
|
+
for (const imp of findServerImports(source)) {
|
|
269
|
+
violations.push({
|
|
270
|
+
file: fileRel, rule: "client-server-boundary", severity: "error",
|
|
271
|
+
message: `"use client" file imports server-only module "${imp.label}" (${imp.module})`,
|
|
272
|
+
line: imp.line,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Next.js Rule 2: API route try/catch
|
|
277
|
+
if (isApiRoute(fileRel)) {
|
|
278
|
+
const sourceLines = source.split("\n");
|
|
279
|
+
for (const sym of findMissingTryCatch(skel.symbols, sourceLines)) {
|
|
280
|
+
violations.push({
|
|
281
|
+
file: fileRel, rule: "api-missing-try-catch", severity: "warning",
|
|
282
|
+
message: `API handler "${sym.name}" has no try/catch`,
|
|
283
|
+
line: sym.range.startLine,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// General rules (Rules 3–5)
|
|
288
|
+
const importCount = skel.imports?.length ?? 0;
|
|
289
|
+
for (const v of checkGeneralRules(fileRel, source, skel.symbols, importCount, thresholds)) {
|
|
290
|
+
violations.push(v);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const errors = violations.filter((v) => v.severity === "error").length;
|
|
294
|
+
const warnings = violations.filter((v) => v.severity === "warning").length;
|
|
295
|
+
return jsonText({
|
|
296
|
+
scanned: filesToCheck.length,
|
|
297
|
+
violations: violations.length,
|
|
298
|
+
errors,
|
|
299
|
+
warnings,
|
|
300
|
+
thresholds,
|
|
301
|
+
summary: violations.length === 0
|
|
302
|
+
? "✓ No architecture violations found."
|
|
303
|
+
: `Found ${errors} error(s) and ${warnings} warning(s).`,
|
|
304
|
+
results: violations,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
return errorText(describeError(err));
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
/* ─────────────────── tool: resolve_imports ────────────────────────────────── */
|
|
312
|
+
server.registerTool("resolve_imports", {
|
|
313
|
+
title: "Resolve imports to source definitions",
|
|
314
|
+
description: "For a given source file, resolves each import statement to its target file and symbol. " +
|
|
315
|
+
"Returns a Reference Object per import with: resolved path, symbol kind, one-line signature, " +
|
|
316
|
+
"parameter list, and whether the symbol was found. " +
|
|
317
|
+
"Only relative imports (starting with '.') are resolved — external packages are flagged. " +
|
|
318
|
+
"Use this to trace what a file depends on before refactoring or to verify API contracts.",
|
|
319
|
+
inputSchema: {
|
|
320
|
+
path: z.string().describe("File path, relative to project root or absolute within it."),
|
|
321
|
+
},
|
|
322
|
+
}, async ({ path: input }) => {
|
|
323
|
+
try {
|
|
324
|
+
const { abs, rel } = resolveInRoot(input);
|
|
325
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
326
|
+
return errorText(`"${input}" is a directory. Provide a single file path.`);
|
|
327
|
+
}
|
|
328
|
+
const opts = resolveOptions({ detail: "full", emitHtml: false });
|
|
329
|
+
const skel = await buildSkeleton(abs, rel, opts);
|
|
330
|
+
const resolved = await resolveFileImports(skel, abs, ROOT);
|
|
331
|
+
return jsonText({
|
|
332
|
+
file: rel,
|
|
333
|
+
importCount: resolved.length,
|
|
334
|
+
resolved,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
return errorText(describeError(err));
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
/* ─────────────────── tool: build_symbol_graph ──────────────────────────────── */
|
|
342
|
+
server.registerTool("build_symbol_graph", {
|
|
343
|
+
title: "Build symbol-level dependency graph",
|
|
344
|
+
description: "Scan a directory and build a symbol-level dependency graph.\n" +
|
|
345
|
+
"Nodes:\n" +
|
|
346
|
+
" - file nodes: one per scanned source file\n" +
|
|
347
|
+
" - symbol nodes: one per function/class/type/etc. (id = '<file>::<Name>' or '<file>::<Class>.<method>')\n" +
|
|
348
|
+
"Edges:\n" +
|
|
349
|
+
" - 'contains': file → symbol, or parent-symbol → child-symbol (structural hierarchy)\n" +
|
|
350
|
+
" - 'imports': importing-file → imported-symbol-node (cross-file dependency)\n" +
|
|
351
|
+
"Use to trace data flow: query edges where edgeType='imports' to see what a file pulls in, " +
|
|
352
|
+
"or where to='src/foo.ts::myFn' to see every file that depends on that symbol.",
|
|
353
|
+
inputSchema: {
|
|
354
|
+
path: z
|
|
355
|
+
.string()
|
|
356
|
+
.describe("Directory to scan, relative to project root or absolute within it."),
|
|
357
|
+
detail: z
|
|
358
|
+
.enum(["outline", "full"])
|
|
359
|
+
.optional()
|
|
360
|
+
.describe('"outline" (default) omits signatures; "full" includes them on symbol nodes.'),
|
|
361
|
+
outputFile: z
|
|
362
|
+
.string()
|
|
363
|
+
.optional()
|
|
364
|
+
.describe("If provided, write the graph JSON to this path (relative to root) and return only stats. " +
|
|
365
|
+
"Recommended for large projects to avoid bloated inline responses."),
|
|
366
|
+
},
|
|
367
|
+
}, async ({ path: input, detail, outputFile }) => {
|
|
368
|
+
try {
|
|
369
|
+
const { abs, rel } = resolveInRoot(input);
|
|
370
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
371
|
+
return errorText(`"${input}" is not a directory. build_symbol_graph requires a directory.`);
|
|
372
|
+
}
|
|
373
|
+
const opts = resolveOptions({ detail, emitHtml: false });
|
|
374
|
+
const files = collectSourceFiles(abs, opts);
|
|
375
|
+
const skeletons = [];
|
|
376
|
+
const errors = [];
|
|
377
|
+
for (const file of files) {
|
|
378
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
379
|
+
try {
|
|
380
|
+
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
errors.push({ file: fileRel, error: describeError(err) });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const graph = buildSymbolGraph(skeletons, ROOT);
|
|
387
|
+
if (outputFile) {
|
|
388
|
+
const { abs: outAbs } = resolveInRoot(outputFile);
|
|
389
|
+
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
|
|
390
|
+
fs.writeFileSync(outAbs, JSON.stringify(graph, null, 2), "utf8");
|
|
391
|
+
return jsonText({
|
|
392
|
+
directory: rel,
|
|
393
|
+
scanned: files.length,
|
|
394
|
+
graphFilePath: outAbs,
|
|
395
|
+
stats: graph.stats,
|
|
396
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
// Guard against bloated inline responses for large graphs.
|
|
400
|
+
// 2000 nodes ≈ ~50–80 source files; beyond that inline JSON becomes unusable in an MCP context.
|
|
401
|
+
const INLINE_NODE_LIMIT = 2000;
|
|
402
|
+
if (graph.nodes.length > INLINE_NODE_LIMIT) {
|
|
403
|
+
return jsonText({
|
|
404
|
+
directory: rel,
|
|
405
|
+
scanned: files.length,
|
|
406
|
+
stats: graph.stats,
|
|
407
|
+
warning: `Graph has ${graph.nodes.length} nodes — too large to return inline. ` +
|
|
408
|
+
`Use the outputFile parameter to write it to disk, then read specific sections with get_file_deps or get_change_impact.`,
|
|
409
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return jsonText({
|
|
413
|
+
directory: rel,
|
|
414
|
+
scanned: files.length,
|
|
415
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
416
|
+
graph,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
return errorText(describeError(err));
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
/* ─────────────────── tool: find_dead_code ──────────────────────────────── */
|
|
424
|
+
server.registerTool("find_dead_code", {
|
|
425
|
+
title: "Find dead (unreferenced) exports",
|
|
426
|
+
description: "Scan a directory, build the import graph, and return exported symbols that are never " +
|
|
427
|
+
"imported by any other file in the scan root. These are candidates for removal.\n" +
|
|
428
|
+
"Note: entry-point symbols (e.g. Next.js page exports) are technically 'dead' within " +
|
|
429
|
+
"the codebase graph — use your judgement before deleting them.",
|
|
430
|
+
inputSchema: {
|
|
431
|
+
path: z
|
|
432
|
+
.string()
|
|
433
|
+
.describe("Directory to scan, relative to project root or absolute within it."),
|
|
434
|
+
detail: z
|
|
435
|
+
.enum(["outline", "full"])
|
|
436
|
+
.optional()
|
|
437
|
+
.describe('"outline" (default) is sufficient for dead-code detection.'),
|
|
438
|
+
},
|
|
439
|
+
}, async ({ path: input, detail }) => {
|
|
440
|
+
try {
|
|
441
|
+
const { abs, rel } = resolveInRoot(input);
|
|
442
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
443
|
+
return errorText(`"${input}" is not a directory. find_dead_code requires a directory.`);
|
|
444
|
+
}
|
|
445
|
+
const opts = resolveOptions({ detail, emitHtml: false });
|
|
446
|
+
const files = collectSourceFiles(abs, opts);
|
|
447
|
+
const skeletons = [];
|
|
448
|
+
const errors = [];
|
|
449
|
+
for (const file of files) {
|
|
450
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
451
|
+
try {
|
|
452
|
+
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
errors.push({ file: fileRel, error: describeError(err) });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const graph = buildSymbolGraph(skeletons, ROOT);
|
|
459
|
+
const dead = findDeadExports(graph);
|
|
460
|
+
return jsonText({
|
|
461
|
+
directory: rel.split(path.sep).join("/"),
|
|
462
|
+
scanned: files.length,
|
|
463
|
+
deadExportCount: dead.length,
|
|
464
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
465
|
+
deadExports: dead,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
return errorText(describeError(err));
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
/* ─────────────────── tool: find_circular_deps ──────────────────────────── */
|
|
473
|
+
server.registerTool("find_circular_deps", {
|
|
474
|
+
title: "Find circular import dependencies",
|
|
475
|
+
description: "Scan a directory and detect circular import chains (A → B → C → A). " +
|
|
476
|
+
"Each result includes the full cycle path with repeated start node at the end for clarity.",
|
|
477
|
+
inputSchema: {
|
|
478
|
+
path: z
|
|
479
|
+
.string()
|
|
480
|
+
.describe("Directory to scan, relative to project root or absolute within it."),
|
|
481
|
+
},
|
|
482
|
+
}, async ({ path: input }) => {
|
|
483
|
+
try {
|
|
484
|
+
const { abs, rel } = resolveInRoot(input);
|
|
485
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
486
|
+
return errorText(`"${input}" is not a directory. find_circular_deps requires a directory.`);
|
|
487
|
+
}
|
|
488
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
489
|
+
const files = collectSourceFiles(abs, opts);
|
|
490
|
+
const skeletons = [];
|
|
491
|
+
const errors = [];
|
|
492
|
+
for (const file of files) {
|
|
493
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
494
|
+
try {
|
|
495
|
+
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
errors.push({ file: fileRel, error: describeError(err) });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const graph = buildSymbolGraph(skeletons, ROOT);
|
|
502
|
+
const cycles = findCircularDeps(graph);
|
|
503
|
+
return jsonText({
|
|
504
|
+
directory: rel.split(path.sep).join("/"),
|
|
505
|
+
scanned: files.length,
|
|
506
|
+
cycleCount: cycles.length,
|
|
507
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
508
|
+
cycles,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
return errorText(describeError(err));
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
/* ─────────────────── tool: get_change_impact ───────────────────────────── */
|
|
516
|
+
server.registerTool("get_change_impact", {
|
|
517
|
+
title: "Get change impact (blast radius)",
|
|
518
|
+
description: "Given a file and a symbol name, find every file/symbol in the project that directly or " +
|
|
519
|
+
"transitively depends on it via imports. Use this before refactoring to understand blast radius.\n" +
|
|
520
|
+
"Returns: { direct, transitive, totalFiles } where direct = files that import the symbol " +
|
|
521
|
+
"directly, transitive = further dependents up the chain.",
|
|
522
|
+
inputSchema: {
|
|
523
|
+
path: z
|
|
524
|
+
.string()
|
|
525
|
+
.describe("File containing the symbol, relative to project root or absolute within it."),
|
|
526
|
+
symbol: z.string().describe("Name of the exported symbol to analyse."),
|
|
527
|
+
scanDir: z
|
|
528
|
+
.string()
|
|
529
|
+
.optional()
|
|
530
|
+
.describe("Directory to build the dependency graph from. Defaults to the directory of the given file."),
|
|
531
|
+
},
|
|
532
|
+
}, async ({ path: input, symbol, scanDir }) => {
|
|
533
|
+
try {
|
|
534
|
+
const { abs, rel } = resolveInRoot(input);
|
|
535
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
536
|
+
return errorText(`"${input}" is a directory. Provide a single file path.`);
|
|
537
|
+
}
|
|
538
|
+
const scanRoot = scanDir ? resolveInRoot(scanDir).abs : path.dirname(abs);
|
|
539
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
540
|
+
const files = collectSourceFiles(scanRoot, opts);
|
|
541
|
+
const skeletons = [];
|
|
542
|
+
for (const file of files) {
|
|
543
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
544
|
+
try {
|
|
545
|
+
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// skip parse errors
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const graph = buildSymbolGraph(skeletons, ROOT);
|
|
552
|
+
const targetNodeId = `${rel.split(path.sep).join("/")}::${symbol}`;
|
|
553
|
+
const impact = getChangeImpact(graph, targetNodeId);
|
|
554
|
+
if (!impact) {
|
|
555
|
+
return errorText(`Symbol "${symbol}" not found in graph for "${rel}". ` +
|
|
556
|
+
`Check the symbol name and ensure the file is inside the scan directory.`);
|
|
557
|
+
}
|
|
558
|
+
return jsonText(impact);
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
return errorText(describeError(err));
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
/* ─────────────────── tool: get_call_graph ──────────────────────────────── */
|
|
565
|
+
server.registerTool("get_call_graph", {
|
|
566
|
+
title: "Get function-level call graph",
|
|
567
|
+
description: "For a named function in a file, return:\n" +
|
|
568
|
+
" - calls: every function/method this function calls, with line number and resolved file\n" +
|
|
569
|
+
" - calledBy: files that import (and thus likely call) this function\n" +
|
|
570
|
+
"Supports TypeScript, JavaScript, Python, and Go. " +
|
|
571
|
+
"Cross-file calls are resolved via the import graph; local calls are flagged isLocal=true.",
|
|
572
|
+
inputSchema: {
|
|
573
|
+
path: z
|
|
574
|
+
.string()
|
|
575
|
+
.describe("File path, relative to project root or absolute within it."),
|
|
576
|
+
function: z.string().describe("Name of the function or method to analyse."),
|
|
577
|
+
scanDir: z
|
|
578
|
+
.string()
|
|
579
|
+
.optional()
|
|
580
|
+
.describe("Directory to scan for reverse import lookup (calledBy). " +
|
|
581
|
+
"Defaults to the directory of the given file."),
|
|
582
|
+
},
|
|
583
|
+
}, async ({ path: input, function: funcName, scanDir }) => {
|
|
584
|
+
try {
|
|
585
|
+
const { abs, rel } = resolveInRoot(input);
|
|
586
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
587
|
+
return errorText(`"${input}" is a directory. Provide a single file path.`);
|
|
588
|
+
}
|
|
589
|
+
// Collect skeletons for the scan directory (for calledBy lookup)
|
|
590
|
+
const scanRoot = scanDir ? resolveInRoot(scanDir).abs : path.dirname(abs);
|
|
591
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
592
|
+
const files = collectSourceFiles(scanRoot, opts);
|
|
593
|
+
const skeletons = [];
|
|
594
|
+
for (const file of files) {
|
|
595
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
596
|
+
try {
|
|
597
|
+
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// skip
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const result = await buildCallGraph(abs, funcName, ROOT, skeletons);
|
|
604
|
+
if (!result) {
|
|
605
|
+
return errorText(`Function "${funcName}" not found in "${rel}", or the file language is unsupported.`);
|
|
606
|
+
}
|
|
607
|
+
return jsonText(result);
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
return errorText(describeError(err));
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
/* ─────────────────── tool: search_symbol ───────────────────────────────── */
|
|
614
|
+
server.registerTool("search_symbol", {
|
|
615
|
+
title: "Search symbols by name",
|
|
616
|
+
description: "Find symbols (functions, classes, types, methods, …) by name across all source files " +
|
|
617
|
+
"in a directory. Supports exact match, contains (default), or regex.\n" +
|
|
618
|
+
"Useful when you know a symbol name but not which file it lives in.",
|
|
619
|
+
inputSchema: {
|
|
620
|
+
path: z
|
|
621
|
+
.string()
|
|
622
|
+
.describe("Directory to search in, relative to project root or absolute within it."),
|
|
623
|
+
name: z.string().describe("Symbol name to search for."),
|
|
624
|
+
matchType: z
|
|
625
|
+
.enum(["contains", "exact", "regex"])
|
|
626
|
+
.optional()
|
|
627
|
+
.describe('"contains" (default) — case-insensitive substring. "exact" — full name. "regex" — JS regex.'),
|
|
628
|
+
kind: z
|
|
629
|
+
.enum(["function", "class", "interface", "type", "method", "const", "var", "enum", "struct", "field"])
|
|
630
|
+
.optional()
|
|
631
|
+
.describe("Filter by symbol kind."),
|
|
632
|
+
exportedOnly: z
|
|
633
|
+
.boolean()
|
|
634
|
+
.optional()
|
|
635
|
+
.describe("Only return exported symbols. Default false."),
|
|
636
|
+
},
|
|
637
|
+
}, async ({ path: input, name, matchType, kind, exportedOnly }) => {
|
|
638
|
+
try {
|
|
639
|
+
const { abs, rel } = resolveInRoot(input);
|
|
640
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
641
|
+
return errorText(`"${input}" is not a directory. search_symbol requires a directory.`);
|
|
642
|
+
}
|
|
643
|
+
const matches = await searchSymbols(abs, name, ROOT, { matchType, kind, exportedOnly });
|
|
644
|
+
return jsonText({
|
|
645
|
+
directory: rel.split(path.sep).join("/"),
|
|
646
|
+
pattern: name,
|
|
647
|
+
matchCount: matches.length,
|
|
648
|
+
matches,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
return errorText(describeError(err));
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
/* ─────────────────── tool: get_file_deps ───────────────────────────────── */
|
|
656
|
+
server.registerTool("get_file_deps", {
|
|
657
|
+
title: "Get file-level import dependencies",
|
|
658
|
+
description: "For a single file, show:\n" +
|
|
659
|
+
" - imports: what this file imports from other files (with symbol names)\n" +
|
|
660
|
+
" - importedBy: which files import from this file (with symbol names)\n" +
|
|
661
|
+
"More focused than build_symbol_graph — use this for quick dependency lookup without needing the full graph.",
|
|
662
|
+
inputSchema: {
|
|
663
|
+
path: z.string().describe("File to inspect, relative to project root or absolute within it."),
|
|
664
|
+
scanDir: z
|
|
665
|
+
.string()
|
|
666
|
+
.optional()
|
|
667
|
+
.describe("Directory to build the graph from. Defaults to the directory of the given file."),
|
|
668
|
+
},
|
|
669
|
+
}, async ({ path: input, scanDir }) => {
|
|
670
|
+
try {
|
|
671
|
+
const { abs, rel } = resolveInRoot(input);
|
|
672
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
673
|
+
return errorText(`"${input}" is a directory. Provide a single file path.`);
|
|
674
|
+
}
|
|
675
|
+
const scanRoot = scanDir ? resolveInRoot(scanDir).abs : path.dirname(abs);
|
|
676
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
677
|
+
const files = collectSourceFiles(scanRoot, opts);
|
|
678
|
+
const skeletons = [];
|
|
679
|
+
for (const file of files) {
|
|
680
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
681
|
+
try {
|
|
682
|
+
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
683
|
+
}
|
|
684
|
+
catch { /* skip */ }
|
|
685
|
+
}
|
|
686
|
+
const graph = buildSymbolGraph(skeletons, ROOT);
|
|
687
|
+
const fileId = rel.split(path.sep).join("/");
|
|
688
|
+
const result = getFileDeps(graph, fileId);
|
|
689
|
+
if (!result) {
|
|
690
|
+
return errorText(`"${rel}" was not found in the graph. Ensure it is inside the scan directory and is a supported source file.`);
|
|
691
|
+
}
|
|
692
|
+
return jsonText(result);
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
return errorText(describeError(err));
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
/* ─────────────────── tool: get_top_symbols ─────────────────────────────── */
|
|
699
|
+
server.registerTool("get_top_symbols", {
|
|
700
|
+
title: "Get most-imported symbols (God Node detector)",
|
|
701
|
+
description: "Scan a directory and return the N symbols that are imported by the most files. " +
|
|
702
|
+
"These are your codebase's 'God Nodes' — high-coupling, high-risk symbols where a " +
|
|
703
|
+
"breaking change would have maximum blast radius. Use before a major refactor to " +
|
|
704
|
+
"identify which symbols need the most care.",
|
|
705
|
+
inputSchema: {
|
|
706
|
+
path: z
|
|
707
|
+
.string()
|
|
708
|
+
.describe("Directory to scan, relative to project root or absolute within it."),
|
|
709
|
+
limit: z
|
|
710
|
+
.number()
|
|
711
|
+
.int()
|
|
712
|
+
.min(1)
|
|
713
|
+
.max(100)
|
|
714
|
+
.optional()
|
|
715
|
+
.describe("Number of top symbols to return. Default 10."),
|
|
716
|
+
},
|
|
717
|
+
}, async ({ path: input, limit }) => {
|
|
718
|
+
try {
|
|
719
|
+
const { abs, rel } = resolveInRoot(input);
|
|
720
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
721
|
+
return errorText(`"${input}" is not a directory. get_top_symbols requires a directory.`);
|
|
722
|
+
}
|
|
723
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
724
|
+
const files = collectSourceFiles(abs, opts);
|
|
725
|
+
const skeletons = [];
|
|
726
|
+
for (const file of files) {
|
|
727
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
728
|
+
try {
|
|
729
|
+
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
730
|
+
}
|
|
731
|
+
catch { /* skip */ }
|
|
732
|
+
}
|
|
733
|
+
const graph = buildSymbolGraph(skeletons, ROOT);
|
|
734
|
+
const top = getTopSymbols(graph, limit ?? 10);
|
|
735
|
+
return jsonText({
|
|
736
|
+
directory: rel.split(path.sep).join("/"),
|
|
737
|
+
scanned: files.length,
|
|
738
|
+
topSymbols: top,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
return errorText(describeError(err));
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
function describeError(err) {
|
|
746
|
+
if (err instanceof UnsupportedLanguageError)
|
|
747
|
+
return err.message;
|
|
748
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
|
749
|
+
return `File not found. Check the path (resolved against root ${ROOT}).`;
|
|
750
|
+
}
|
|
751
|
+
return err instanceof Error ? err.message : String(err);
|
|
752
|
+
}
|
|
753
|
+
async function main() {
|
|
754
|
+
const transport = new StdioServerTransport();
|
|
755
|
+
await server.connect(transport);
|
|
756
|
+
// stderr is safe for logging; stdout is reserved for the MCP protocol.
|
|
757
|
+
process.stderr.write(`universal-ast-mapper running. root=${ROOT}\n`);
|
|
758
|
+
}
|
|
759
|
+
main().catch((err) => {
|
|
760
|
+
process.stderr.write(`Fatal: ${err instanceof Error ? err.stack : String(err)}\n`);
|
|
761
|
+
process.exit(1);
|
|
762
|
+
});
|