universal-ast-mapper 1.27.0 → 2.0.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/BLUEPRINT.md +230 -230
- package/CHANGELOG.md +466 -321
- package/README.md +878 -877
- package/package.json +48 -47
- package/scripts/install-skill.mjs +187 -187
- package/dist/analysis.js +0 -134
- package/dist/callgraph.js +0 -467
- package/dist/check.js +0 -112
- package/dist/cli.js +0 -1275
- package/dist/complexity.js +0 -98
- package/dist/config.js +0 -53
- package/dist/contextpack.js +0 -79
- package/dist/coupling.js +0 -35
- package/dist/crosslang.js +0 -425
- package/dist/diskcache.js +0 -97
- package/dist/explorer.js +0 -123
- package/dist/extractors/c.js +0 -204
- package/dist/extractors/common.js +0 -56
- package/dist/extractors/cpp.js +0 -272
- package/dist/extractors/csharp.js +0 -209
- package/dist/extractors/go.js +0 -212
- package/dist/extractors/java.js +0 -152
- package/dist/extractors/kotlin.js +0 -159
- package/dist/extractors/php.js +0 -208
- package/dist/extractors/python.js +0 -153
- package/dist/extractors/ruby.js +0 -146
- package/dist/extractors/rust.js +0 -249
- package/dist/extractors/swift.js +0 -192
- package/dist/extractors/typescript.js +0 -577
- package/dist/gitdiff.js +0 -178
- package/dist/graph-analysis.js +0 -279
- package/dist/graph.js +0 -165
- package/dist/html.js +0 -326
- package/dist/index.js +0 -1407
- package/dist/layers.js +0 -36
- package/dist/modulecoupling.js +0 -0
- package/dist/parser.js +0 -84
- package/dist/pool.js +0 -114
- package/dist/prompts.js +0 -67
- package/dist/registry.js +0 -87
- package/dist/report.js +0 -187
- package/dist/resolver.js +0 -222
- package/dist/roots.js +0 -47
- package/dist/search.js +0 -68
- package/dist/semantic.js +0 -365
- package/dist/sfc.js +0 -27
- package/dist/skeleton.js +0 -132
- package/dist/sourcemap.js +0 -60
- package/dist/testmap.js +0 -167
- package/dist/tsconfig.js +0 -212
- package/dist/typeflow.js +0 -124
- package/dist/types.js +0 -5
- package/dist/unused-params.js +0 -127
- package/dist/worker.js +0 -27
- package/dist/workspace.js +0 -330
package/dist/index.js
DELETED
|
@@ -1,1407 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
-
import { z } from "zod";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { resolveOptions, loadProjectConfig } from "./config.js";
|
|
9
|
-
import { initDiskCache, defaultCacheDir } from "./diskcache.js";
|
|
10
|
-
import { buildSkeletonsBulk } from "./pool.js";
|
|
11
|
-
import { buildSkeleton, collectSourceFiles, UnsupportedLanguageError, } from "./skeleton.js";
|
|
12
|
-
import { renderHtml, renderCombinedHtml } from "./html.js";
|
|
13
|
-
import { supportedLanguages } from "./registry.js";
|
|
14
|
-
import { findSymbol, findRelatedSymbols, findServerImports, isApiRoute, findMissingTryCatch, checkGeneralRules, GENERAL_RULE_DEFAULTS, } from "./analysis.js";
|
|
15
|
-
import { resolveFileImports } from "./resolver.js";
|
|
16
|
-
import { buildSymbolGraph } from "./graph.js";
|
|
17
|
-
import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols, findDuplicateSymbols } from "./graph-analysis.js";
|
|
18
|
-
import { buildCallGraph } from "./callgraph.js";
|
|
19
|
-
import { searchSymbols } from "./search.js";
|
|
20
|
-
import { semanticSearch } from "./semantic.js";
|
|
21
|
-
import { mapTestCoverage } from "./testmap.js";
|
|
22
|
-
import { computeFileComplexity } from "./complexity.js";
|
|
23
|
-
import { findUnusedParams } from "./unused-params.js";
|
|
24
|
-
import { traceTypeInFile } from "./typeflow.js";
|
|
25
|
-
import { discoverWorkspace, findPackageCycles } from "./workspace.js";
|
|
26
|
-
import { readSourceMap } from "./sourcemap.js";
|
|
27
|
-
import { buildReport } from "./report.js";
|
|
28
|
-
import { runQualityGate } from "./check.js";
|
|
29
|
-
import { computeDiff, computeRisk, isGitRepo } from "./gitdiff.js";
|
|
30
|
-
import { packContext } from "./contextpack.js";
|
|
31
|
-
import { computeCoupling } from "./coupling.js";
|
|
32
|
-
import { findLayerViolations } from "./layers.js";
|
|
33
|
-
import { computeModuleCoupling } from "./modulecoupling.js";
|
|
34
|
-
import { registerPrompts } from "./prompts.js";
|
|
35
|
-
import { parseRootsFromEnv, resolvePathInRoots } from "./roots.js";
|
|
36
|
-
/**
|
|
37
|
-
* Security boundary. AST_MAP_ROOT may list several roots (path-delimiter
|
|
38
|
-
* separated); AST_MAP_UNLOCKED=1 allows any absolute path. The first root is
|
|
39
|
-
* the primary — relative inputs resolve against it.
|
|
40
|
-
*/
|
|
41
|
-
const ROOTS = parseRootsFromEnv();
|
|
42
|
-
const ROOT = ROOTS.roots[0];
|
|
43
|
-
// Persistent parse cache (disable with AST_MAP_NO_CACHE=1 or "cache": false in config).
|
|
44
|
-
if (process.env.AST_MAP_NO_CACHE !== "1" && loadProjectConfig(ROOT).cache !== false) {
|
|
45
|
-
initDiskCache(defaultCacheDir(ROOT));
|
|
46
|
-
}
|
|
47
|
-
function resolveInRoot(input) {
|
|
48
|
-
return resolvePathInRoots(input, ROOTS);
|
|
49
|
-
}
|
|
50
|
-
function htmlPathFor(rel, opts) {
|
|
51
|
-
const outDir = opts.outputDir ? path.resolve(ROOT, opts.outputDir) : path.join(ROOT, ".ast-map");
|
|
52
|
-
return path.join(outDir, `${rel}-skeleton.html`);
|
|
53
|
-
}
|
|
54
|
-
function writeHtml(skel, rel, opts) {
|
|
55
|
-
const target = htmlPathFor(rel, opts);
|
|
56
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
57
|
-
fs.writeFileSync(target, renderHtml(skel), "utf8");
|
|
58
|
-
return target;
|
|
59
|
-
}
|
|
60
|
-
function jsonText(value) {
|
|
61
|
-
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
62
|
-
}
|
|
63
|
-
function errorText(message) {
|
|
64
|
-
return {
|
|
65
|
-
isError: true,
|
|
66
|
-
content: [{ type: "text", text: message }],
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
/** Read the package version at runtime so it never drifts from package.json. */
|
|
70
|
-
const PKG_VERSION = (() => {
|
|
71
|
-
try {
|
|
72
|
-
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
73
|
-
return JSON.parse(fs.readFileSync(path.join(dir, "..", "package.json"), "utf8")).version;
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
return "0.0.0";
|
|
77
|
-
}
|
|
78
|
-
})();
|
|
79
|
-
const server = new McpServer({
|
|
80
|
-
name: "universal-ast-mapper",
|
|
81
|
-
version: PKG_VERSION,
|
|
82
|
-
});
|
|
83
|
-
registerPrompts(server);
|
|
84
|
-
/* ----------------------- tool: list_supported_languages ----------------------- */
|
|
85
|
-
server.registerTool("list_supported_languages", {
|
|
86
|
-
title: "List supported languages",
|
|
87
|
-
description: "Returns the languages and file extensions this server can map into a code skeleton.",
|
|
88
|
-
inputSchema: {},
|
|
89
|
-
}, async () => jsonText({ root: ROOT, languages: supportedLanguages() }));
|
|
90
|
-
/* --------------------------- tool: get_skeleton_json -------------------------- */
|
|
91
|
-
server.registerTool("get_skeleton_json", {
|
|
92
|
-
title: "Get code skeleton (JSON only)",
|
|
93
|
-
description: "Parse a single source file and return its normalized skeleton as JSON. " +
|
|
94
|
-
"Does NOT write an HTML file. Use this when you only need the structure for reasoning.",
|
|
95
|
-
inputSchema: {
|
|
96
|
-
path: z.string().describe("File path, relative to the project root or absolute within it."),
|
|
97
|
-
detail: z
|
|
98
|
-
.enum(["outline", "full"])
|
|
99
|
-
.optional()
|
|
100
|
-
.describe('"outline" (default) = names+kinds+ranges; "full" adds signatures and docs.'),
|
|
101
|
-
},
|
|
102
|
-
}, async ({ path: input, detail }) => {
|
|
103
|
-
try {
|
|
104
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
105
|
-
if (fs.statSync(abs).isDirectory()) {
|
|
106
|
-
return errorText(`"${input}" is a directory. Use generate_skeleton for directories.`);
|
|
107
|
-
}
|
|
108
|
-
const opts = resolveOptions({ detail, emitHtml: false });
|
|
109
|
-
const skel = await buildSkeleton(abs, rel, opts);
|
|
110
|
-
return jsonText(skel);
|
|
111
|
-
}
|
|
112
|
-
catch (err) {
|
|
113
|
-
return errorText(describeError(err));
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
/* --------------------------- tool: generate_skeleton -------------------------- */
|
|
117
|
-
server.registerTool("generate_skeleton", {
|
|
118
|
-
title: "Generate code skeleton (JSON + HTML)",
|
|
119
|
-
description: "Map a source FILE or DIRECTORY into a normalized code skeleton. Returns compact JSON " +
|
|
120
|
-
"for the agent and writes a self-contained collapsible HTML view per file (under " +
|
|
121
|
-
"<root>/.ast-map by default). For a single file the full skeleton is returned inline; " +
|
|
122
|
-
"for a directory a summary with per-file HTML paths is returned.",
|
|
123
|
-
inputSchema: {
|
|
124
|
-
path: z.string().describe("File or directory path, relative to the project root or absolute within it."),
|
|
125
|
-
detail: z.enum(["outline", "full"]).optional().describe('Default "outline".'),
|
|
126
|
-
emitHtml: z.boolean().optional().describe("Write per-file HTML views. Default true."),
|
|
127
|
-
combineHtml: z
|
|
128
|
-
.boolean()
|
|
129
|
-
.optional()
|
|
130
|
-
.describe("Merge all per-file skeletons into a single <outputDir>/index.html with a sidebar, " +
|
|
131
|
-
"search, and collapsible sections. Only applies to directory scans. Default false."),
|
|
132
|
-
outputDir: z
|
|
133
|
-
.string()
|
|
134
|
-
.optional()
|
|
135
|
-
.describe("Directory for HTML output, relative to root. Default '.ast-map'."),
|
|
136
|
-
},
|
|
137
|
-
}, async ({ path: input, detail, emitHtml, combineHtml, outputDir }) => {
|
|
138
|
-
try {
|
|
139
|
-
const opts = resolveOptions({ detail, emitHtml, combineHtml, outputDir });
|
|
140
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
141
|
-
const stat = fs.statSync(abs);
|
|
142
|
-
if (stat.isDirectory()) {
|
|
143
|
-
const files = collectSourceFiles(abs, opts);
|
|
144
|
-
const results = [];
|
|
145
|
-
const successSkeletons = [];
|
|
146
|
-
let totalSymbols = 0;
|
|
147
|
-
const items = files.map((file) => ({
|
|
148
|
-
abs: file,
|
|
149
|
-
rel: path.relative(root, file).split(path.sep).join("/"),
|
|
150
|
-
}));
|
|
151
|
-
const built = await buildSkeletonsBulk(items, opts);
|
|
152
|
-
for (let i = 0; i < built.length; i++) {
|
|
153
|
-
const r = built[i];
|
|
154
|
-
if (r) {
|
|
155
|
-
const skel = r.skel;
|
|
156
|
-
totalSymbols += skel.symbolCount;
|
|
157
|
-
const htmlPath = opts.emitHtml ? writeHtml(skel, items[i].rel, opts) : null;
|
|
158
|
-
successSkeletons.push(skel);
|
|
159
|
-
results.push({
|
|
160
|
-
file: skel.file,
|
|
161
|
-
language: skel.language,
|
|
162
|
-
symbolCount: skel.symbolCount,
|
|
163
|
-
htmlPath,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
else {
|
|
167
|
-
results.push({ file: items[i].rel, error: "parse failed or unsupported file type" });
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
let combinedHtmlPath = null;
|
|
171
|
-
if (opts.combineHtml && successSkeletons.length > 0) {
|
|
172
|
-
const outDir = opts.outputDir
|
|
173
|
-
? path.resolve(root, opts.outputDir)
|
|
174
|
-
: path.join(root, ".ast-map");
|
|
175
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
176
|
-
combinedHtmlPath = path.join(outDir, "index.html");
|
|
177
|
-
fs.writeFileSync(combinedHtmlPath, renderCombinedHtml(successSkeletons), "utf8");
|
|
178
|
-
}
|
|
179
|
-
return jsonText({
|
|
180
|
-
mode: "directory",
|
|
181
|
-
root: root,
|
|
182
|
-
directory: rel.split(path.sep).join("/"),
|
|
183
|
-
fileCount: files.length,
|
|
184
|
-
totalSymbols,
|
|
185
|
-
combinedHtmlPath,
|
|
186
|
-
results,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
// single file
|
|
190
|
-
const skel = await buildSkeleton(abs, rel, opts);
|
|
191
|
-
const htmlPath = opts.emitHtml ? writeHtml(skel, rel, opts) : null;
|
|
192
|
-
return jsonText({ mode: "file", htmlPath, skeleton: skel });
|
|
193
|
-
}
|
|
194
|
-
catch (err) {
|
|
195
|
-
return errorText(describeError(err));
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
/* ─────────────────── tool: get_symbol_context ─────────────────────────────── */
|
|
199
|
-
server.registerTool("get_symbol_context", {
|
|
200
|
-
title: "Get symbol source context",
|
|
201
|
-
description: "Extract the exact source lines of a specific named symbol (function, class, interface, etc.) " +
|
|
202
|
-
"from a file. Returns the raw code block — ideal for focused AI refactoring without sending the " +
|
|
203
|
-
"whole file. Token-efficient: a 300-line file becomes ~40 lines of relevant code. " +
|
|
204
|
-
"Use includeRelated=true to also receive related types/interfaces referenced in the symbol's signature.",
|
|
205
|
-
inputSchema: {
|
|
206
|
-
path: z.string().describe("File path, relative to the project root or absolute within it."),
|
|
207
|
-
symbol: z.string().describe("Name of the symbol to extract (function/class/interface/type name)."),
|
|
208
|
-
kind: z
|
|
209
|
-
.enum(["function", "class", "interface", "type", "method", "const", "var", "enum"])
|
|
210
|
-
.optional()
|
|
211
|
-
.describe("Narrow by kind when multiple symbols share the same name."),
|
|
212
|
-
includeRelated: z
|
|
213
|
-
.boolean()
|
|
214
|
-
.optional()
|
|
215
|
-
.describe("Also return related types/interfaces referenced in the symbol's signature. Default false."),
|
|
216
|
-
},
|
|
217
|
-
}, async ({ path: input, symbol, kind, includeRelated }) => {
|
|
218
|
-
try {
|
|
219
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
220
|
-
if (fs.statSync(abs).isDirectory()) {
|
|
221
|
-
return errorText(`"${input}" is a directory. Provide a single file path.`);
|
|
222
|
-
}
|
|
223
|
-
const source = fs.readFileSync(abs, "utf8");
|
|
224
|
-
const sourceLines = source.split("\n");
|
|
225
|
-
const opts = resolveOptions({ detail: "full", emitHtml: false });
|
|
226
|
-
const skel = await buildSkeleton(abs, rel, opts);
|
|
227
|
-
const found = findSymbol(skel.symbols, symbol, kind);
|
|
228
|
-
if (!found) {
|
|
229
|
-
const available = skel.symbols.map((s) => `${s.name} (${s.kind})`).join(", ");
|
|
230
|
-
return errorText(`Symbol "${symbol}" not found in ${rel}. Top-level symbols: ${available || "(none)"}`);
|
|
231
|
-
}
|
|
232
|
-
const code = sourceLines.slice(found.range.startLine - 1, found.range.endLine).join("\n");
|
|
233
|
-
const result = {
|
|
234
|
-
file: rel,
|
|
235
|
-
symbol: found.name,
|
|
236
|
-
kind: found.kind,
|
|
237
|
-
range: found.range,
|
|
238
|
-
lines: found.range.endLine - found.range.startLine + 1,
|
|
239
|
-
code,
|
|
240
|
-
};
|
|
241
|
-
if (includeRelated) {
|
|
242
|
-
const related = findRelatedSymbols(skel.symbols, found, sourceLines);
|
|
243
|
-
if (related.length > 0)
|
|
244
|
-
result.related = related;
|
|
245
|
-
}
|
|
246
|
-
return jsonText(result);
|
|
247
|
-
}
|
|
248
|
-
catch (err) {
|
|
249
|
-
return errorText(describeError(err));
|
|
250
|
-
}
|
|
251
|
-
});
|
|
252
|
-
/* ───────────────── tool: validate_architecture ─────────────────────────────── */
|
|
253
|
-
server.registerTool("validate_architecture", {
|
|
254
|
-
title: "Validate architecture — Next.js + general rules",
|
|
255
|
-
description: "Scan files for architecture violations. Two rule sets run together:\n\n" +
|
|
256
|
-
"Next.js App Router rules:\n" +
|
|
257
|
-
" (1) client-server-boundary — 'use client' components importing server-only modules.\n" +
|
|
258
|
-
" (2) api-missing-try-catch — API route handlers with no try/catch.\n\n" +
|
|
259
|
-
"General rules (any project):\n" +
|
|
260
|
-
" (3) large-file — files exceeding maxLines (default 500).\n" +
|
|
261
|
-
" (4) too-many-imports — files with more than maxImports imports (default 15).\n" +
|
|
262
|
-
" (5) god-export — files exporting more than maxExports symbols (default 10).\n\n" +
|
|
263
|
-
"Thresholds can be overridden per-call or set globally in .ast-map.config.json.",
|
|
264
|
-
inputSchema: {
|
|
265
|
-
path: z
|
|
266
|
-
.string()
|
|
267
|
-
.describe("File or directory to scan (relative to root or absolute within it). Use '.' to scan the whole project."),
|
|
268
|
-
maxLines: z.number().int().optional().describe("Override large-file threshold (default 500)."),
|
|
269
|
-
maxImports: z.number().int().optional().describe("Override too-many-imports threshold (default 15)."),
|
|
270
|
-
maxExports: z.number().int().optional().describe("Override god-export threshold (default 10)."),
|
|
271
|
-
},
|
|
272
|
-
}, async ({ path: input, maxLines, maxImports, maxExports }) => {
|
|
273
|
-
try {
|
|
274
|
-
const { abs, root } = resolveInRoot(input);
|
|
275
|
-
const projectConfig = loadProjectConfig(root);
|
|
276
|
-
const opts = resolveOptions({ detail: "full", emitHtml: false }, projectConfig);
|
|
277
|
-
const stat = fs.statSync(abs);
|
|
278
|
-
const filesToCheck = stat.isDirectory()
|
|
279
|
-
? collectSourceFiles(abs, opts)
|
|
280
|
-
: [abs];
|
|
281
|
-
// Merge thresholds: call param → config file → defaults
|
|
282
|
-
const thresholds = {
|
|
283
|
-
largeFileLines: maxLines ?? projectConfig.rules?.["large-file"]?.maxLines ?? GENERAL_RULE_DEFAULTS.largeFileLines,
|
|
284
|
-
tooManyImports: maxImports ?? projectConfig.rules?.["too-many-imports"]?.maxImports ?? GENERAL_RULE_DEFAULTS.tooManyImports,
|
|
285
|
-
godExportCount: maxExports ?? projectConfig.rules?.["god-export"]?.maxExports ?? GENERAL_RULE_DEFAULTS.godExportCount,
|
|
286
|
-
};
|
|
287
|
-
const violations = [];
|
|
288
|
-
for (const file of filesToCheck) {
|
|
289
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
290
|
-
let source;
|
|
291
|
-
try {
|
|
292
|
-
source = fs.readFileSync(file, "utf8");
|
|
293
|
-
}
|
|
294
|
-
catch {
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
let skel;
|
|
298
|
-
try {
|
|
299
|
-
skel = await buildSkeleton(file, fileRel, opts);
|
|
300
|
-
}
|
|
301
|
-
catch {
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
// Next.js Rule 1: "use client" boundary (AST-based, no comment false-positives)
|
|
305
|
-
if (skel.directives?.includes("use client")) {
|
|
306
|
-
for (const imp of findServerImports(source)) {
|
|
307
|
-
violations.push({
|
|
308
|
-
file: fileRel, rule: "client-server-boundary", severity: "error",
|
|
309
|
-
message: `"use client" file imports server-only module "${imp.label}" (${imp.module})`,
|
|
310
|
-
line: imp.line,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
// Next.js Rule 2: API route try/catch
|
|
315
|
-
if (isApiRoute(fileRel)) {
|
|
316
|
-
const sourceLines = source.split("\n");
|
|
317
|
-
for (const sym of findMissingTryCatch(skel.symbols, sourceLines)) {
|
|
318
|
-
violations.push({
|
|
319
|
-
file: fileRel, rule: "api-missing-try-catch", severity: "warning",
|
|
320
|
-
message: `API handler "${sym.name}" has no try/catch`,
|
|
321
|
-
line: sym.range.startLine,
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
// General rules (Rules 3–5)
|
|
326
|
-
const importCount = skel.imports?.length ?? 0;
|
|
327
|
-
for (const v of checkGeneralRules(fileRel, source, skel.symbols, importCount, thresholds)) {
|
|
328
|
-
violations.push(v);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
const errors = violations.filter((v) => v.severity === "error").length;
|
|
332
|
-
const warnings = violations.filter((v) => v.severity === "warning").length;
|
|
333
|
-
return jsonText({
|
|
334
|
-
scanned: filesToCheck.length,
|
|
335
|
-
violations: violations.length,
|
|
336
|
-
errors,
|
|
337
|
-
warnings,
|
|
338
|
-
thresholds,
|
|
339
|
-
summary: violations.length === 0
|
|
340
|
-
? "✓ No architecture violations found."
|
|
341
|
-
: `Found ${errors} error(s) and ${warnings} warning(s).`,
|
|
342
|
-
results: violations,
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
catch (err) {
|
|
346
|
-
return errorText(describeError(err));
|
|
347
|
-
}
|
|
348
|
-
});
|
|
349
|
-
/* ─────────────────── tool: resolve_imports ────────────────────────────────── */
|
|
350
|
-
server.registerTool("resolve_imports", {
|
|
351
|
-
title: "Resolve imports to source definitions",
|
|
352
|
-
description: "For a given source file, resolves each import statement to its target file and symbol. " +
|
|
353
|
-
"Returns a Reference Object per import with: resolved path, symbol kind, one-line signature, " +
|
|
354
|
-
"parameter list, and whether the symbol was found. " +
|
|
355
|
-
"Only relative imports (starting with '.') are resolved — external packages are flagged. " +
|
|
356
|
-
"Use this to trace what a file depends on before refactoring or to verify API contracts.",
|
|
357
|
-
inputSchema: {
|
|
358
|
-
path: z.string().describe("File path, relative to project root or absolute within it."),
|
|
359
|
-
},
|
|
360
|
-
}, async ({ path: input }) => {
|
|
361
|
-
try {
|
|
362
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
363
|
-
if (fs.statSync(abs).isDirectory()) {
|
|
364
|
-
return errorText(`"${input}" is a directory. Provide a single file path.`);
|
|
365
|
-
}
|
|
366
|
-
const opts = resolveOptions({ detail: "full", emitHtml: false });
|
|
367
|
-
const skel = await buildSkeleton(abs, rel, opts);
|
|
368
|
-
const resolved = await resolveFileImports(skel, abs, root);
|
|
369
|
-
return jsonText({
|
|
370
|
-
file: rel,
|
|
371
|
-
importCount: resolved.length,
|
|
372
|
-
resolved,
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
catch (err) {
|
|
376
|
-
return errorText(describeError(err));
|
|
377
|
-
}
|
|
378
|
-
});
|
|
379
|
-
/* ─────────────────── tool: build_symbol_graph ──────────────────────────────── */
|
|
380
|
-
server.registerTool("build_symbol_graph", {
|
|
381
|
-
title: "Build symbol-level dependency graph",
|
|
382
|
-
description: "Scan a directory and build a symbol-level dependency graph.\n" +
|
|
383
|
-
"Nodes:\n" +
|
|
384
|
-
" - file nodes: one per scanned source file\n" +
|
|
385
|
-
" - symbol nodes: one per function/class/type/etc. (id = '<file>::<Name>' or '<file>::<Class>.<method>')\n" +
|
|
386
|
-
"Edges:\n" +
|
|
387
|
-
" - 'contains': file → symbol, or parent-symbol → child-symbol (structural hierarchy)\n" +
|
|
388
|
-
" - 'imports': importing-file → imported-symbol-node (cross-file dependency)\n" +
|
|
389
|
-
"Use to trace data flow: query edges where edgeType='imports' to see what a file pulls in, " +
|
|
390
|
-
"or where to='src/foo.ts::myFn' to see every file that depends on that symbol.",
|
|
391
|
-
inputSchema: {
|
|
392
|
-
path: z
|
|
393
|
-
.string()
|
|
394
|
-
.describe("Directory to scan, relative to project root or absolute within it."),
|
|
395
|
-
detail: z
|
|
396
|
-
.enum(["outline", "full"])
|
|
397
|
-
.optional()
|
|
398
|
-
.describe('"outline" (default) omits signatures; "full" includes them on symbol nodes.'),
|
|
399
|
-
outputFile: z
|
|
400
|
-
.string()
|
|
401
|
-
.optional()
|
|
402
|
-
.describe("If provided, write the graph JSON to this path (relative to root) and return only stats. " +
|
|
403
|
-
"Recommended for large projects to avoid bloated inline responses."),
|
|
404
|
-
},
|
|
405
|
-
}, async ({ path: input, detail, outputFile }) => {
|
|
406
|
-
try {
|
|
407
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
408
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
409
|
-
return errorText(`"${input}" is not a directory. build_symbol_graph requires a directory.`);
|
|
410
|
-
}
|
|
411
|
-
const opts = resolveOptions({ detail, emitHtml: false });
|
|
412
|
-
const files = collectSourceFiles(abs, opts);
|
|
413
|
-
const skeletons = [];
|
|
414
|
-
const errors = [];
|
|
415
|
-
for (const file of files) {
|
|
416
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
417
|
-
try {
|
|
418
|
-
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
419
|
-
}
|
|
420
|
-
catch (err) {
|
|
421
|
-
errors.push({ file: fileRel, error: describeError(err) });
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
const graph = buildSymbolGraph(skeletons, root);
|
|
425
|
-
if (outputFile) {
|
|
426
|
-
const { abs: outAbs } = resolveInRoot(outputFile);
|
|
427
|
-
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
|
|
428
|
-
fs.writeFileSync(outAbs, JSON.stringify(graph, null, 2), "utf8");
|
|
429
|
-
return jsonText({
|
|
430
|
-
directory: rel,
|
|
431
|
-
scanned: files.length,
|
|
432
|
-
graphFilePath: outAbs,
|
|
433
|
-
stats: graph.stats,
|
|
434
|
-
...(errors.length > 0 ? { errors } : {}),
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
// Guard against bloated inline responses for large graphs.
|
|
438
|
-
// 2000 nodes ≈ ~50–80 source files; beyond that inline JSON becomes unusable in an MCP context.
|
|
439
|
-
const INLINE_NODE_LIMIT = 2000;
|
|
440
|
-
if (graph.nodes.length > INLINE_NODE_LIMIT) {
|
|
441
|
-
return jsonText({
|
|
442
|
-
directory: rel,
|
|
443
|
-
scanned: files.length,
|
|
444
|
-
stats: graph.stats,
|
|
445
|
-
warning: `Graph has ${graph.nodes.length} nodes — too large to return inline. ` +
|
|
446
|
-
`Use the outputFile parameter to write it to disk, then read specific sections with get_file_deps or get_change_impact.`,
|
|
447
|
-
...(errors.length > 0 ? { errors } : {}),
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
return jsonText({
|
|
451
|
-
directory: rel,
|
|
452
|
-
scanned: files.length,
|
|
453
|
-
...(errors.length > 0 ? { errors } : {}),
|
|
454
|
-
graph,
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
catch (err) {
|
|
458
|
-
return errorText(describeError(err));
|
|
459
|
-
}
|
|
460
|
-
});
|
|
461
|
-
/* ─────────────────── tool: find_dead_code ──────────────────────────────── */
|
|
462
|
-
server.registerTool("find_dead_code", {
|
|
463
|
-
title: "Find dead (unreferenced) exports",
|
|
464
|
-
description: "Scan a directory, build the import graph, and return exported symbols that are never " +
|
|
465
|
-
"imported by any other file in the scan root. These are candidates for removal.\n" +
|
|
466
|
-
"Note: entry-point symbols (e.g. Next.js page exports) are technically 'dead' within " +
|
|
467
|
-
"the codebase graph — use your judgement before deleting them.",
|
|
468
|
-
inputSchema: {
|
|
469
|
-
path: z
|
|
470
|
-
.string()
|
|
471
|
-
.describe("Directory to scan, relative to project root or absolute within it."),
|
|
472
|
-
detail: z
|
|
473
|
-
.enum(["outline", "full"])
|
|
474
|
-
.optional()
|
|
475
|
-
.describe('"outline" (default) is sufficient for dead-code detection.'),
|
|
476
|
-
},
|
|
477
|
-
}, async ({ path: input, detail }) => {
|
|
478
|
-
try {
|
|
479
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
480
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
481
|
-
return errorText(`"${input}" is not a directory. find_dead_code requires a directory.`);
|
|
482
|
-
}
|
|
483
|
-
const opts = resolveOptions({ detail, emitHtml: false });
|
|
484
|
-
const files = collectSourceFiles(abs, opts);
|
|
485
|
-
const skeletons = [];
|
|
486
|
-
const errors = [];
|
|
487
|
-
for (const file of files) {
|
|
488
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
489
|
-
try {
|
|
490
|
-
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
491
|
-
}
|
|
492
|
-
catch (err) {
|
|
493
|
-
errors.push({ file: fileRel, error: describeError(err) });
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
const graph = buildSymbolGraph(skeletons, root);
|
|
497
|
-
const dead = findDeadExports(graph);
|
|
498
|
-
return jsonText({
|
|
499
|
-
directory: rel.split(path.sep).join("/"),
|
|
500
|
-
scanned: files.length,
|
|
501
|
-
deadExportCount: dead.length,
|
|
502
|
-
...(errors.length > 0 ? { errors } : {}),
|
|
503
|
-
deadExports: dead,
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
catch (err) {
|
|
507
|
-
return errorText(describeError(err));
|
|
508
|
-
}
|
|
509
|
-
});
|
|
510
|
-
/* ─────────────────── tool: find_circular_deps ──────────────────────────── */
|
|
511
|
-
server.registerTool("find_circular_deps", {
|
|
512
|
-
title: "Find circular import dependencies",
|
|
513
|
-
description: "Scan a directory and detect circular import chains (A → B → C → A). " +
|
|
514
|
-
"Each result includes the full cycle path with repeated start node at the end for clarity.",
|
|
515
|
-
inputSchema: {
|
|
516
|
-
path: z
|
|
517
|
-
.string()
|
|
518
|
-
.describe("Directory to scan, relative to project root or absolute within it."),
|
|
519
|
-
},
|
|
520
|
-
}, async ({ path: input }) => {
|
|
521
|
-
try {
|
|
522
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
523
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
524
|
-
return errorText(`"${input}" is not a directory. find_circular_deps requires a directory.`);
|
|
525
|
-
}
|
|
526
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
527
|
-
const files = collectSourceFiles(abs, opts);
|
|
528
|
-
const skeletons = [];
|
|
529
|
-
const errors = [];
|
|
530
|
-
for (const file of files) {
|
|
531
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
532
|
-
try {
|
|
533
|
-
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
534
|
-
}
|
|
535
|
-
catch (err) {
|
|
536
|
-
errors.push({ file: fileRel, error: describeError(err) });
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
const graph = buildSymbolGraph(skeletons, root);
|
|
540
|
-
const cycles = findCircularDeps(graph);
|
|
541
|
-
return jsonText({
|
|
542
|
-
directory: rel.split(path.sep).join("/"),
|
|
543
|
-
scanned: files.length,
|
|
544
|
-
cycleCount: cycles.length,
|
|
545
|
-
...(errors.length > 0 ? { errors } : {}),
|
|
546
|
-
cycles,
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
catch (err) {
|
|
550
|
-
return errorText(describeError(err));
|
|
551
|
-
}
|
|
552
|
-
});
|
|
553
|
-
/* ─────────────────── tool: find_duplicate_symbols ──────────────────────── */
|
|
554
|
-
server.registerTool("find_duplicate_symbols", {
|
|
555
|
-
title: "Find duplicate exported symbols",
|
|
556
|
-
description: "Scan a directory and return symbol names that are exported from more than one file. " +
|
|
557
|
-
"These are often accidental collisions (copy-paste, parallel implementations) that make " +
|
|
558
|
-
"a codebase harder to navigate. Each result lists every file/kind that declares the name.",
|
|
559
|
-
inputSchema: {
|
|
560
|
-
path: z
|
|
561
|
-
.string()
|
|
562
|
-
.describe("Directory to scan, relative to project root or absolute within it."),
|
|
563
|
-
},
|
|
564
|
-
}, async ({ path: input }) => {
|
|
565
|
-
try {
|
|
566
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
567
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
568
|
-
return errorText(`"${input}" is not a directory. find_duplicate_symbols requires a directory.`);
|
|
569
|
-
}
|
|
570
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
571
|
-
const files = collectSourceFiles(abs, opts);
|
|
572
|
-
const skeletons = [];
|
|
573
|
-
const errors = [];
|
|
574
|
-
for (const file of files) {
|
|
575
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
576
|
-
try {
|
|
577
|
-
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
578
|
-
}
|
|
579
|
-
catch (err) {
|
|
580
|
-
errors.push({ file: fileRel, error: describeError(err) });
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
const graph = buildSymbolGraph(skeletons, root);
|
|
584
|
-
const duplicates = findDuplicateSymbols(graph);
|
|
585
|
-
return jsonText({
|
|
586
|
-
directory: rel.split(path.sep).join("/"),
|
|
587
|
-
scanned: files.length,
|
|
588
|
-
duplicateCount: duplicates.length,
|
|
589
|
-
...(errors.length > 0 ? { errors } : {}),
|
|
590
|
-
duplicates,
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
catch (err) {
|
|
594
|
-
return errorText(describeError(err));
|
|
595
|
-
}
|
|
596
|
-
});
|
|
597
|
-
/* ─────────────────── tool: get_complexity ──────────────────────────────── */
|
|
598
|
-
server.registerTool("get_complexity", {
|
|
599
|
-
title: "Get cyclomatic complexity per function",
|
|
600
|
-
description: "Compute AST-based cyclomatic complexity for every function/method in a FILE or DIRECTORY. " +
|
|
601
|
-
"Each function gets a score (1 + decision points: if / for / while / case / catch / ternary / && / ||) " +
|
|
602
|
-
"and a rating (low <=5, moderate <=10, high <=20, very-high >20). For a directory, returns per-file " +
|
|
603
|
-
"results plus the highest-complexity hotspots across the scan.",
|
|
604
|
-
inputSchema: {
|
|
605
|
-
path: z.string().describe("File or directory, relative to project root or absolute within it."),
|
|
606
|
-
},
|
|
607
|
-
}, async ({ path: input }) => {
|
|
608
|
-
try {
|
|
609
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
610
|
-
const stat = fs.statSync(abs);
|
|
611
|
-
if (stat.isDirectory()) {
|
|
612
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
613
|
-
const files = collectSourceFiles(abs, opts);
|
|
614
|
-
const results = [];
|
|
615
|
-
const errors = [];
|
|
616
|
-
for (const file of files) {
|
|
617
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
618
|
-
try {
|
|
619
|
-
const fc = await computeFileComplexity(file, fileRel);
|
|
620
|
-
if (fc)
|
|
621
|
-
results.push(fc);
|
|
622
|
-
}
|
|
623
|
-
catch (err) {
|
|
624
|
-
errors.push({ file: fileRel, error: describeError(err) });
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
const hotspots = results
|
|
628
|
-
.flatMap((r) => r.functions.map((f) => ({ file: r.file, ...f })))
|
|
629
|
-
.sort((a, b) => b.complexity - a.complexity)
|
|
630
|
-
.slice(0, 15);
|
|
631
|
-
return jsonText({
|
|
632
|
-
directory: rel.split(path.sep).join("/"),
|
|
633
|
-
scanned: files.length,
|
|
634
|
-
...(errors.length > 0 ? { errors } : {}),
|
|
635
|
-
hotspots,
|
|
636
|
-
files: results,
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
const fc = await computeFileComplexity(abs, rel.split(path.sep).join("/"));
|
|
640
|
-
if (!fc)
|
|
641
|
-
return errorText(`Unsupported file type: ${input}`);
|
|
642
|
-
return jsonText(fc);
|
|
643
|
-
}
|
|
644
|
-
catch (err) {
|
|
645
|
-
return errorText(describeError(err));
|
|
646
|
-
}
|
|
647
|
-
});
|
|
648
|
-
/* ─────────────────── tool: find_unused_params ──────────────────────────── */
|
|
649
|
-
server.registerTool("find_unused_params", {
|
|
650
|
-
title: "Find unused function parameters",
|
|
651
|
-
description: "Scan a FILE or DIRECTORY for named functions/methods that declare parameters never " +
|
|
652
|
-
"referenced in their body. Skips `_`-prefixed params (conventionally intentional), " +
|
|
653
|
-
"anonymous callbacks, and destructured bindings to avoid false positives.",
|
|
654
|
-
inputSchema: {
|
|
655
|
-
path: z.string().describe("File or directory, relative to project root or absolute within it."),
|
|
656
|
-
},
|
|
657
|
-
}, async ({ path: input }) => {
|
|
658
|
-
try {
|
|
659
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
660
|
-
const stat = fs.statSync(abs);
|
|
661
|
-
if (stat.isDirectory()) {
|
|
662
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
663
|
-
const files = collectSourceFiles(abs, opts);
|
|
664
|
-
const results = [];
|
|
665
|
-
const errors = [];
|
|
666
|
-
for (const file of files) {
|
|
667
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
668
|
-
try {
|
|
669
|
-
const r = await findUnusedParams(file, fileRel);
|
|
670
|
-
if (r && r.functions.length > 0)
|
|
671
|
-
results.push(r);
|
|
672
|
-
}
|
|
673
|
-
catch (err) {
|
|
674
|
-
errors.push({ file: fileRel, error: describeError(err) });
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
const unusedParamCount = results.reduce((sum, r) => sum + r.functions.reduce((a, f) => a + f.unused.length, 0), 0);
|
|
678
|
-
return jsonText({
|
|
679
|
-
directory: rel.split(path.sep).join("/"),
|
|
680
|
-
scanned: files.length,
|
|
681
|
-
...(errors.length > 0 ? { errors } : {}),
|
|
682
|
-
unusedParamCount,
|
|
683
|
-
files: results,
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
const r = await findUnusedParams(abs, rel.split(path.sep).join("/"));
|
|
687
|
-
if (!r)
|
|
688
|
-
return errorText(`Unsupported file type: ${input}`);
|
|
689
|
-
return jsonText(r);
|
|
690
|
-
}
|
|
691
|
-
catch (err) {
|
|
692
|
-
return errorText(describeError(err));
|
|
693
|
-
}
|
|
694
|
-
});
|
|
695
|
-
/* ─────────────────── tool: trace_type ──────────────────────────────────── */
|
|
696
|
-
server.registerTool("trace_type", {
|
|
697
|
-
title: "Trace a type through the code",
|
|
698
|
-
description: "Find everywhere a named type flows through a directory: function parameters and return " +
|
|
699
|
-
"types, typed variables, and class fields. A scoped, AST-based type-flow view (best for " +
|
|
700
|
-
"TS/Python) \u2014 no full type inference, so it tracks where the type is *named* in signatures.",
|
|
701
|
-
inputSchema: {
|
|
702
|
-
type: z.string().describe('Type name to trace, e.g. "Inventory".'),
|
|
703
|
-
path: z.string().describe("Directory to scan, relative to project root or absolute within it."),
|
|
704
|
-
},
|
|
705
|
-
}, async ({ type: typeName, path: input }) => {
|
|
706
|
-
try {
|
|
707
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
708
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
709
|
-
return errorText(`"${input}" is not a directory. trace_type requires a directory.`);
|
|
710
|
-
}
|
|
711
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
712
|
-
const files = collectSourceFiles(abs, opts);
|
|
713
|
-
const refs = [];
|
|
714
|
-
const errors = [];
|
|
715
|
-
for (const file of files) {
|
|
716
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
717
|
-
try {
|
|
718
|
-
refs.push(...(await traceTypeInFile(file, fileRel, typeName)));
|
|
719
|
-
}
|
|
720
|
-
catch (err) {
|
|
721
|
-
errors.push({ file: fileRel, error: describeError(err) });
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
const byRole = { param: 0, return: 0, variable: 0, field: 0 };
|
|
725
|
-
for (const r of refs)
|
|
726
|
-
byRole[r.role]++;
|
|
727
|
-
return jsonText({
|
|
728
|
-
type: typeName,
|
|
729
|
-
directory: rel.split(path.sep).join("/"),
|
|
730
|
-
scanned: files.length,
|
|
731
|
-
refCount: refs.length,
|
|
732
|
-
byRole,
|
|
733
|
-
...(errors.length > 0 ? { errors } : {}),
|
|
734
|
-
refs,
|
|
735
|
-
});
|
|
736
|
-
}
|
|
737
|
-
catch (err) {
|
|
738
|
-
return errorText(describeError(err));
|
|
739
|
-
}
|
|
740
|
-
});
|
|
741
|
-
/* ─────────────────── tool: analyze_workspace ───────────────────────────── */
|
|
742
|
-
server.registerTool("analyze_workspace", {
|
|
743
|
-
title: "Analyze a monorepo workspace",
|
|
744
|
-
description: "Discover the packages in a JS/TS monorepo (npm/yarn `workspaces`, pnpm-workspace.yaml, or " +
|
|
745
|
-
"lerna.json) and the dependency edges between them. Returns each package's name, directory, " +
|
|
746
|
-
"and workspace-internal dependencies, plus any circular dependencies between packages.",
|
|
747
|
-
inputSchema: {
|
|
748
|
-
path: z.string().optional().describe("Workspace root directory. Defaults to the project root."),
|
|
749
|
-
},
|
|
750
|
-
}, async ({ path: input }) => {
|
|
751
|
-
try {
|
|
752
|
-
const { abs, rel, root } = resolveInRoot(input ?? ".");
|
|
753
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
754
|
-
return errorText(`"${input}" is not a directory. analyze_workspace requires a directory.`);
|
|
755
|
-
}
|
|
756
|
-
const info = discoverWorkspace(abs);
|
|
757
|
-
const cycles = findPackageCycles(info);
|
|
758
|
-
return jsonText({
|
|
759
|
-
root: rel.split(path.sep).join("/") || ".",
|
|
760
|
-
tool: info.tool,
|
|
761
|
-
packageCount: info.packages.length,
|
|
762
|
-
packages: info.packages,
|
|
763
|
-
edges: info.edges,
|
|
764
|
-
packageCycles: cycles,
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
catch (err) {
|
|
768
|
-
return errorText(describeError(err));
|
|
769
|
-
}
|
|
770
|
-
});
|
|
771
|
-
/* ─────────────────── tool: read_source_map ─────────────────────────────── */
|
|
772
|
-
server.registerTool("read_source_map", {
|
|
773
|
-
title: "Read a compiled file's source map",
|
|
774
|
-
description: "Given a compiled JS/CSS file with an inline (`data:`) or external `sourceMappingURL`, " +
|
|
775
|
-
"return the original source paths it maps back to. Useful for tracing built output in " +
|
|
776
|
-
"dist/ back to the real source files.",
|
|
777
|
-
inputSchema: {
|
|
778
|
-
path: z.string().describe("Compiled file path, relative to project root or absolute within it."),
|
|
779
|
-
},
|
|
780
|
-
}, async ({ path: input }) => {
|
|
781
|
-
try {
|
|
782
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
783
|
-
const info = readSourceMap(abs, rel.split(path.sep).join("/"));
|
|
784
|
-
if (!info)
|
|
785
|
-
return errorText(`No source map found for "${input}".`);
|
|
786
|
-
return jsonText(info);
|
|
787
|
-
}
|
|
788
|
-
catch (err) {
|
|
789
|
-
return errorText(describeError(err));
|
|
790
|
-
}
|
|
791
|
-
});
|
|
792
|
-
/* ─────────────────── tool: get_codebase_report ─────────────────────────── */
|
|
793
|
-
server.registerTool("get_codebase_report", {
|
|
794
|
-
title: "Codebase health report",
|
|
795
|
-
description: "Scan a directory and return a one-shot health summary: file/symbol counts, language " +
|
|
796
|
-
"breakdown, a health grade (A\u2013F) and score, complexity hotspots, god nodes (most-imported " +
|
|
797
|
-
"symbols), dead exports, and circular dependencies. The `ast-map report` CLI renders this as HTML.",
|
|
798
|
-
inputSchema: {
|
|
799
|
-
path: z.string().optional().describe("Directory to scan. Defaults to the project root."),
|
|
800
|
-
},
|
|
801
|
-
}, async ({ path: input }) => {
|
|
802
|
-
try {
|
|
803
|
-
const { abs, rel, root } = resolveInRoot(input ?? ".");
|
|
804
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
805
|
-
return errorText(`"${input}" is not a directory. get_codebase_report requires a directory.`);
|
|
806
|
-
}
|
|
807
|
-
const data = await buildReport(abs, root);
|
|
808
|
-
return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...data });
|
|
809
|
-
}
|
|
810
|
-
catch (err) {
|
|
811
|
-
return errorText(describeError(err));
|
|
812
|
-
}
|
|
813
|
-
});
|
|
814
|
-
/* ─────────────────── tool: check_quality_gate ──────────────────────────── */
|
|
815
|
-
server.registerTool("check_quality_gate", {
|
|
816
|
-
title: "Quality gate (thresholds + baseline ratchet)",
|
|
817
|
-
description: "Run the CI quality gate over a directory: evaluates absolute thresholds (from " +
|
|
818
|
-
"`.ast-map.config.json` \u2192 `check`) and a **baseline ratchet** against " +
|
|
819
|
-
"`.ast-map.baseline.json` \u2014 fails when cycles, dead exports, SDP violations, " +
|
|
820
|
-
"very-high-complexity functions, or the health score regress. " +
|
|
821
|
-
"Set updateBaseline to re-anchor the baseline at the current metrics.",
|
|
822
|
-
inputSchema: {
|
|
823
|
-
path: z.string().optional().describe("Directory to gate. Defaults to the project root."),
|
|
824
|
-
baseline: z.string().optional().describe("Baseline file path. Default .ast-map.baseline.json."),
|
|
825
|
-
updateBaseline: z.boolean().optional().describe("Write current metrics as the new baseline."),
|
|
826
|
-
},
|
|
827
|
-
}, async ({ path: input, baseline, updateBaseline }) => {
|
|
828
|
-
try {
|
|
829
|
-
const { abs, rel, root } = resolveInRoot(input ?? ".");
|
|
830
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
831
|
-
return errorText(`"${input}" is not a directory. check_quality_gate requires a directory.`);
|
|
832
|
-
}
|
|
833
|
-
const thresholds = loadProjectConfig(root).check;
|
|
834
|
-
const result = await runQualityGate(abs, root, {
|
|
835
|
-
baselinePath: baseline,
|
|
836
|
-
thresholds,
|
|
837
|
-
updateBaseline,
|
|
838
|
-
});
|
|
839
|
-
return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...result });
|
|
840
|
-
}
|
|
841
|
-
catch (err) {
|
|
842
|
-
return errorText(describeError(err));
|
|
843
|
-
}
|
|
844
|
-
});
|
|
845
|
-
/* ─────────────────── tool: get_diff ────────────────────────────────────── */
|
|
846
|
-
server.registerTool("get_diff", {
|
|
847
|
-
title: "Git-aware change diff + blast radius",
|
|
848
|
-
description: "Compare the working tree against a git ref (default HEAD) and return which symbols were " +
|
|
849
|
-
"added/removed/modified per file, which changes are potentially **breaking** (removed or " +
|
|
850
|
-
"signature-changed exports), and the **blast radius** \u2014 files that depend on those breaking changes.",
|
|
851
|
-
inputSchema: {
|
|
852
|
-
base: z.string().optional().describe("Git ref to compare against. Default HEAD."),
|
|
853
|
-
path: z.string().optional().describe("Limit to a subdirectory. Default project root."),
|
|
854
|
-
},
|
|
855
|
-
}, async ({ base, path: input }) => {
|
|
856
|
-
try {
|
|
857
|
-
const { abs, rel, root } = resolveInRoot(input ?? ".");
|
|
858
|
-
if (!isGitRepo(root))
|
|
859
|
-
return errorText("Not a git repository (or git is unavailable).");
|
|
860
|
-
const data = await computeDiff(abs, root, base ?? "HEAD");
|
|
861
|
-
return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...data });
|
|
862
|
-
}
|
|
863
|
-
catch (err) {
|
|
864
|
-
return errorText(describeError(err));
|
|
865
|
-
}
|
|
866
|
-
});
|
|
867
|
-
/* ─────────────────── tool: get_risk_map ────────────────────────────────── */
|
|
868
|
-
server.registerTool("get_risk_map", {
|
|
869
|
-
title: "Refactor risk map (churn \u00d7 complexity)",
|
|
870
|
-
description: "Rank files by refactor risk = git churn (number of commits touching the file) \u00d7 the file's " +
|
|
871
|
-
"max function complexity. Surfaces the files that are both frequently changed and complex \u2014 " +
|
|
872
|
-
"the most valuable refactor / test targets.",
|
|
873
|
-
inputSchema: {
|
|
874
|
-
path: z.string().optional().describe("Directory to scan. Default project root."),
|
|
875
|
-
},
|
|
876
|
-
}, async ({ path: input }) => {
|
|
877
|
-
try {
|
|
878
|
-
const { abs, rel, root } = resolveInRoot(input ?? ".");
|
|
879
|
-
if (!isGitRepo(root))
|
|
880
|
-
return errorText("Not a git repository (or git is unavailable).");
|
|
881
|
-
const files = await computeRisk(abs, root);
|
|
882
|
-
return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: files.length, files: files.slice(0, 50) });
|
|
883
|
-
}
|
|
884
|
-
catch (err) {
|
|
885
|
-
return errorText(describeError(err));
|
|
886
|
-
}
|
|
887
|
-
});
|
|
888
|
-
/* ─────────────────── tool: pack_context ────────────────────────────────── */
|
|
889
|
-
server.registerTool("pack_context", {
|
|
890
|
-
title: "Minimal context pack for a symbol",
|
|
891
|
-
description: "Assemble the *minimal* context needed to understand or change a symbol \u2014 the symbol's own " +
|
|
892
|
-
"source, the signatures of what it depends on (resolved imports), and the files that depend on " +
|
|
893
|
-
"it \u2014 instead of reading whole files. Returns a token estimate so you can see the savings.",
|
|
894
|
-
inputSchema: {
|
|
895
|
-
path: z.string().describe("File containing the symbol (relative to root or absolute within it)."),
|
|
896
|
-
symbol: z.string().optional().describe("Symbol name to centre the pack on. Omit for the whole file."),
|
|
897
|
-
scan: z.string().optional().describe("Directory to scan for dependents. Default: project root."),
|
|
898
|
-
},
|
|
899
|
-
}, async ({ path: input, symbol, scan }) => {
|
|
900
|
-
try {
|
|
901
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
902
|
-
if (fs.statSync(abs).isDirectory())
|
|
903
|
-
return errorText(`"${input}" is a directory; pass a file.`);
|
|
904
|
-
const scanAbs = scan ? resolveInRoot(scan).abs : root;
|
|
905
|
-
const pack = await packContext(abs, rel.split(path.sep).join("/"), root, symbol, scanAbs);
|
|
906
|
-
return jsonText(pack);
|
|
907
|
-
}
|
|
908
|
-
catch (err) {
|
|
909
|
-
return errorText(describeError(err));
|
|
910
|
-
}
|
|
911
|
-
});
|
|
912
|
-
/* ─────────────────── tool: get_coupling ────────────────────────────────── */
|
|
913
|
-
server.registerTool("get_coupling", {
|
|
914
|
-
title: "Coupling metrics (afferent / efferent / instability)",
|
|
915
|
-
description: "Compute Robert C. Martin's coupling metrics per file from the import graph: afferent coupling " +
|
|
916
|
-
"(Ca, fan-in), efferent coupling (Ce, fan-out), and instability I = Ce/(Ca+Ce) (0 = stable, " +
|
|
917
|
-
"1 = unstable). High-Ca files are load-bearing; high-instability files change freely.",
|
|
918
|
-
inputSchema: {
|
|
919
|
-
path: z.string().optional().describe("Directory to scan. Default project root."),
|
|
920
|
-
},
|
|
921
|
-
}, async ({ path: input }) => {
|
|
922
|
-
try {
|
|
923
|
-
const { abs, rel, root } = resolveInRoot(input ?? ".");
|
|
924
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
925
|
-
return errorText(`"${input}" is not a directory. get_coupling requires a directory.`);
|
|
926
|
-
}
|
|
927
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
928
|
-
const files = collectSourceFiles(abs, opts);
|
|
929
|
-
const skels = [];
|
|
930
|
-
for (const f of files) {
|
|
931
|
-
const r = path.relative(root, f).split(path.sep).join("/");
|
|
932
|
-
try {
|
|
933
|
-
skels.push(await buildSkeleton(f, r, opts));
|
|
934
|
-
}
|
|
935
|
-
catch { /* skip */ }
|
|
936
|
-
}
|
|
937
|
-
const metrics = computeCoupling(buildSymbolGraph(skels, root));
|
|
938
|
-
return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: metrics.length, files: metrics });
|
|
939
|
-
}
|
|
940
|
-
catch (err) {
|
|
941
|
-
return errorText(describeError(err));
|
|
942
|
-
}
|
|
943
|
-
});
|
|
944
|
-
/* ─────────────────── tool: get_layer_violations ────────────────────────── */
|
|
945
|
-
server.registerTool("get_layer_violations", {
|
|
946
|
-
title: "Layer violations (Stable Dependencies Principle)",
|
|
947
|
-
description: "Find dependencies that point the wrong way on the stability gradient: a stable file " +
|
|
948
|
-
"(low instability) that imports a more volatile file (high instability). Per Robert C. Martin's " +
|
|
949
|
-
"Stable Dependencies Principle, stable code should not depend on volatile code — it gets dragged " +
|
|
950
|
-
"along every time the volatile file churns. Results are sorted by severity (the instability gap).",
|
|
951
|
-
inputSchema: {
|
|
952
|
-
path: z.string().optional().describe("Directory to scan. Default project root."),
|
|
953
|
-
minGap: z.number().optional().describe("Only report violations whose instability gap exceeds this (0-1). Default 0."),
|
|
954
|
-
},
|
|
955
|
-
}, async ({ path: input, minGap }) => {
|
|
956
|
-
try {
|
|
957
|
-
const { abs, rel, root } = resolveInRoot(input ?? ".");
|
|
958
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
959
|
-
return errorText(`"${input}" is not a directory. get_layer_violations requires a directory.`);
|
|
960
|
-
}
|
|
961
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
962
|
-
const files = collectSourceFiles(abs, opts);
|
|
963
|
-
const skels = [];
|
|
964
|
-
for (const f of files) {
|
|
965
|
-
const r = path.relative(root, f).split(path.sep).join("/");
|
|
966
|
-
try {
|
|
967
|
-
skels.push(await buildSkeleton(f, r, opts));
|
|
968
|
-
}
|
|
969
|
-
catch { /* skip */ }
|
|
970
|
-
}
|
|
971
|
-
const violations = findLayerViolations(buildSymbolGraph(skels, root), minGap ?? 0);
|
|
972
|
-
return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: violations.length, violations });
|
|
973
|
-
}
|
|
974
|
-
catch (err) {
|
|
975
|
-
return errorText(describeError(err));
|
|
976
|
-
}
|
|
977
|
-
});
|
|
978
|
-
/* ─────────────────── tool: get_module_coupling ─────────────────────────── */
|
|
979
|
-
server.registerTool("get_module_coupling", {
|
|
980
|
-
title: "Module coupling (directory-level Ca / Ce / instability)",
|
|
981
|
-
description: "Aggregate the import graph up to the directory/module level: per-module afferent (Ca) / " +
|
|
982
|
-
"efferent (Ce) coupling and instability, plus the weighted inter-module edges. Intra-module " +
|
|
983
|
-
"imports (files importing siblings in the same directory) are ignored — only cross-module " +
|
|
984
|
-
"dependencies count. The architectural bird's-eye view above per-file coupling.",
|
|
985
|
-
inputSchema: {
|
|
986
|
-
path: z.string().optional().describe("Directory to scan. Default project root."),
|
|
987
|
-
},
|
|
988
|
-
}, async ({ path: input }) => {
|
|
989
|
-
try {
|
|
990
|
-
const { abs, rel, root } = resolveInRoot(input ?? ".");
|
|
991
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
992
|
-
return errorText(`"${input}" is not a directory. get_module_coupling requires a directory.`);
|
|
993
|
-
}
|
|
994
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
995
|
-
const files = collectSourceFiles(abs, opts);
|
|
996
|
-
const skels = [];
|
|
997
|
-
for (const f of files) {
|
|
998
|
-
const r = path.relative(root, f).split(path.sep).join("/");
|
|
999
|
-
try {
|
|
1000
|
-
skels.push(await buildSkeleton(f, r, opts));
|
|
1001
|
-
}
|
|
1002
|
-
catch { /* skip */ }
|
|
1003
|
-
}
|
|
1004
|
-
const mc = computeModuleCoupling(buildSymbolGraph(skels, root));
|
|
1005
|
-
return jsonText({ directory: rel.split(path.sep).join("/") || ".", moduleCount: mc.modules.length, ...mc });
|
|
1006
|
-
}
|
|
1007
|
-
catch (err) {
|
|
1008
|
-
return errorText(describeError(err));
|
|
1009
|
-
}
|
|
1010
|
-
});
|
|
1011
|
-
/* ─────────────────── tool: get_change_impact ───────────────────────────── */
|
|
1012
|
-
server.registerTool("get_change_impact", {
|
|
1013
|
-
title: "Get change impact (blast radius)",
|
|
1014
|
-
description: "Given a file and a symbol name, find every file/symbol in the project that directly or " +
|
|
1015
|
-
"transitively depends on it via imports. Use this before refactoring to understand blast radius.\n" +
|
|
1016
|
-
"Returns: { direct, transitive, totalFiles } where direct = files that import the symbol " +
|
|
1017
|
-
"directly, transitive = further dependents up the chain.",
|
|
1018
|
-
inputSchema: {
|
|
1019
|
-
path: z
|
|
1020
|
-
.string()
|
|
1021
|
-
.describe("File containing the symbol, relative to project root or absolute within it."),
|
|
1022
|
-
symbol: z.string().describe("Name of the exported symbol to analyse."),
|
|
1023
|
-
scanDir: z
|
|
1024
|
-
.string()
|
|
1025
|
-
.optional()
|
|
1026
|
-
.describe("Directory to build the dependency graph from. Defaults to the directory of the given file."),
|
|
1027
|
-
},
|
|
1028
|
-
}, async ({ path: input, symbol, scanDir }) => {
|
|
1029
|
-
try {
|
|
1030
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
1031
|
-
if (fs.statSync(abs).isDirectory()) {
|
|
1032
|
-
return errorText(`"${input}" is a directory. Provide a single file path.`);
|
|
1033
|
-
}
|
|
1034
|
-
const scanRoot = scanDir ? resolveInRoot(scanDir).abs : path.dirname(abs);
|
|
1035
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
1036
|
-
const files = collectSourceFiles(scanRoot, opts);
|
|
1037
|
-
const skeletons = [];
|
|
1038
|
-
for (const file of files) {
|
|
1039
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
1040
|
-
try {
|
|
1041
|
-
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
1042
|
-
}
|
|
1043
|
-
catch {
|
|
1044
|
-
// skip parse errors
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
const graph = buildSymbolGraph(skeletons, root);
|
|
1048
|
-
const targetNodeId = `${rel.split(path.sep).join("/")}::${symbol}`;
|
|
1049
|
-
const impact = getChangeImpact(graph, targetNodeId);
|
|
1050
|
-
if (!impact) {
|
|
1051
|
-
return errorText(`Symbol "${symbol}" not found in graph for "${rel}". ` +
|
|
1052
|
-
`Check the symbol name and ensure the file is inside the scan directory.`);
|
|
1053
|
-
}
|
|
1054
|
-
return jsonText(impact);
|
|
1055
|
-
}
|
|
1056
|
-
catch (err) {
|
|
1057
|
-
return errorText(describeError(err));
|
|
1058
|
-
}
|
|
1059
|
-
});
|
|
1060
|
-
/* ─────────────────── tool: get_call_graph ──────────────────────────────── */
|
|
1061
|
-
server.registerTool("get_call_graph", {
|
|
1062
|
-
title: "Get function-level call graph",
|
|
1063
|
-
description: "For a named function in a file, return:\n" +
|
|
1064
|
-
" - calls: every function/method this function calls, with line number and resolved file\n" +
|
|
1065
|
-
" - calledBy: files that import (and thus likely call) this function\n" +
|
|
1066
|
-
"Supports TypeScript, JavaScript, Python, and Go. " +
|
|
1067
|
-
"Cross-file calls are resolved via the import graph; local calls are flagged isLocal=true.",
|
|
1068
|
-
inputSchema: {
|
|
1069
|
-
path: z
|
|
1070
|
-
.string()
|
|
1071
|
-
.describe("File path, relative to project root or absolute within it."),
|
|
1072
|
-
function: z.string().describe("Name of the function or method to analyse."),
|
|
1073
|
-
scanDir: z
|
|
1074
|
-
.string()
|
|
1075
|
-
.optional()
|
|
1076
|
-
.describe("Directory to scan for reverse import lookup (calledBy). " +
|
|
1077
|
-
"Defaults to the directory of the given file."),
|
|
1078
|
-
},
|
|
1079
|
-
}, async ({ path: input, function: funcName, scanDir }) => {
|
|
1080
|
-
try {
|
|
1081
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
1082
|
-
if (fs.statSync(abs).isDirectory()) {
|
|
1083
|
-
return errorText(`"${input}" is a directory. Provide a single file path.`);
|
|
1084
|
-
}
|
|
1085
|
-
// Collect skeletons for the scan directory (for calledBy lookup)
|
|
1086
|
-
const scanRoot = scanDir ? resolveInRoot(scanDir).abs : path.dirname(abs);
|
|
1087
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
1088
|
-
const files = collectSourceFiles(scanRoot, opts);
|
|
1089
|
-
const skeletons = [];
|
|
1090
|
-
for (const file of files) {
|
|
1091
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
1092
|
-
try {
|
|
1093
|
-
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
1094
|
-
}
|
|
1095
|
-
catch {
|
|
1096
|
-
// skip
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
const result = await buildCallGraph(abs, funcName, root, skeletons);
|
|
1100
|
-
if (!result) {
|
|
1101
|
-
return errorText(`Function "${funcName}" not found in "${rel}", or the file language is unsupported.`);
|
|
1102
|
-
}
|
|
1103
|
-
return jsonText(result);
|
|
1104
|
-
}
|
|
1105
|
-
catch (err) {
|
|
1106
|
-
return errorText(describeError(err));
|
|
1107
|
-
}
|
|
1108
|
-
});
|
|
1109
|
-
/* ─────────────────── tool: search_symbol ───────────────────────────────── */
|
|
1110
|
-
server.registerTool("search_symbol", {
|
|
1111
|
-
title: "Search symbols by name",
|
|
1112
|
-
description: "Find symbols (functions, classes, types, methods, …) by name across all source files " +
|
|
1113
|
-
"in a directory. Supports exact match, contains (default), or regex.\n" +
|
|
1114
|
-
"Useful when you know a symbol name but not which file it lives in.",
|
|
1115
|
-
inputSchema: {
|
|
1116
|
-
path: z
|
|
1117
|
-
.string()
|
|
1118
|
-
.describe("Directory to search in, relative to project root or absolute within it."),
|
|
1119
|
-
name: z.string().describe("Symbol name to search for."),
|
|
1120
|
-
matchType: z
|
|
1121
|
-
.enum(["contains", "exact", "regex"])
|
|
1122
|
-
.optional()
|
|
1123
|
-
.describe('"contains" (default) — case-insensitive substring. "exact" — full name. "regex" — JS regex.'),
|
|
1124
|
-
kind: z
|
|
1125
|
-
.enum(["function", "class", "interface", "type", "method", "const", "var", "enum", "struct", "field"])
|
|
1126
|
-
.optional()
|
|
1127
|
-
.describe("Filter by symbol kind."),
|
|
1128
|
-
exportedOnly: z
|
|
1129
|
-
.boolean()
|
|
1130
|
-
.optional()
|
|
1131
|
-
.describe("Only return exported symbols. Default false."),
|
|
1132
|
-
},
|
|
1133
|
-
}, async ({ path: input, name, matchType, kind, exportedOnly }) => {
|
|
1134
|
-
try {
|
|
1135
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
1136
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
1137
|
-
return errorText(`"${input}" is not a directory. search_symbol requires a directory.`);
|
|
1138
|
-
}
|
|
1139
|
-
const matches = await searchSymbols(abs, name, root, { matchType, kind, exportedOnly });
|
|
1140
|
-
return jsonText({
|
|
1141
|
-
directory: rel.split(path.sep).join("/"),
|
|
1142
|
-
pattern: name,
|
|
1143
|
-
matchCount: matches.length,
|
|
1144
|
-
matches,
|
|
1145
|
-
});
|
|
1146
|
-
}
|
|
1147
|
-
catch (err) {
|
|
1148
|
-
return errorText(describeError(err));
|
|
1149
|
-
}
|
|
1150
|
-
});
|
|
1151
|
-
/* ─────────────────── tool: semantic_search ─────────────────────── */
|
|
1152
|
-
server.registerTool("semantic_search", {
|
|
1153
|
-
title: "Search symbols by meaning",
|
|
1154
|
-
description: "Find symbols by *meaning*, not exact name. Tokenizes identifiers (camelCase/snake_case), " +
|
|
1155
|
-
"expands programming synonyms (fetch≈get≈load, remove≈delete≈destroy, …), applies light " +
|
|
1156
|
-
"stemming and fuzzy matching, and ranks with BM25-style IDF weighting over symbol names, " +
|
|
1157
|
-
"doc comments, signatures and file paths.\n" +
|
|
1158
|
-
'Use when you know what code *does* but not what it\'s called: "remove expired sessions", ' +
|
|
1159
|
-
'"parse config file", "validate user input".',
|
|
1160
|
-
inputSchema: {
|
|
1161
|
-
path: z
|
|
1162
|
-
.string()
|
|
1163
|
-
.describe("Directory to search in, relative to project root or absolute within it."),
|
|
1164
|
-
query: z
|
|
1165
|
-
.string()
|
|
1166
|
-
.describe('What the code does, e.g. "delete old cache entries" or "load user settings".'),
|
|
1167
|
-
limit: z.number().int().min(1).max(100).optional().describe("Max results. Default 20."),
|
|
1168
|
-
kind: z
|
|
1169
|
-
.enum(["function", "class", "interface", "type", "method", "const", "var", "enum", "struct", "field"])
|
|
1170
|
-
.optional()
|
|
1171
|
-
.describe("Filter by symbol kind."),
|
|
1172
|
-
exportedOnly: z
|
|
1173
|
-
.boolean()
|
|
1174
|
-
.optional()
|
|
1175
|
-
.describe("Only return exported symbols. Default false."),
|
|
1176
|
-
},
|
|
1177
|
-
}, async ({ path: input, query, limit, kind, exportedOnly }) => {
|
|
1178
|
-
try {
|
|
1179
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
1180
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
1181
|
-
return errorText(`"${input}" is not a directory. semantic_search requires a directory.`);
|
|
1182
|
-
}
|
|
1183
|
-
const matches = await semanticSearch(abs, query, root, { limit, kind, exportedOnly });
|
|
1184
|
-
return jsonText({
|
|
1185
|
-
directory: rel.split(path.sep).join("/"),
|
|
1186
|
-
query,
|
|
1187
|
-
matchCount: matches.length,
|
|
1188
|
-
matches,
|
|
1189
|
-
});
|
|
1190
|
-
}
|
|
1191
|
-
catch (err) {
|
|
1192
|
-
return errorText(describeError(err));
|
|
1193
|
-
}
|
|
1194
|
-
});
|
|
1195
|
-
/* ─────────────────── tool: get_test_coverage ───────────────────────────── */
|
|
1196
|
-
server.registerTool("get_test_coverage", {
|
|
1197
|
-
title: "Test-coverage map (tests ↔ sources)",
|
|
1198
|
-
description: "Structural test coverage: pair test files with the source files they exercise and list " +
|
|
1199
|
-
"source files no test touches. Two signals: a test file *importing* a source file " +
|
|
1200
|
-
"(definitive) and naming conventions (auth.test.ts → auth.ts, test_utils.py → utils.py). " +
|
|
1201
|
-
"No instrumentation or test runner needed. Untested files are ranked by risk " +
|
|
1202
|
-
"(fan-in, then symbol count). This is file-level coverage, not line coverage.",
|
|
1203
|
-
inputSchema: {
|
|
1204
|
-
path: z.string().optional().describe("Directory to scan (should include the test files). Default project root."),
|
|
1205
|
-
untestedOnly: z.boolean().optional().describe("Return only the untested-sources list. Default false."),
|
|
1206
|
-
},
|
|
1207
|
-
}, async ({ path: input, untestedOnly }) => {
|
|
1208
|
-
try {
|
|
1209
|
-
const { abs, rel, root } = resolveInRoot(input ?? ".");
|
|
1210
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
1211
|
-
return errorText(`"${input}" is not a directory. get_test_coverage requires a directory.`);
|
|
1212
|
-
}
|
|
1213
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
1214
|
-
const files = collectSourceFiles(abs, opts);
|
|
1215
|
-
const skels = [];
|
|
1216
|
-
for (const f of files) {
|
|
1217
|
-
const r = path.relative(root, f).split(path.sep).join("/");
|
|
1218
|
-
try {
|
|
1219
|
-
skels.push(await buildSkeleton(f, r, opts));
|
|
1220
|
-
}
|
|
1221
|
-
catch { /* skip */ }
|
|
1222
|
-
}
|
|
1223
|
-
const map = mapTestCoverage(buildSymbolGraph(skels, root));
|
|
1224
|
-
const dir = rel.split(path.sep).join("/") || ".";
|
|
1225
|
-
if (untestedOnly) {
|
|
1226
|
-
return jsonText({ directory: dir, untestedSources: map.untestedSources, coverageRatio: map.coverageRatio, untested: map.untested });
|
|
1227
|
-
}
|
|
1228
|
-
return jsonText({ directory: dir, ...map });
|
|
1229
|
-
}
|
|
1230
|
-
catch (err) {
|
|
1231
|
-
return errorText(describeError(err));
|
|
1232
|
-
}
|
|
1233
|
-
});
|
|
1234
|
-
/* ─────────────────── tool: get_file_deps ───────────────────────────────── */
|
|
1235
|
-
server.registerTool("get_file_deps", {
|
|
1236
|
-
title: "Get file-level import dependencies",
|
|
1237
|
-
description: "For a single file, show:\n" +
|
|
1238
|
-
" - imports: what this file imports from other files (with symbol names)\n" +
|
|
1239
|
-
" - importedBy: which files import from this file (with symbol names)\n" +
|
|
1240
|
-
"More focused than build_symbol_graph — use this for quick dependency lookup without needing the full graph.",
|
|
1241
|
-
inputSchema: {
|
|
1242
|
-
path: z.string().describe("File to inspect, relative to project root or absolute within it."),
|
|
1243
|
-
scanDir: z
|
|
1244
|
-
.string()
|
|
1245
|
-
.optional()
|
|
1246
|
-
.describe("Directory to build the graph from. Defaults to the directory of the given file."),
|
|
1247
|
-
},
|
|
1248
|
-
}, async ({ path: input, scanDir }) => {
|
|
1249
|
-
try {
|
|
1250
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
1251
|
-
if (fs.statSync(abs).isDirectory()) {
|
|
1252
|
-
return errorText(`"${input}" is a directory. Provide a single file path.`);
|
|
1253
|
-
}
|
|
1254
|
-
const scanRoot = scanDir ? resolveInRoot(scanDir).abs : path.dirname(abs);
|
|
1255
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
1256
|
-
const files = collectSourceFiles(scanRoot, opts);
|
|
1257
|
-
const skeletons = [];
|
|
1258
|
-
for (const file of files) {
|
|
1259
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
1260
|
-
try {
|
|
1261
|
-
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
1262
|
-
}
|
|
1263
|
-
catch { /* skip */ }
|
|
1264
|
-
}
|
|
1265
|
-
const graph = buildSymbolGraph(skeletons, root);
|
|
1266
|
-
const fileId = rel.split(path.sep).join("/");
|
|
1267
|
-
const result = getFileDeps(graph, fileId);
|
|
1268
|
-
if (!result) {
|
|
1269
|
-
return errorText(`"${rel}" was not found in the graph. Ensure it is inside the scan directory and is a supported source file.`);
|
|
1270
|
-
}
|
|
1271
|
-
return jsonText(result);
|
|
1272
|
-
}
|
|
1273
|
-
catch (err) {
|
|
1274
|
-
return errorText(describeError(err));
|
|
1275
|
-
}
|
|
1276
|
-
});
|
|
1277
|
-
/* ─────────────────── tool: get_top_symbols ─────────────────────────────── */
|
|
1278
|
-
server.registerTool("get_top_symbols", {
|
|
1279
|
-
title: "Get most-imported symbols (God Node detector)",
|
|
1280
|
-
description: "Scan a directory and return the N symbols that are imported by the most files. " +
|
|
1281
|
-
"These are your codebase's 'God Nodes' — high-coupling, high-risk symbols where a " +
|
|
1282
|
-
"breaking change would have maximum blast radius. Use before a major refactor to " +
|
|
1283
|
-
"identify which symbols need the most care.",
|
|
1284
|
-
inputSchema: {
|
|
1285
|
-
path: z
|
|
1286
|
-
.string()
|
|
1287
|
-
.describe("Directory to scan, relative to project root or absolute within it."),
|
|
1288
|
-
limit: z
|
|
1289
|
-
.number()
|
|
1290
|
-
.int()
|
|
1291
|
-
.min(1)
|
|
1292
|
-
.max(100)
|
|
1293
|
-
.optional()
|
|
1294
|
-
.describe("Number of top symbols to return. Default 10."),
|
|
1295
|
-
},
|
|
1296
|
-
}, async ({ path: input, limit }) => {
|
|
1297
|
-
try {
|
|
1298
|
-
const { abs, rel, root } = resolveInRoot(input);
|
|
1299
|
-
if (!fs.statSync(abs).isDirectory()) {
|
|
1300
|
-
return errorText(`"${input}" is not a directory. get_top_symbols requires a directory.`);
|
|
1301
|
-
}
|
|
1302
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
1303
|
-
const files = collectSourceFiles(abs, opts);
|
|
1304
|
-
const skeletons = [];
|
|
1305
|
-
for (const file of files) {
|
|
1306
|
-
const fileRel = path.relative(root, file).split(path.sep).join("/");
|
|
1307
|
-
try {
|
|
1308
|
-
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
1309
|
-
}
|
|
1310
|
-
catch { /* skip */ }
|
|
1311
|
-
}
|
|
1312
|
-
const graph = buildSymbolGraph(skeletons, root);
|
|
1313
|
-
const top = getTopSymbols(graph, limit ?? 10);
|
|
1314
|
-
return jsonText({
|
|
1315
|
-
directory: rel.split(path.sep).join("/"),
|
|
1316
|
-
scanned: files.length,
|
|
1317
|
-
topSymbols: top,
|
|
1318
|
-
});
|
|
1319
|
-
}
|
|
1320
|
-
catch (err) {
|
|
1321
|
-
return errorText(describeError(err));
|
|
1322
|
-
}
|
|
1323
|
-
});
|
|
1324
|
-
function describeError(err) {
|
|
1325
|
-
if (err instanceof UnsupportedLanguageError)
|
|
1326
|
-
return err.message;
|
|
1327
|
-
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
|
1328
|
-
return `File not found. Check the path (resolved against root ${ROOT}).`;
|
|
1329
|
-
}
|
|
1330
|
-
return err instanceof Error ? err.message : String(err);
|
|
1331
|
-
}
|
|
1332
|
-
/* ─────────────────── MCP resources (browseable structure) ──────────────── */
|
|
1333
|
-
server.registerResource("languages", "ast://languages", {
|
|
1334
|
-
title: "Supported languages",
|
|
1335
|
-
description: "Languages and file extensions this server can map.",
|
|
1336
|
-
mimeType: "application/json",
|
|
1337
|
-
}, async (uri) => ({
|
|
1338
|
-
contents: [{
|
|
1339
|
-
uri: uri.href,
|
|
1340
|
-
mimeType: "application/json",
|
|
1341
|
-
text: JSON.stringify({ root: ROOT, languages: supportedLanguages() }, null, 2),
|
|
1342
|
-
}],
|
|
1343
|
-
}));
|
|
1344
|
-
server.registerResource("skeleton", new ResourceTemplate("ast://skeleton/{+path}", {
|
|
1345
|
-
list: async () => {
|
|
1346
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
1347
|
-
const files = collectSourceFiles(ROOT, opts);
|
|
1348
|
-
return {
|
|
1349
|
-
resources: files.map((f) => {
|
|
1350
|
-
const rel = path.relative(ROOT, f).split(path.sep).join("/");
|
|
1351
|
-
return { uri: `ast://skeleton/${rel}`, name: rel, mimeType: "application/json" };
|
|
1352
|
-
}),
|
|
1353
|
-
};
|
|
1354
|
-
},
|
|
1355
|
-
}), {
|
|
1356
|
-
title: "File skeleton",
|
|
1357
|
-
description: "Normalized code skeleton (symbols, imports, ranges) for one source file.",
|
|
1358
|
-
mimeType: "application/json",
|
|
1359
|
-
}, async (uri, variables) => {
|
|
1360
|
-
const rel = decodeURIComponent(String(variables.path)).split(path.sep).join("/");
|
|
1361
|
-
const { abs, rel: safeRel } = resolveInRoot(rel);
|
|
1362
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
1363
|
-
const skel = await buildSkeleton(abs, safeRel.split(path.sep).join("/"), opts);
|
|
1364
|
-
return {
|
|
1365
|
-
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(skel, null, 2) }],
|
|
1366
|
-
};
|
|
1367
|
-
});
|
|
1368
|
-
server.registerResource("graph", "ast://graph", {
|
|
1369
|
-
title: "Symbol dependency graph",
|
|
1370
|
-
description: "Symbol-level dependency graph for the whole root (guarded by node count).",
|
|
1371
|
-
mimeType: "application/json",
|
|
1372
|
-
}, async (uri) => {
|
|
1373
|
-
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
1374
|
-
const files = collectSourceFiles(ROOT, opts);
|
|
1375
|
-
if (files.length > 1500) {
|
|
1376
|
-
return {
|
|
1377
|
-
contents: [{
|
|
1378
|
-
uri: uri.href,
|
|
1379
|
-
mimeType: "application/json",
|
|
1380
|
-
text: JSON.stringify({ note: `Too large to inline (${files.length} files). Use build_symbol_graph on a subdirectory.`, files: files.length }, null, 2),
|
|
1381
|
-
}],
|
|
1382
|
-
};
|
|
1383
|
-
}
|
|
1384
|
-
const skels = [];
|
|
1385
|
-
for (const file of files) {
|
|
1386
|
-
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
1387
|
-
try {
|
|
1388
|
-
skels.push(await buildSkeleton(file, fileRel, opts));
|
|
1389
|
-
}
|
|
1390
|
-
catch { /* skip */ }
|
|
1391
|
-
}
|
|
1392
|
-
const graph = buildSymbolGraph(skels, ROOT);
|
|
1393
|
-
return {
|
|
1394
|
-
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(graph, null, 2) }],
|
|
1395
|
-
};
|
|
1396
|
-
});
|
|
1397
|
-
async function main() {
|
|
1398
|
-
const transport = new StdioServerTransport();
|
|
1399
|
-
await server.connect(transport);
|
|
1400
|
-
// stderr is safe for logging; stdout is reserved for the MCP protocol.
|
|
1401
|
-
process.stderr.write(`universal-ast-mapper running. roots=${ROOTS.roots.join(path.delimiter)}` +
|
|
1402
|
-
(ROOTS.unlocked ? " (UNLOCKED: any absolute path allowed)" : "") + "\n");
|
|
1403
|
-
}
|
|
1404
|
-
main().catch((err) => {
|
|
1405
|
-
process.stderr.write(`Fatal: ${err instanceof Error ? err.stack : String(err)}\n`);
|
|
1406
|
-
process.exit(1);
|
|
1407
|
-
});
|