universal-ast-mapper 2.0.0 → 2.0.1

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +261 -12
  3. package/dist/ai-refactor.js +185 -0
  4. package/dist/ai-testgen.js +105 -0
  5. package/dist/analysis.js +134 -0
  6. package/dist/arch-rules.js +82 -0
  7. package/dist/callgraph.js +467 -0
  8. package/dist/check.js +112 -0
  9. package/dist/cli.js +2284 -0
  10. package/dist/complexity.js +98 -0
  11. package/dist/config.js +53 -0
  12. package/dist/contextpack.js +79 -0
  13. package/dist/coupling.js +35 -0
  14. package/dist/covmerge.js +176 -0
  15. package/dist/crosslang.js +425 -0
  16. package/dist/dashboard.js +259 -0
  17. package/dist/diagram.js +264 -0
  18. package/dist/diskcache.js +97 -0
  19. package/dist/docgen.js +156 -0
  20. package/dist/embeddings.js +136 -0
  21. package/dist/explain.js +123 -0
  22. package/dist/explorer.js +123 -0
  23. package/dist/extractors/c.js +204 -0
  24. package/dist/extractors/common.js +56 -0
  25. package/dist/extractors/cpp.js +272 -0
  26. package/dist/extractors/csharp.js +209 -0
  27. package/dist/extractors/go.js +212 -0
  28. package/dist/extractors/java.js +152 -0
  29. package/dist/extractors/kotlin.js +159 -0
  30. package/dist/extractors/php.js +208 -0
  31. package/dist/extractors/python.js +153 -0
  32. package/dist/extractors/ruby.js +146 -0
  33. package/dist/extractors/rust.js +249 -0
  34. package/dist/extractors/swift.js +192 -0
  35. package/dist/extractors/typescript.js +577 -0
  36. package/dist/fix.js +92 -0
  37. package/dist/gitdiff.js +178 -0
  38. package/dist/graph-analysis.js +279 -0
  39. package/dist/graph.js +165 -0
  40. package/dist/history.js +36 -0
  41. package/dist/html.js +658 -0
  42. package/dist/incremental.js +122 -0
  43. package/dist/index.js +1945 -0
  44. package/dist/indexstore.js +105 -0
  45. package/dist/layers.js +36 -0
  46. package/dist/lsp.js +238 -0
  47. package/dist/modulecoupling.js +0 -0
  48. package/dist/parser.js +84 -0
  49. package/dist/patch.js +199 -0
  50. package/dist/plugins.js +88 -0
  51. package/dist/pool.js +114 -0
  52. package/dist/prompts.js +67 -0
  53. package/dist/registry.js +87 -0
  54. package/dist/report.js +441 -0
  55. package/dist/resolver.js +222 -0
  56. package/dist/roots.js +47 -0
  57. package/dist/search.js +68 -0
  58. package/dist/security.js +178 -0
  59. package/dist/semantic.js +365 -0
  60. package/dist/serve.js +185 -0
  61. package/dist/sfc.js +27 -0
  62. package/dist/similar.js +98 -0
  63. package/dist/skeleton.js +132 -0
  64. package/dist/smells.js +285 -0
  65. package/dist/sourcemap.js +60 -0
  66. package/dist/testgen.js +280 -0
  67. package/dist/testmap.js +167 -0
  68. package/dist/tsconfig.js +212 -0
  69. package/dist/typeflow.js +124 -0
  70. package/dist/types.js +5 -0
  71. package/dist/unused-params.js +127 -0
  72. package/dist/webapp.js +341 -0
  73. package/dist/worker.js +27 -0
  74. package/dist/workspace.js +330 -0
  75. package/package.json +2 -1
package/dist/index.js ADDED
@@ -0,0 +1,1945 @@
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 { detectSmells } from "./smells.js";
36
+ import { scanFileForSecurityIssues } from "./security.js";
37
+ import { buildClassDiagram, buildDepsDiagram, buildModulesDiagram } from "./diagram.js";
38
+ import { buildFixSuggestions } from "./fix.js";
39
+ import { generateTestFile, detectTestFramework } from "./testgen.js";
40
+ import { tryAiEnhanceTests } from "./ai-testgen.js";
41
+ import { aiRefactorBatch, readSource } from "./ai-refactor.js";
42
+ import { buildExplainResult, aiExplain } from "./explain.js";
43
+ import { findSimilar } from "./similar.js";
44
+ import { mergeCoverage } from "./covmerge.js";
45
+ import { loadPlugins, runPlugins } from "./plugins.js";
46
+ import { buildIndex, loadIndex, getSkeletons as getIndexSkeletons, isIndexFresh } from "./indexstore.js";
47
+ import { checkArchRules, loadArchRules } from "./arch-rules.js";
48
+ import { buildDocOutput, renderMarkdown, renderDocHtml, aiEnhanceDocs } from "./docgen.js";
49
+ import { parseRootsFromEnv, resolvePathInRoots } from "./roots.js";
50
+ /**
51
+ * Security boundary. AST_MAP_ROOT may list several roots (path-delimiter
52
+ * separated); AST_MAP_UNLOCKED=1 allows any absolute path. The first root is
53
+ * the primary — relative inputs resolve against it.
54
+ */
55
+ const ROOTS = parseRootsFromEnv();
56
+ const ROOT = ROOTS.roots[0];
57
+ // Persistent parse cache (disable with AST_MAP_NO_CACHE=1 or "cache": false in config).
58
+ if (process.env.AST_MAP_NO_CACHE !== "1" && loadProjectConfig(ROOT).cache !== false) {
59
+ initDiskCache(defaultCacheDir(ROOT));
60
+ }
61
+ function resolveInRoot(input) {
62
+ return resolvePathInRoots(input, ROOTS);
63
+ }
64
+ function htmlPathFor(rel, opts) {
65
+ const outDir = opts.outputDir ? path.resolve(ROOT, opts.outputDir) : path.join(ROOT, ".ast-map");
66
+ return path.join(outDir, `${rel}-skeleton.html`);
67
+ }
68
+ function writeHtml(skel, rel, opts) {
69
+ const target = htmlPathFor(rel, opts);
70
+ fs.mkdirSync(path.dirname(target), { recursive: true });
71
+ fs.writeFileSync(target, renderHtml(skel), "utf8");
72
+ return target;
73
+ }
74
+ function jsonText(value) {
75
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
76
+ }
77
+ function errorText(message) {
78
+ return {
79
+ isError: true,
80
+ content: [{ type: "text", text: message }],
81
+ };
82
+ }
83
+ /** Read the package version at runtime so it never drifts from package.json. */
84
+ const PKG_VERSION = (() => {
85
+ try {
86
+ const dir = path.dirname(fileURLToPath(import.meta.url));
87
+ return JSON.parse(fs.readFileSync(path.join(dir, "..", "package.json"), "utf8")).version;
88
+ }
89
+ catch {
90
+ return "0.0.0";
91
+ }
92
+ })();
93
+ const server = new McpServer({
94
+ name: "universal-ast-mapper",
95
+ version: PKG_VERSION,
96
+ });
97
+ registerPrompts(server);
98
+ /* ----------------------- tool: list_supported_languages ----------------------- */
99
+ server.registerTool("list_supported_languages", {
100
+ title: "List supported languages",
101
+ description: "Returns the languages and file extensions this server can map into a code skeleton.",
102
+ inputSchema: {},
103
+ }, async () => jsonText({ root: ROOT, languages: supportedLanguages() }));
104
+ /* --------------------------- tool: get_skeleton_json -------------------------- */
105
+ server.registerTool("get_skeleton_json", {
106
+ title: "Get code skeleton (JSON only)",
107
+ description: "Parse a single source file and return its normalized skeleton as JSON. " +
108
+ "Does NOT write an HTML file. Use this when you only need the structure for reasoning.",
109
+ inputSchema: {
110
+ path: z.string().describe("File path, relative to the project root or absolute within it."),
111
+ detail: z
112
+ .enum(["outline", "full"])
113
+ .optional()
114
+ .describe('"outline" (default) = names+kinds+ranges; "full" adds signatures and docs.'),
115
+ },
116
+ }, async ({ path: input, detail }) => {
117
+ try {
118
+ const { abs, rel, root } = resolveInRoot(input);
119
+ if (fs.statSync(abs).isDirectory()) {
120
+ return errorText(`"${input}" is a directory. Use generate_skeleton for directories.`);
121
+ }
122
+ const opts = resolveOptions({ detail, emitHtml: false });
123
+ const skel = await buildSkeleton(abs, rel, opts);
124
+ return jsonText(skel);
125
+ }
126
+ catch (err) {
127
+ return errorText(describeError(err));
128
+ }
129
+ });
130
+ /* --------------------------- tool: generate_skeleton -------------------------- */
131
+ server.registerTool("generate_skeleton", {
132
+ title: "Generate code skeleton (JSON + HTML)",
133
+ description: "Map a source FILE or DIRECTORY into a normalized code skeleton. Returns compact JSON " +
134
+ "for the agent and writes a self-contained collapsible HTML view per file (under " +
135
+ "<root>/.ast-map by default). For a single file the full skeleton is returned inline; " +
136
+ "for a directory a summary with per-file HTML paths is returned.",
137
+ inputSchema: {
138
+ path: z.string().describe("File or directory path, relative to the project root or absolute within it."),
139
+ detail: z.enum(["outline", "full"]).optional().describe('Default "outline".'),
140
+ emitHtml: z.boolean().optional().describe("Write per-file HTML views. Default true."),
141
+ combineHtml: z
142
+ .boolean()
143
+ .optional()
144
+ .describe("Merge all per-file skeletons into a single <outputDir>/index.html with a sidebar, " +
145
+ "search, and collapsible sections. Only applies to directory scans. Default false."),
146
+ outputDir: z
147
+ .string()
148
+ .optional()
149
+ .describe("Directory for HTML output, relative to root. Default '.ast-map'."),
150
+ },
151
+ }, async ({ path: input, detail, emitHtml, combineHtml, outputDir }) => {
152
+ try {
153
+ const opts = resolveOptions({ detail, emitHtml, combineHtml, outputDir });
154
+ const { abs, rel, root } = resolveInRoot(input);
155
+ const stat = fs.statSync(abs);
156
+ if (stat.isDirectory()) {
157
+ const files = collectSourceFiles(abs, opts);
158
+ const results = [];
159
+ const successSkeletons = [];
160
+ let totalSymbols = 0;
161
+ const items = files.map((file) => ({
162
+ abs: file,
163
+ rel: path.relative(root, file).split(path.sep).join("/"),
164
+ }));
165
+ const built = await buildSkeletonsBulk(items, opts);
166
+ for (let i = 0; i < built.length; i++) {
167
+ const r = built[i];
168
+ if (r) {
169
+ const skel = r.skel;
170
+ totalSymbols += skel.symbolCount;
171
+ const htmlPath = opts.emitHtml ? writeHtml(skel, items[i].rel, opts) : null;
172
+ successSkeletons.push(skel);
173
+ results.push({
174
+ file: skel.file,
175
+ language: skel.language,
176
+ symbolCount: skel.symbolCount,
177
+ htmlPath,
178
+ });
179
+ }
180
+ else {
181
+ results.push({ file: items[i].rel, error: "parse failed or unsupported file type" });
182
+ }
183
+ }
184
+ let combinedHtmlPath = null;
185
+ if (opts.combineHtml && successSkeletons.length > 0) {
186
+ const outDir = opts.outputDir
187
+ ? path.resolve(root, opts.outputDir)
188
+ : path.join(root, ".ast-map");
189
+ fs.mkdirSync(outDir, { recursive: true });
190
+ combinedHtmlPath = path.join(outDir, "index.html");
191
+ fs.writeFileSync(combinedHtmlPath, renderCombinedHtml(successSkeletons), "utf8");
192
+ }
193
+ return jsonText({
194
+ mode: "directory",
195
+ root: root,
196
+ directory: rel.split(path.sep).join("/"),
197
+ fileCount: files.length,
198
+ totalSymbols,
199
+ combinedHtmlPath,
200
+ results,
201
+ });
202
+ }
203
+ // single file
204
+ const skel = await buildSkeleton(abs, rel, opts);
205
+ const htmlPath = opts.emitHtml ? writeHtml(skel, rel, opts) : null;
206
+ return jsonText({ mode: "file", htmlPath, skeleton: skel });
207
+ }
208
+ catch (err) {
209
+ return errorText(describeError(err));
210
+ }
211
+ });
212
+ /* ─────────────────── tool: get_symbol_context ─────────────────────────────── */
213
+ server.registerTool("get_symbol_context", {
214
+ title: "Get symbol source context",
215
+ description: "Extract the exact source lines of a specific named symbol (function, class, interface, etc.) " +
216
+ "from a file. Returns the raw code block — ideal for focused AI refactoring without sending the " +
217
+ "whole file. Token-efficient: a 300-line file becomes ~40 lines of relevant code. " +
218
+ "Use includeRelated=true to also receive related types/interfaces referenced in the symbol's signature.",
219
+ inputSchema: {
220
+ path: z.string().describe("File path, relative to the project root or absolute within it."),
221
+ symbol: z.string().describe("Name of the symbol to extract (function/class/interface/type name)."),
222
+ kind: z
223
+ .enum(["function", "class", "interface", "type", "method", "const", "var", "enum"])
224
+ .optional()
225
+ .describe("Narrow by kind when multiple symbols share the same name."),
226
+ includeRelated: z
227
+ .boolean()
228
+ .optional()
229
+ .describe("Also return related types/interfaces referenced in the symbol's signature. Default false."),
230
+ },
231
+ }, async ({ path: input, symbol, kind, includeRelated }) => {
232
+ try {
233
+ const { abs, rel, root } = resolveInRoot(input);
234
+ if (fs.statSync(abs).isDirectory()) {
235
+ return errorText(`"${input}" is a directory. Provide a single file path.`);
236
+ }
237
+ const source = fs.readFileSync(abs, "utf8");
238
+ const sourceLines = source.split("\n");
239
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
240
+ const skel = await buildSkeleton(abs, rel, opts);
241
+ const found = findSymbol(skel.symbols, symbol, kind);
242
+ if (!found) {
243
+ const available = skel.symbols.map((s) => `${s.name} (${s.kind})`).join(", ");
244
+ return errorText(`Symbol "${symbol}" not found in ${rel}. Top-level symbols: ${available || "(none)"}`);
245
+ }
246
+ const code = sourceLines.slice(found.range.startLine - 1, found.range.endLine).join("\n");
247
+ const result = {
248
+ file: rel,
249
+ symbol: found.name,
250
+ kind: found.kind,
251
+ range: found.range,
252
+ lines: found.range.endLine - found.range.startLine + 1,
253
+ code,
254
+ };
255
+ if (includeRelated) {
256
+ const related = findRelatedSymbols(skel.symbols, found, sourceLines);
257
+ if (related.length > 0)
258
+ result.related = related;
259
+ }
260
+ return jsonText(result);
261
+ }
262
+ catch (err) {
263
+ return errorText(describeError(err));
264
+ }
265
+ });
266
+ /* ───────────────── tool: validate_architecture ─────────────────────────────── */
267
+ server.registerTool("validate_architecture", {
268
+ title: "Validate architecture — Next.js + general rules",
269
+ description: "Scan files for architecture violations. Two rule sets run together:\n\n" +
270
+ "Next.js App Router rules:\n" +
271
+ " (1) client-server-boundary — 'use client' components importing server-only modules.\n" +
272
+ " (2) api-missing-try-catch — API route handlers with no try/catch.\n\n" +
273
+ "General rules (any project):\n" +
274
+ " (3) large-file — files exceeding maxLines (default 500).\n" +
275
+ " (4) too-many-imports — files with more than maxImports imports (default 15).\n" +
276
+ " (5) god-export — files exporting more than maxExports symbols (default 10).\n\n" +
277
+ "Thresholds can be overridden per-call or set globally in .ast-map.config.json.",
278
+ inputSchema: {
279
+ path: z
280
+ .string()
281
+ .describe("File or directory to scan (relative to root or absolute within it). Use '.' to scan the whole project."),
282
+ maxLines: z.number().int().optional().describe("Override large-file threshold (default 500)."),
283
+ maxImports: z.number().int().optional().describe("Override too-many-imports threshold (default 15)."),
284
+ maxExports: z.number().int().optional().describe("Override god-export threshold (default 10)."),
285
+ },
286
+ }, async ({ path: input, maxLines, maxImports, maxExports }) => {
287
+ try {
288
+ const { abs, root } = resolveInRoot(input);
289
+ const projectConfig = loadProjectConfig(root);
290
+ const opts = resolveOptions({ detail: "full", emitHtml: false }, projectConfig);
291
+ const stat = fs.statSync(abs);
292
+ const filesToCheck = stat.isDirectory()
293
+ ? collectSourceFiles(abs, opts)
294
+ : [abs];
295
+ // Merge thresholds: call param → config file → defaults
296
+ const thresholds = {
297
+ largeFileLines: maxLines ?? projectConfig.rules?.["large-file"]?.maxLines ?? GENERAL_RULE_DEFAULTS.largeFileLines,
298
+ tooManyImports: maxImports ?? projectConfig.rules?.["too-many-imports"]?.maxImports ?? GENERAL_RULE_DEFAULTS.tooManyImports,
299
+ godExportCount: maxExports ?? projectConfig.rules?.["god-export"]?.maxExports ?? GENERAL_RULE_DEFAULTS.godExportCount,
300
+ };
301
+ const violations = [];
302
+ for (const file of filesToCheck) {
303
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
304
+ let source;
305
+ try {
306
+ source = fs.readFileSync(file, "utf8");
307
+ }
308
+ catch {
309
+ continue;
310
+ }
311
+ let skel;
312
+ try {
313
+ skel = await buildSkeleton(file, fileRel, opts);
314
+ }
315
+ catch {
316
+ continue;
317
+ }
318
+ // Next.js Rule 1: "use client" boundary (AST-based, no comment false-positives)
319
+ if (skel.directives?.includes("use client")) {
320
+ for (const imp of findServerImports(source)) {
321
+ violations.push({
322
+ file: fileRel, rule: "client-server-boundary", severity: "error",
323
+ message: `"use client" file imports server-only module "${imp.label}" (${imp.module})`,
324
+ line: imp.line,
325
+ });
326
+ }
327
+ }
328
+ // Next.js Rule 2: API route try/catch
329
+ if (isApiRoute(fileRel)) {
330
+ const sourceLines = source.split("\n");
331
+ for (const sym of findMissingTryCatch(skel.symbols, sourceLines)) {
332
+ violations.push({
333
+ file: fileRel, rule: "api-missing-try-catch", severity: "warning",
334
+ message: `API handler "${sym.name}" has no try/catch`,
335
+ line: sym.range.startLine,
336
+ });
337
+ }
338
+ }
339
+ // General rules (Rules 3–5)
340
+ const importCount = skel.imports?.length ?? 0;
341
+ for (const v of checkGeneralRules(fileRel, source, skel.symbols, importCount, thresholds)) {
342
+ violations.push(v);
343
+ }
344
+ }
345
+ const errors = violations.filter((v) => v.severity === "error").length;
346
+ const warnings = violations.filter((v) => v.severity === "warning").length;
347
+ return jsonText({
348
+ scanned: filesToCheck.length,
349
+ violations: violations.length,
350
+ errors,
351
+ warnings,
352
+ thresholds,
353
+ summary: violations.length === 0
354
+ ? "✓ No architecture violations found."
355
+ : `Found ${errors} error(s) and ${warnings} warning(s).`,
356
+ results: violations,
357
+ });
358
+ }
359
+ catch (err) {
360
+ return errorText(describeError(err));
361
+ }
362
+ });
363
+ /* ─────────────────── tool: resolve_imports ────────────────────────────────── */
364
+ server.registerTool("resolve_imports", {
365
+ title: "Resolve imports to source definitions",
366
+ description: "For a given source file, resolves each import statement to its target file and symbol. " +
367
+ "Returns a Reference Object per import with: resolved path, symbol kind, one-line signature, " +
368
+ "parameter list, and whether the symbol was found. " +
369
+ "Only relative imports (starting with '.') are resolved — external packages are flagged. " +
370
+ "Use this to trace what a file depends on before refactoring or to verify API contracts.",
371
+ inputSchema: {
372
+ path: z.string().describe("File path, relative to project root or absolute within it."),
373
+ },
374
+ }, async ({ path: input }) => {
375
+ try {
376
+ const { abs, rel, root } = resolveInRoot(input);
377
+ if (fs.statSync(abs).isDirectory()) {
378
+ return errorText(`"${input}" is a directory. Provide a single file path.`);
379
+ }
380
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
381
+ const skel = await buildSkeleton(abs, rel, opts);
382
+ const resolved = await resolveFileImports(skel, abs, root);
383
+ return jsonText({
384
+ file: rel,
385
+ importCount: resolved.length,
386
+ resolved,
387
+ });
388
+ }
389
+ catch (err) {
390
+ return errorText(describeError(err));
391
+ }
392
+ });
393
+ /* ─────────────────── tool: build_symbol_graph ──────────────────────────────── */
394
+ server.registerTool("build_symbol_graph", {
395
+ title: "Build symbol-level dependency graph",
396
+ description: "Scan a directory and build a symbol-level dependency graph.\n" +
397
+ "Nodes:\n" +
398
+ " - file nodes: one per scanned source file\n" +
399
+ " - symbol nodes: one per function/class/type/etc. (id = '<file>::<Name>' or '<file>::<Class>.<method>')\n" +
400
+ "Edges:\n" +
401
+ " - 'contains': file → symbol, or parent-symbol → child-symbol (structural hierarchy)\n" +
402
+ " - 'imports': importing-file → imported-symbol-node (cross-file dependency)\n" +
403
+ "Use to trace data flow: query edges where edgeType='imports' to see what a file pulls in, " +
404
+ "or where to='src/foo.ts::myFn' to see every file that depends on that symbol.",
405
+ inputSchema: {
406
+ path: z
407
+ .string()
408
+ .describe("Directory to scan, relative to project root or absolute within it."),
409
+ detail: z
410
+ .enum(["outline", "full"])
411
+ .optional()
412
+ .describe('"outline" (default) omits signatures; "full" includes them on symbol nodes.'),
413
+ outputFile: z
414
+ .string()
415
+ .optional()
416
+ .describe("If provided, write the graph JSON to this path (relative to root) and return only stats. " +
417
+ "Recommended for large projects to avoid bloated inline responses."),
418
+ },
419
+ }, async ({ path: input, detail, outputFile }) => {
420
+ try {
421
+ const { abs, rel, root } = resolveInRoot(input);
422
+ if (!fs.statSync(abs).isDirectory()) {
423
+ return errorText(`"${input}" is not a directory. build_symbol_graph requires a directory.`);
424
+ }
425
+ const opts = resolveOptions({ detail, emitHtml: false });
426
+ const files = collectSourceFiles(abs, opts);
427
+ const skeletons = [];
428
+ const errors = [];
429
+ for (const file of files) {
430
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
431
+ try {
432
+ skeletons.push(await buildSkeleton(file, fileRel, opts));
433
+ }
434
+ catch (err) {
435
+ errors.push({ file: fileRel, error: describeError(err) });
436
+ }
437
+ }
438
+ const graph = buildSymbolGraph(skeletons, root);
439
+ if (outputFile) {
440
+ const { abs: outAbs } = resolveInRoot(outputFile);
441
+ fs.mkdirSync(path.dirname(outAbs), { recursive: true });
442
+ fs.writeFileSync(outAbs, JSON.stringify(graph, null, 2), "utf8");
443
+ return jsonText({
444
+ directory: rel,
445
+ scanned: files.length,
446
+ graphFilePath: outAbs,
447
+ stats: graph.stats,
448
+ ...(errors.length > 0 ? { errors } : {}),
449
+ });
450
+ }
451
+ // Guard against bloated inline responses for large graphs.
452
+ // 2000 nodes ≈ ~50–80 source files; beyond that inline JSON becomes unusable in an MCP context.
453
+ const INLINE_NODE_LIMIT = 2000;
454
+ if (graph.nodes.length > INLINE_NODE_LIMIT) {
455
+ return jsonText({
456
+ directory: rel,
457
+ scanned: files.length,
458
+ stats: graph.stats,
459
+ warning: `Graph has ${graph.nodes.length} nodes — too large to return inline. ` +
460
+ `Use the outputFile parameter to write it to disk, then read specific sections with get_file_deps or get_change_impact.`,
461
+ ...(errors.length > 0 ? { errors } : {}),
462
+ });
463
+ }
464
+ return jsonText({
465
+ directory: rel,
466
+ scanned: files.length,
467
+ ...(errors.length > 0 ? { errors } : {}),
468
+ graph,
469
+ });
470
+ }
471
+ catch (err) {
472
+ return errorText(describeError(err));
473
+ }
474
+ });
475
+ /* ─────────────────── tool: find_dead_code ──────────────────────────────── */
476
+ server.registerTool("find_dead_code", {
477
+ title: "Find dead (unreferenced) exports",
478
+ description: "Scan a directory, build the import graph, and return exported symbols that are never " +
479
+ "imported by any other file in the scan root. These are candidates for removal.\n" +
480
+ "Note: entry-point symbols (e.g. Next.js page exports) are technically 'dead' within " +
481
+ "the codebase graph — use your judgement before deleting them.",
482
+ inputSchema: {
483
+ path: z
484
+ .string()
485
+ .describe("Directory to scan, relative to project root or absolute within it."),
486
+ detail: z
487
+ .enum(["outline", "full"])
488
+ .optional()
489
+ .describe('"outline" (default) is sufficient for dead-code detection.'),
490
+ },
491
+ }, async ({ path: input, detail }) => {
492
+ try {
493
+ const { abs, rel, root } = resolveInRoot(input);
494
+ if (!fs.statSync(abs).isDirectory()) {
495
+ return errorText(`"${input}" is not a directory. find_dead_code requires a directory.`);
496
+ }
497
+ const opts = resolveOptions({ detail, emitHtml: false });
498
+ const files = collectSourceFiles(abs, opts);
499
+ const skeletons = [];
500
+ const errors = [];
501
+ for (const file of files) {
502
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
503
+ try {
504
+ skeletons.push(await buildSkeleton(file, fileRel, opts));
505
+ }
506
+ catch (err) {
507
+ errors.push({ file: fileRel, error: describeError(err) });
508
+ }
509
+ }
510
+ const graph = buildSymbolGraph(skeletons, root);
511
+ const dead = findDeadExports(graph);
512
+ return jsonText({
513
+ directory: rel.split(path.sep).join("/"),
514
+ scanned: files.length,
515
+ deadExportCount: dead.length,
516
+ ...(errors.length > 0 ? { errors } : {}),
517
+ deadExports: dead,
518
+ });
519
+ }
520
+ catch (err) {
521
+ return errorText(describeError(err));
522
+ }
523
+ });
524
+ /* ─────────────────── tool: find_circular_deps ──────────────────────────── */
525
+ server.registerTool("find_circular_deps", {
526
+ title: "Find circular import dependencies",
527
+ description: "Scan a directory and detect circular import chains (A → B → C → A). " +
528
+ "Each result includes the full cycle path with repeated start node at the end for clarity.",
529
+ inputSchema: {
530
+ path: z
531
+ .string()
532
+ .describe("Directory to scan, relative to project root or absolute within it."),
533
+ },
534
+ }, async ({ path: input }) => {
535
+ try {
536
+ const { abs, rel, root } = resolveInRoot(input);
537
+ if (!fs.statSync(abs).isDirectory()) {
538
+ return errorText(`"${input}" is not a directory. find_circular_deps requires a directory.`);
539
+ }
540
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
541
+ const files = collectSourceFiles(abs, opts);
542
+ const skeletons = [];
543
+ const errors = [];
544
+ for (const file of files) {
545
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
546
+ try {
547
+ skeletons.push(await buildSkeleton(file, fileRel, opts));
548
+ }
549
+ catch (err) {
550
+ errors.push({ file: fileRel, error: describeError(err) });
551
+ }
552
+ }
553
+ const graph = buildSymbolGraph(skeletons, root);
554
+ const cycles = findCircularDeps(graph);
555
+ return jsonText({
556
+ directory: rel.split(path.sep).join("/"),
557
+ scanned: files.length,
558
+ cycleCount: cycles.length,
559
+ ...(errors.length > 0 ? { errors } : {}),
560
+ cycles,
561
+ });
562
+ }
563
+ catch (err) {
564
+ return errorText(describeError(err));
565
+ }
566
+ });
567
+ /* ─────────────────── tool: find_duplicate_symbols ──────────────────────── */
568
+ server.registerTool("find_duplicate_symbols", {
569
+ title: "Find duplicate exported symbols",
570
+ description: "Scan a directory and return symbol names that are exported from more than one file. " +
571
+ "These are often accidental collisions (copy-paste, parallel implementations) that make " +
572
+ "a codebase harder to navigate. Each result lists every file/kind that declares the name.",
573
+ inputSchema: {
574
+ path: z
575
+ .string()
576
+ .describe("Directory to scan, relative to project root or absolute within it."),
577
+ },
578
+ }, async ({ path: input }) => {
579
+ try {
580
+ const { abs, rel, root } = resolveInRoot(input);
581
+ if (!fs.statSync(abs).isDirectory()) {
582
+ return errorText(`"${input}" is not a directory. find_duplicate_symbols requires a directory.`);
583
+ }
584
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
585
+ const files = collectSourceFiles(abs, opts);
586
+ const skeletons = [];
587
+ const errors = [];
588
+ for (const file of files) {
589
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
590
+ try {
591
+ skeletons.push(await buildSkeleton(file, fileRel, opts));
592
+ }
593
+ catch (err) {
594
+ errors.push({ file: fileRel, error: describeError(err) });
595
+ }
596
+ }
597
+ const graph = buildSymbolGraph(skeletons, root);
598
+ const duplicates = findDuplicateSymbols(graph);
599
+ return jsonText({
600
+ directory: rel.split(path.sep).join("/"),
601
+ scanned: files.length,
602
+ duplicateCount: duplicates.length,
603
+ ...(errors.length > 0 ? { errors } : {}),
604
+ duplicates,
605
+ });
606
+ }
607
+ catch (err) {
608
+ return errorText(describeError(err));
609
+ }
610
+ });
611
+ /* ─────────────────── tool: get_complexity ──────────────────────────────── */
612
+ server.registerTool("get_complexity", {
613
+ title: "Get cyclomatic complexity per function",
614
+ description: "Compute AST-based cyclomatic complexity for every function/method in a FILE or DIRECTORY. " +
615
+ "Each function gets a score (1 + decision points: if / for / while / case / catch / ternary / && / ||) " +
616
+ "and a rating (low <=5, moderate <=10, high <=20, very-high >20). For a directory, returns per-file " +
617
+ "results plus the highest-complexity hotspots across the scan.",
618
+ inputSchema: {
619
+ path: z.string().describe("File or directory, relative to project root or absolute within it."),
620
+ },
621
+ }, async ({ path: input }) => {
622
+ try {
623
+ const { abs, rel, root } = resolveInRoot(input);
624
+ const stat = fs.statSync(abs);
625
+ if (stat.isDirectory()) {
626
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
627
+ const files = collectSourceFiles(abs, opts);
628
+ const results = [];
629
+ const errors = [];
630
+ for (const file of files) {
631
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
632
+ try {
633
+ const fc = await computeFileComplexity(file, fileRel);
634
+ if (fc)
635
+ results.push(fc);
636
+ }
637
+ catch (err) {
638
+ errors.push({ file: fileRel, error: describeError(err) });
639
+ }
640
+ }
641
+ const hotspots = results
642
+ .flatMap((r) => r.functions.map((f) => ({ file: r.file, ...f })))
643
+ .sort((a, b) => b.complexity - a.complexity)
644
+ .slice(0, 15);
645
+ return jsonText({
646
+ directory: rel.split(path.sep).join("/"),
647
+ scanned: files.length,
648
+ ...(errors.length > 0 ? { errors } : {}),
649
+ hotspots,
650
+ files: results,
651
+ });
652
+ }
653
+ const fc = await computeFileComplexity(abs, rel.split(path.sep).join("/"));
654
+ if (!fc)
655
+ return errorText(`Unsupported file type: ${input}`);
656
+ return jsonText(fc);
657
+ }
658
+ catch (err) {
659
+ return errorText(describeError(err));
660
+ }
661
+ });
662
+ /* ─────────────────── tool: find_unused_params ──────────────────────────── */
663
+ server.registerTool("find_unused_params", {
664
+ title: "Find unused function parameters",
665
+ description: "Scan a FILE or DIRECTORY for named functions/methods that declare parameters never " +
666
+ "referenced in their body. Skips `_`-prefixed params (conventionally intentional), " +
667
+ "anonymous callbacks, and destructured bindings to avoid false positives.",
668
+ inputSchema: {
669
+ path: z.string().describe("File or directory, relative to project root or absolute within it."),
670
+ },
671
+ }, async ({ path: input }) => {
672
+ try {
673
+ const { abs, rel, root } = resolveInRoot(input);
674
+ const stat = fs.statSync(abs);
675
+ if (stat.isDirectory()) {
676
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
677
+ const files = collectSourceFiles(abs, opts);
678
+ const results = [];
679
+ const errors = [];
680
+ for (const file of files) {
681
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
682
+ try {
683
+ const r = await findUnusedParams(file, fileRel);
684
+ if (r && r.functions.length > 0)
685
+ results.push(r);
686
+ }
687
+ catch (err) {
688
+ errors.push({ file: fileRel, error: describeError(err) });
689
+ }
690
+ }
691
+ const unusedParamCount = results.reduce((sum, r) => sum + r.functions.reduce((a, f) => a + f.unused.length, 0), 0);
692
+ return jsonText({
693
+ directory: rel.split(path.sep).join("/"),
694
+ scanned: files.length,
695
+ ...(errors.length > 0 ? { errors } : {}),
696
+ unusedParamCount,
697
+ files: results,
698
+ });
699
+ }
700
+ const r = await findUnusedParams(abs, rel.split(path.sep).join("/"));
701
+ if (!r)
702
+ return errorText(`Unsupported file type: ${input}`);
703
+ return jsonText(r);
704
+ }
705
+ catch (err) {
706
+ return errorText(describeError(err));
707
+ }
708
+ });
709
+ /* ─────────────────── tool: trace_type ──────────────────────────────────── */
710
+ server.registerTool("trace_type", {
711
+ title: "Trace a type through the code",
712
+ description: "Find everywhere a named type flows through a directory: function parameters and return " +
713
+ "types, typed variables, and class fields. A scoped, AST-based type-flow view (best for " +
714
+ "TS/Python) \u2014 no full type inference, so it tracks where the type is *named* in signatures.",
715
+ inputSchema: {
716
+ type: z.string().describe('Type name to trace, e.g. "Inventory".'),
717
+ path: z.string().describe("Directory to scan, relative to project root or absolute within it."),
718
+ },
719
+ }, async ({ type: typeName, path: input }) => {
720
+ try {
721
+ const { abs, rel, root } = resolveInRoot(input);
722
+ if (!fs.statSync(abs).isDirectory()) {
723
+ return errorText(`"${input}" is not a directory. trace_type requires a directory.`);
724
+ }
725
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
726
+ const files = collectSourceFiles(abs, opts);
727
+ const refs = [];
728
+ const errors = [];
729
+ for (const file of files) {
730
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
731
+ try {
732
+ refs.push(...(await traceTypeInFile(file, fileRel, typeName)));
733
+ }
734
+ catch (err) {
735
+ errors.push({ file: fileRel, error: describeError(err) });
736
+ }
737
+ }
738
+ const byRole = { param: 0, return: 0, variable: 0, field: 0 };
739
+ for (const r of refs)
740
+ byRole[r.role]++;
741
+ return jsonText({
742
+ type: typeName,
743
+ directory: rel.split(path.sep).join("/"),
744
+ scanned: files.length,
745
+ refCount: refs.length,
746
+ byRole,
747
+ ...(errors.length > 0 ? { errors } : {}),
748
+ refs,
749
+ });
750
+ }
751
+ catch (err) {
752
+ return errorText(describeError(err));
753
+ }
754
+ });
755
+ /* ─────────────────── tool: analyze_workspace ───────────────────────────── */
756
+ server.registerTool("analyze_workspace", {
757
+ title: "Analyze a monorepo workspace",
758
+ description: "Discover the packages in a JS/TS monorepo (npm/yarn `workspaces`, pnpm-workspace.yaml, or " +
759
+ "lerna.json) and the dependency edges between them. Returns each package's name, directory, " +
760
+ "and workspace-internal dependencies, plus any circular dependencies between packages.",
761
+ inputSchema: {
762
+ path: z.string().optional().describe("Workspace root directory. Defaults to the project root."),
763
+ },
764
+ }, async ({ path: input }) => {
765
+ try {
766
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
767
+ if (!fs.statSync(abs).isDirectory()) {
768
+ return errorText(`"${input}" is not a directory. analyze_workspace requires a directory.`);
769
+ }
770
+ const info = discoverWorkspace(abs);
771
+ const cycles = findPackageCycles(info);
772
+ return jsonText({
773
+ root: rel.split(path.sep).join("/") || ".",
774
+ tool: info.tool,
775
+ packageCount: info.packages.length,
776
+ packages: info.packages,
777
+ edges: info.edges,
778
+ packageCycles: cycles,
779
+ });
780
+ }
781
+ catch (err) {
782
+ return errorText(describeError(err));
783
+ }
784
+ });
785
+ /* ─────────────────── tool: read_source_map ─────────────────────────────── */
786
+ server.registerTool("read_source_map", {
787
+ title: "Read a compiled file's source map",
788
+ description: "Given a compiled JS/CSS file with an inline (`data:`) or external `sourceMappingURL`, " +
789
+ "return the original source paths it maps back to. Useful for tracing built output in " +
790
+ "dist/ back to the real source files.",
791
+ inputSchema: {
792
+ path: z.string().describe("Compiled file path, relative to project root or absolute within it."),
793
+ },
794
+ }, async ({ path: input }) => {
795
+ try {
796
+ const { abs, rel, root } = resolveInRoot(input);
797
+ const info = readSourceMap(abs, rel.split(path.sep).join("/"));
798
+ if (!info)
799
+ return errorText(`No source map found for "${input}".`);
800
+ return jsonText(info);
801
+ }
802
+ catch (err) {
803
+ return errorText(describeError(err));
804
+ }
805
+ });
806
+ /* ─────────────────── tool: get_codebase_report ─────────────────────────── */
807
+ server.registerTool("get_codebase_report", {
808
+ title: "Codebase health report",
809
+ description: "Scan a directory and return a one-shot health summary: file/symbol counts, language " +
810
+ "breakdown, a health grade (A\u2013F) and score, complexity hotspots, god nodes (most-imported " +
811
+ "symbols), dead exports, circular dependencies, module coupling, SDP violations, and structural " +
812
+ "test coverage (untested sources ranked by risk). The `ast-map report` CLI renders this as HTML.",
813
+ inputSchema: {
814
+ path: z.string().optional().describe("Directory to scan. Defaults to the project root."),
815
+ },
816
+ }, async ({ path: input }) => {
817
+ try {
818
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
819
+ if (!fs.statSync(abs).isDirectory()) {
820
+ return errorText(`"${input}" is not a directory. get_codebase_report requires a directory.`);
821
+ }
822
+ const data = await buildReport(abs, root);
823
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...data });
824
+ }
825
+ catch (err) {
826
+ return errorText(describeError(err));
827
+ }
828
+ });
829
+ /* ─────────────────── tool: check_quality_gate ──────────────────────────── */
830
+ server.registerTool("check_quality_gate", {
831
+ title: "Quality gate (thresholds + baseline ratchet)",
832
+ description: "Run the CI quality gate over a directory: evaluates absolute thresholds (from " +
833
+ "`.ast-map.config.json` \u2192 `check`) and a **baseline ratchet** against " +
834
+ "`.ast-map.baseline.json` \u2014 fails when cycles, dead exports, SDP violations, " +
835
+ "very-high-complexity functions, or the health score regress. " +
836
+ "Set updateBaseline to re-anchor the baseline at the current metrics.",
837
+ inputSchema: {
838
+ path: z.string().optional().describe("Directory to gate. Defaults to the project root."),
839
+ baseline: z.string().optional().describe("Baseline file path. Default .ast-map.baseline.json."),
840
+ updateBaseline: z.boolean().optional().describe("Write current metrics as the new baseline."),
841
+ },
842
+ }, async ({ path: input, baseline, updateBaseline }) => {
843
+ try {
844
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
845
+ if (!fs.statSync(abs).isDirectory()) {
846
+ return errorText(`"${input}" is not a directory. check_quality_gate requires a directory.`);
847
+ }
848
+ const thresholds = loadProjectConfig(root).check;
849
+ const result = await runQualityGate(abs, root, {
850
+ baselinePath: baseline,
851
+ thresholds,
852
+ updateBaseline,
853
+ });
854
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...result });
855
+ }
856
+ catch (err) {
857
+ return errorText(describeError(err));
858
+ }
859
+ });
860
+ /* ─────────────────── tool: get_diff ────────────────────────────────────── */
861
+ server.registerTool("get_diff", {
862
+ title: "Git-aware change diff + blast radius",
863
+ description: "Compare the working tree against a git ref (default HEAD) and return which symbols were " +
864
+ "added/removed/modified per file, which changes are potentially **breaking** (removed or " +
865
+ "signature-changed exports), and the **blast radius** \u2014 files that depend on those breaking changes.",
866
+ inputSchema: {
867
+ base: z.string().optional().describe("Git ref to compare against. Default HEAD."),
868
+ path: z.string().optional().describe("Limit to a subdirectory. Default project root."),
869
+ },
870
+ }, async ({ base, path: input }) => {
871
+ try {
872
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
873
+ if (!isGitRepo(root))
874
+ return errorText("Not a git repository (or git is unavailable).");
875
+ const data = await computeDiff(abs, root, base ?? "HEAD");
876
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...data });
877
+ }
878
+ catch (err) {
879
+ return errorText(describeError(err));
880
+ }
881
+ });
882
+ /* ─────────────────── tool: get_risk_map ────────────────────────────────── */
883
+ server.registerTool("get_risk_map", {
884
+ title: "Refactor risk map (churn \u00d7 complexity)",
885
+ description: "Rank files by refactor risk = git churn (number of commits touching the file) \u00d7 the file's " +
886
+ "max function complexity. Surfaces the files that are both frequently changed and complex \u2014 " +
887
+ "the most valuable refactor / test targets.",
888
+ inputSchema: {
889
+ path: z.string().optional().describe("Directory to scan. Default project root."),
890
+ },
891
+ }, async ({ path: input }) => {
892
+ try {
893
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
894
+ if (!isGitRepo(root))
895
+ return errorText("Not a git repository (or git is unavailable).");
896
+ const files = await computeRisk(abs, root);
897
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: files.length, files: files.slice(0, 50) });
898
+ }
899
+ catch (err) {
900
+ return errorText(describeError(err));
901
+ }
902
+ });
903
+ /* ─────────────────── tool: pack_context ────────────────────────────────── */
904
+ server.registerTool("pack_context", {
905
+ title: "Minimal context pack for a symbol",
906
+ description: "Assemble the *minimal* context needed to understand or change a symbol \u2014 the symbol's own " +
907
+ "source, the signatures of what it depends on (resolved imports), and the files that depend on " +
908
+ "it \u2014 instead of reading whole files. Returns a token estimate so you can see the savings.",
909
+ inputSchema: {
910
+ path: z.string().describe("File containing the symbol (relative to root or absolute within it)."),
911
+ symbol: z.string().optional().describe("Symbol name to centre the pack on. Omit for the whole file."),
912
+ scan: z.string().optional().describe("Directory to scan for dependents. Default: project root."),
913
+ },
914
+ }, async ({ path: input, symbol, scan }) => {
915
+ try {
916
+ const { abs, rel, root } = resolveInRoot(input);
917
+ if (fs.statSync(abs).isDirectory())
918
+ return errorText(`"${input}" is a directory; pass a file.`);
919
+ const scanAbs = scan ? resolveInRoot(scan).abs : root;
920
+ const pack = await packContext(abs, rel.split(path.sep).join("/"), root, symbol, scanAbs);
921
+ return jsonText(pack);
922
+ }
923
+ catch (err) {
924
+ return errorText(describeError(err));
925
+ }
926
+ });
927
+ /* ─────────────────── tool: get_coupling ────────────────────────────────── */
928
+ server.registerTool("get_coupling", {
929
+ title: "Coupling metrics (afferent / efferent / instability)",
930
+ description: "Compute Robert C. Martin's coupling metrics per file from the import graph: afferent coupling " +
931
+ "(Ca, fan-in), efferent coupling (Ce, fan-out), and instability I = Ce/(Ca+Ce) (0 = stable, " +
932
+ "1 = unstable). High-Ca files are load-bearing; high-instability files change freely.",
933
+ inputSchema: {
934
+ path: z.string().optional().describe("Directory to scan. Default project root."),
935
+ },
936
+ }, async ({ path: input }) => {
937
+ try {
938
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
939
+ if (!fs.statSync(abs).isDirectory()) {
940
+ return errorText(`"${input}" is not a directory. get_coupling requires a directory.`);
941
+ }
942
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
943
+ const files = collectSourceFiles(abs, opts);
944
+ const skels = [];
945
+ for (const f of files) {
946
+ const r = path.relative(root, f).split(path.sep).join("/");
947
+ try {
948
+ skels.push(await buildSkeleton(f, r, opts));
949
+ }
950
+ catch { /* skip */ }
951
+ }
952
+ const metrics = computeCoupling(buildSymbolGraph(skels, root));
953
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: metrics.length, files: metrics });
954
+ }
955
+ catch (err) {
956
+ return errorText(describeError(err));
957
+ }
958
+ });
959
+ /* ─────────────────── tool: get_layer_violations ────────────────────────── */
960
+ server.registerTool("get_layer_violations", {
961
+ title: "Layer violations (Stable Dependencies Principle)",
962
+ description: "Find dependencies that point the wrong way on the stability gradient: a stable file " +
963
+ "(low instability) that imports a more volatile file (high instability). Per Robert C. Martin's " +
964
+ "Stable Dependencies Principle, stable code should not depend on volatile code — it gets dragged " +
965
+ "along every time the volatile file churns. Results are sorted by severity (the instability gap).",
966
+ inputSchema: {
967
+ path: z.string().optional().describe("Directory to scan. Default project root."),
968
+ minGap: z.number().optional().describe("Only report violations whose instability gap exceeds this (0-1). Default 0."),
969
+ },
970
+ }, async ({ path: input, minGap }) => {
971
+ try {
972
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
973
+ if (!fs.statSync(abs).isDirectory()) {
974
+ return errorText(`"${input}" is not a directory. get_layer_violations requires a directory.`);
975
+ }
976
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
977
+ const files = collectSourceFiles(abs, opts);
978
+ const skels = [];
979
+ for (const f of files) {
980
+ const r = path.relative(root, f).split(path.sep).join("/");
981
+ try {
982
+ skels.push(await buildSkeleton(f, r, opts));
983
+ }
984
+ catch { /* skip */ }
985
+ }
986
+ const violations = findLayerViolations(buildSymbolGraph(skels, root), minGap ?? 0);
987
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: violations.length, violations });
988
+ }
989
+ catch (err) {
990
+ return errorText(describeError(err));
991
+ }
992
+ });
993
+ /* ─────────────────── tool: get_module_coupling ─────────────────────────── */
994
+ server.registerTool("get_module_coupling", {
995
+ title: "Module coupling (directory-level Ca / Ce / instability)",
996
+ description: "Aggregate the import graph up to the directory/module level: per-module afferent (Ca) / " +
997
+ "efferent (Ce) coupling and instability, plus the weighted inter-module edges. Intra-module " +
998
+ "imports (files importing siblings in the same directory) are ignored — only cross-module " +
999
+ "dependencies count. The architectural bird's-eye view above per-file coupling.",
1000
+ inputSchema: {
1001
+ path: z.string().optional().describe("Directory to scan. Default project root."),
1002
+ },
1003
+ }, async ({ path: input }) => {
1004
+ try {
1005
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
1006
+ if (!fs.statSync(abs).isDirectory()) {
1007
+ return errorText(`"${input}" is not a directory. get_module_coupling requires a directory.`);
1008
+ }
1009
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1010
+ const files = collectSourceFiles(abs, opts);
1011
+ const skels = [];
1012
+ for (const f of files) {
1013
+ const r = path.relative(root, f).split(path.sep).join("/");
1014
+ try {
1015
+ skels.push(await buildSkeleton(f, r, opts));
1016
+ }
1017
+ catch { /* skip */ }
1018
+ }
1019
+ const mc = computeModuleCoupling(buildSymbolGraph(skels, root));
1020
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", moduleCount: mc.modules.length, ...mc });
1021
+ }
1022
+ catch (err) {
1023
+ return errorText(describeError(err));
1024
+ }
1025
+ });
1026
+ /* ─────────────────── tool: get_change_impact ───────────────────────────── */
1027
+ server.registerTool("get_change_impact", {
1028
+ title: "Get change impact (blast radius)",
1029
+ description: "Given a file and a symbol name, find every file/symbol in the project that directly or " +
1030
+ "transitively depends on it via imports. Use this before refactoring to understand blast radius.\n" +
1031
+ "Returns: { direct, transitive, totalFiles } where direct = files that import the symbol " +
1032
+ "directly, transitive = further dependents up the chain.",
1033
+ inputSchema: {
1034
+ path: z
1035
+ .string()
1036
+ .describe("File containing the symbol, relative to project root or absolute within it."),
1037
+ symbol: z.string().describe("Name of the exported symbol to analyse."),
1038
+ scanDir: z
1039
+ .string()
1040
+ .optional()
1041
+ .describe("Directory to build the dependency graph from. Defaults to the directory of the given file."),
1042
+ },
1043
+ }, async ({ path: input, symbol, scanDir }) => {
1044
+ try {
1045
+ const { abs, rel, root } = resolveInRoot(input);
1046
+ if (fs.statSync(abs).isDirectory()) {
1047
+ return errorText(`"${input}" is a directory. Provide a single file path.`);
1048
+ }
1049
+ const scanRoot = scanDir ? resolveInRoot(scanDir).abs : path.dirname(abs);
1050
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1051
+ const files = collectSourceFiles(scanRoot, opts);
1052
+ const skeletons = [];
1053
+ for (const file of files) {
1054
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
1055
+ try {
1056
+ skeletons.push(await buildSkeleton(file, fileRel, opts));
1057
+ }
1058
+ catch {
1059
+ // skip parse errors
1060
+ }
1061
+ }
1062
+ const graph = buildSymbolGraph(skeletons, root);
1063
+ const targetNodeId = `${rel.split(path.sep).join("/")}::${symbol}`;
1064
+ const impact = getChangeImpact(graph, targetNodeId);
1065
+ if (!impact) {
1066
+ return errorText(`Symbol "${symbol}" not found in graph for "${rel}". ` +
1067
+ `Check the symbol name and ensure the file is inside the scan directory.`);
1068
+ }
1069
+ return jsonText(impact);
1070
+ }
1071
+ catch (err) {
1072
+ return errorText(describeError(err));
1073
+ }
1074
+ });
1075
+ /* ─────────────────── tool: get_call_graph ──────────────────────────────── */
1076
+ server.registerTool("get_call_graph", {
1077
+ title: "Get function-level call graph",
1078
+ description: "For a named function in a file, return:\n" +
1079
+ " - calls: every function/method this function calls, with line number and resolved file\n" +
1080
+ " - calledBy: files that import (and thus likely call) this function\n" +
1081
+ "Supports TypeScript, JavaScript, Python, and Go. " +
1082
+ "Cross-file calls are resolved via the import graph; local calls are flagged isLocal=true.",
1083
+ inputSchema: {
1084
+ path: z
1085
+ .string()
1086
+ .describe("File path, relative to project root or absolute within it."),
1087
+ function: z.string().describe("Name of the function or method to analyse."),
1088
+ scanDir: z
1089
+ .string()
1090
+ .optional()
1091
+ .describe("Directory to scan for reverse import lookup (calledBy). " +
1092
+ "Defaults to the directory of the given file."),
1093
+ },
1094
+ }, async ({ path: input, function: funcName, scanDir }) => {
1095
+ try {
1096
+ const { abs, rel, root } = resolveInRoot(input);
1097
+ if (fs.statSync(abs).isDirectory()) {
1098
+ return errorText(`"${input}" is a directory. Provide a single file path.`);
1099
+ }
1100
+ // Collect skeletons for the scan directory (for calledBy lookup)
1101
+ const scanRoot = scanDir ? resolveInRoot(scanDir).abs : path.dirname(abs);
1102
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1103
+ const files = collectSourceFiles(scanRoot, opts);
1104
+ const skeletons = [];
1105
+ for (const file of files) {
1106
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
1107
+ try {
1108
+ skeletons.push(await buildSkeleton(file, fileRel, opts));
1109
+ }
1110
+ catch {
1111
+ // skip
1112
+ }
1113
+ }
1114
+ const result = await buildCallGraph(abs, funcName, root, skeletons);
1115
+ if (!result) {
1116
+ return errorText(`Function "${funcName}" not found in "${rel}", or the file language is unsupported.`);
1117
+ }
1118
+ return jsonText(result);
1119
+ }
1120
+ catch (err) {
1121
+ return errorText(describeError(err));
1122
+ }
1123
+ });
1124
+ /* ─────────────────── tool: search_symbol ───────────────────────────────── */
1125
+ server.registerTool("search_symbol", {
1126
+ title: "Search symbols by name",
1127
+ description: "Find symbols (functions, classes, types, methods, …) by name across all source files " +
1128
+ "in a directory. Supports exact match, contains (default), or regex.\n" +
1129
+ "Useful when you know a symbol name but not which file it lives in.",
1130
+ inputSchema: {
1131
+ path: z
1132
+ .string()
1133
+ .describe("Directory to search in, relative to project root or absolute within it."),
1134
+ name: z.string().describe("Symbol name to search for."),
1135
+ matchType: z
1136
+ .enum(["contains", "exact", "regex"])
1137
+ .optional()
1138
+ .describe('"contains" (default) — case-insensitive substring. "exact" — full name. "regex" — JS regex.'),
1139
+ kind: z
1140
+ .enum(["function", "class", "interface", "type", "method", "const", "var", "enum", "struct", "field"])
1141
+ .optional()
1142
+ .describe("Filter by symbol kind."),
1143
+ exportedOnly: z
1144
+ .boolean()
1145
+ .optional()
1146
+ .describe("Only return exported symbols. Default false."),
1147
+ },
1148
+ }, async ({ path: input, name, matchType, kind, exportedOnly }) => {
1149
+ try {
1150
+ const { abs, rel, root } = resolveInRoot(input);
1151
+ if (!fs.statSync(abs).isDirectory()) {
1152
+ return errorText(`"${input}" is not a directory. search_symbol requires a directory.`);
1153
+ }
1154
+ const matches = await searchSymbols(abs, name, root, { matchType, kind, exportedOnly });
1155
+ return jsonText({
1156
+ directory: rel.split(path.sep).join("/"),
1157
+ pattern: name,
1158
+ matchCount: matches.length,
1159
+ matches,
1160
+ });
1161
+ }
1162
+ catch (err) {
1163
+ return errorText(describeError(err));
1164
+ }
1165
+ });
1166
+ /* ─────────────────── tool: semantic_search ─────────────────────── */
1167
+ server.registerTool("semantic_search", {
1168
+ title: "Search symbols by meaning",
1169
+ description: "Find symbols by *meaning*, not exact name. Tokenizes identifiers (camelCase/snake_case), " +
1170
+ "expands programming synonyms (fetch≈get≈load, remove≈delete≈destroy, …), applies light " +
1171
+ "stemming and fuzzy matching, and ranks with BM25-style IDF weighting over symbol names, " +
1172
+ "doc comments, signatures and file paths.\n" +
1173
+ 'Use when you know what code *does* but not what it\'s called: "remove expired sessions", ' +
1174
+ '"parse config file", "validate user input".',
1175
+ inputSchema: {
1176
+ path: z
1177
+ .string()
1178
+ .describe("Directory to search in, relative to project root or absolute within it."),
1179
+ query: z
1180
+ .string()
1181
+ .describe('What the code does, e.g. "delete old cache entries" or "load user settings".'),
1182
+ limit: z.number().int().min(1).max(100).optional().describe("Max results. Default 20."),
1183
+ kind: z
1184
+ .enum(["function", "class", "interface", "type", "method", "const", "var", "enum", "struct", "field"])
1185
+ .optional()
1186
+ .describe("Filter by symbol kind."),
1187
+ exportedOnly: z
1188
+ .boolean()
1189
+ .optional()
1190
+ .describe("Only return exported symbols. Default false."),
1191
+ },
1192
+ }, async ({ path: input, query, limit, kind, exportedOnly }) => {
1193
+ try {
1194
+ const { abs, rel, root } = resolveInRoot(input);
1195
+ if (!fs.statSync(abs).isDirectory()) {
1196
+ return errorText(`"${input}" is not a directory. semantic_search requires a directory.`);
1197
+ }
1198
+ const matches = await semanticSearch(abs, query, root, { limit, kind, exportedOnly });
1199
+ return jsonText({
1200
+ directory: rel.split(path.sep).join("/"),
1201
+ query,
1202
+ matchCount: matches.length,
1203
+ matches,
1204
+ });
1205
+ }
1206
+ catch (err) {
1207
+ return errorText(describeError(err));
1208
+ }
1209
+ });
1210
+ /* ─────────────────── tool: get_test_coverage ───────────────────────────── */
1211
+ server.registerTool("get_test_coverage", {
1212
+ title: "Test-coverage map (tests ↔ sources)",
1213
+ description: "Structural test coverage: pair test files with the source files they exercise and list " +
1214
+ "source files no test touches. Two signals: a test file *importing* a source file " +
1215
+ "(definitive) and naming conventions (auth.test.ts → auth.ts, test_utils.py → utils.py). " +
1216
+ "No instrumentation or test runner needed. Untested files are ranked by risk " +
1217
+ "(fan-in, then symbol count). This is file-level coverage, not line coverage.",
1218
+ inputSchema: {
1219
+ path: z.string().optional().describe("Directory to scan (should include the test files). Default project root."),
1220
+ untestedOnly: z.boolean().optional().describe("Return only the untested-sources list. Default false."),
1221
+ },
1222
+ }, async ({ path: input, untestedOnly }) => {
1223
+ try {
1224
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
1225
+ if (!fs.statSync(abs).isDirectory()) {
1226
+ return errorText(`"${input}" is not a directory. get_test_coverage requires a directory.`);
1227
+ }
1228
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1229
+ const files = collectSourceFiles(abs, opts);
1230
+ const skels = [];
1231
+ for (const f of files) {
1232
+ const r = path.relative(root, f).split(path.sep).join("/");
1233
+ try {
1234
+ skels.push(await buildSkeleton(f, r, opts));
1235
+ }
1236
+ catch { /* skip */ }
1237
+ }
1238
+ const map = mapTestCoverage(buildSymbolGraph(skels, root));
1239
+ const dir = rel.split(path.sep).join("/") || ".";
1240
+ if (untestedOnly) {
1241
+ return jsonText({ directory: dir, untestedSources: map.untestedSources, coverageRatio: map.coverageRatio, untested: map.untested });
1242
+ }
1243
+ return jsonText({ directory: dir, ...map });
1244
+ }
1245
+ catch (err) {
1246
+ return errorText(describeError(err));
1247
+ }
1248
+ });
1249
+ /* ─────────────────── tool: get_file_deps ───────────────────────────────── */
1250
+ server.registerTool("get_file_deps", {
1251
+ title: "Get file-level import dependencies",
1252
+ description: "For a single file, show:\n" +
1253
+ " - imports: what this file imports from other files (with symbol names)\n" +
1254
+ " - importedBy: which files import from this file (with symbol names)\n" +
1255
+ "More focused than build_symbol_graph — use this for quick dependency lookup without needing the full graph.",
1256
+ inputSchema: {
1257
+ path: z.string().describe("File to inspect, relative to project root or absolute within it."),
1258
+ scanDir: z
1259
+ .string()
1260
+ .optional()
1261
+ .describe("Directory to build the graph from. Defaults to the directory of the given file."),
1262
+ },
1263
+ }, async ({ path: input, scanDir }) => {
1264
+ try {
1265
+ const { abs, rel, root } = resolveInRoot(input);
1266
+ if (fs.statSync(abs).isDirectory()) {
1267
+ return errorText(`"${input}" is a directory. Provide a single file path.`);
1268
+ }
1269
+ const scanRoot = scanDir ? resolveInRoot(scanDir).abs : path.dirname(abs);
1270
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1271
+ const files = collectSourceFiles(scanRoot, opts);
1272
+ const skeletons = [];
1273
+ for (const file of files) {
1274
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
1275
+ try {
1276
+ skeletons.push(await buildSkeleton(file, fileRel, opts));
1277
+ }
1278
+ catch { /* skip */ }
1279
+ }
1280
+ const graph = buildSymbolGraph(skeletons, root);
1281
+ const fileId = rel.split(path.sep).join("/");
1282
+ const result = getFileDeps(graph, fileId);
1283
+ if (!result) {
1284
+ return errorText(`"${rel}" was not found in the graph. Ensure it is inside the scan directory and is a supported source file.`);
1285
+ }
1286
+ return jsonText(result);
1287
+ }
1288
+ catch (err) {
1289
+ return errorText(describeError(err));
1290
+ }
1291
+ });
1292
+ /* ─────────────────── tool: get_top_symbols ─────────────────────────────── */
1293
+ server.registerTool("get_top_symbols", {
1294
+ title: "Get most-imported symbols (God Node detector)",
1295
+ description: "Scan a directory and return the N symbols that are imported by the most files. " +
1296
+ "These are your codebase's 'God Nodes' — high-coupling, high-risk symbols where a " +
1297
+ "breaking change would have maximum blast radius. Use before a major refactor to " +
1298
+ "identify which symbols need the most care.",
1299
+ inputSchema: {
1300
+ path: z
1301
+ .string()
1302
+ .describe("Directory to scan, relative to project root or absolute within it."),
1303
+ limit: z
1304
+ .number()
1305
+ .int()
1306
+ .min(1)
1307
+ .max(100)
1308
+ .optional()
1309
+ .describe("Number of top symbols to return. Default 10."),
1310
+ },
1311
+ }, async ({ path: input, limit }) => {
1312
+ try {
1313
+ const { abs, rel, root } = resolveInRoot(input);
1314
+ if (!fs.statSync(abs).isDirectory()) {
1315
+ return errorText(`"${input}" is not a directory. get_top_symbols requires a directory.`);
1316
+ }
1317
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1318
+ const files = collectSourceFiles(abs, opts);
1319
+ const skeletons = [];
1320
+ for (const file of files) {
1321
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
1322
+ try {
1323
+ skeletons.push(await buildSkeleton(file, fileRel, opts));
1324
+ }
1325
+ catch { /* skip */ }
1326
+ }
1327
+ const graph = buildSymbolGraph(skeletons, root);
1328
+ const top = getTopSymbols(graph, limit ?? 10);
1329
+ return jsonText({
1330
+ directory: rel.split(path.sep).join("/"),
1331
+ scanned: files.length,
1332
+ topSymbols: top,
1333
+ });
1334
+ }
1335
+ catch (err) {
1336
+ return errorText(describeError(err));
1337
+ }
1338
+ });
1339
+ /* ─────────────────── tool: detect_code_smells ──────────────────────────── */
1340
+ server.registerTool("detect_code_smells", {
1341
+ title: "Detect code smells",
1342
+ description: "Scan a file or directory for structural code smells: god classes (too many methods/fields), " +
1343
+ "long methods, long parameter lists, primitive obsession, shallow wrappers, and large files. " +
1344
+ "Returns a list of smell results with file, line, symbol, severity, and message.",
1345
+ inputSchema: {
1346
+ path: z.string().describe("File or directory path relative to project root."),
1347
+ max_methods: z.number().int().optional().describe("God-class threshold: max public methods (default 10)."),
1348
+ max_fields: z.number().int().optional().describe("God-class threshold: max fields (default 8)."),
1349
+ max_method_lines: z.number().int().optional().describe("Long-method threshold: max lines (default 60)."),
1350
+ max_params: z.number().int().optional().describe("Long-param-list threshold: max params (default 4)."),
1351
+ },
1352
+ }, async ({ path: input, max_methods, max_fields, max_method_lines, max_params }) => {
1353
+ try {
1354
+ const { abs, rel, root } = resolveInRoot(input);
1355
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
1356
+ const smellOpts = { maxMethods: max_methods, maxFields: max_fields, maxMethodLines: max_method_lines, maxParams: max_params };
1357
+ const allSmells = [];
1358
+ const filesToScan = fs.statSync(abs).isDirectory() ? collectSourceFiles(abs, opts) : [abs];
1359
+ for (const fileAbs of filesToScan) {
1360
+ const fileRel = path.relative(root, fileAbs).split(path.sep).join("/");
1361
+ try {
1362
+ const skel = await buildSkeleton(fileAbs, fileRel, opts);
1363
+ const lineCount = fs.readFileSync(fileAbs, "utf8").split("\n").length;
1364
+ allSmells.push(...detectSmells(skel, lineCount, smellOpts));
1365
+ }
1366
+ catch { /* skip */ }
1367
+ }
1368
+ return jsonText({ path: rel, scanned: filesToScan.length, total: allSmells.length, smells: allSmells });
1369
+ }
1370
+ catch (err) {
1371
+ return errorText(describeError(err));
1372
+ }
1373
+ });
1374
+ /* ─────────────────── tool: scan_security ───────────────────────────────── */
1375
+ server.registerTool("scan_security", {
1376
+ title: "Scan for security issues",
1377
+ description: "Static security scan across 12 rules: eval, innerHTML, dangerously-set-inner-html, " +
1378
+ "child-process, shell-exec, weak-crypto, hardcoded-secret, sql-injection, http-url, " +
1379
+ "no-rate-limit, prototype-pollution. Returns issues with file, rule, severity, line, and snippet.",
1380
+ inputSchema: {
1381
+ path: z.string().describe("File or directory path relative to project root."),
1382
+ min_severity: z
1383
+ .enum(["critical", "high", "medium", "low"])
1384
+ .optional()
1385
+ .describe("Only return issues at or above this severity (default: low = all)."),
1386
+ },
1387
+ }, async ({ path: input, min_severity }) => {
1388
+ try {
1389
+ const { abs, rel, root } = resolveInRoot(input);
1390
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1391
+ const order = ["critical", "high", "medium", "low"];
1392
+ const minIdx = order.indexOf(min_severity ?? "low");
1393
+ const allIssues = [];
1394
+ const filesToScan = fs.statSync(abs).isDirectory() ? collectSourceFiles(abs, opts) : [abs];
1395
+ for (const fileAbs of filesToScan) {
1396
+ const fileRel = path.relative(root, fileAbs).split(path.sep).join("/");
1397
+ try {
1398
+ const source = fs.readFileSync(fileAbs, "utf8");
1399
+ const issues = scanFileForSecurityIssues(source, fileRel);
1400
+ allIssues.push(...issues.filter((i) => order.indexOf(i.severity) <= minIdx));
1401
+ }
1402
+ catch { /* skip */ }
1403
+ }
1404
+ return jsonText({ path: rel, scanned: filesToScan.length, total: allIssues.length, issues: allIssues });
1405
+ }
1406
+ catch (err) {
1407
+ return errorText(describeError(err));
1408
+ }
1409
+ });
1410
+ /* ─────────────────── tool: generate_diagram ───────────────────────────── */
1411
+ server.registerTool("generate_diagram", {
1412
+ title: "Generate Mermaid diagram",
1413
+ description: "Generate a Mermaid diagram of the codebase. " +
1414
+ "type=class: classDiagram of classes/interfaces/enums and their relationships. " +
1415
+ "type=deps: file dependency graph (graph TD). " +
1416
+ "type=modules: collapsed module-level dependency graph (graph LR).",
1417
+ inputSchema: {
1418
+ path: z.string().describe("Directory to scan."),
1419
+ type: z
1420
+ .enum(["class", "deps", "modules"])
1421
+ .optional()
1422
+ .describe("Diagram type: class | deps | modules (default: deps)."),
1423
+ max_nodes: z.number().int().optional().describe("Max nodes in deps diagram (default 50)."),
1424
+ },
1425
+ }, async ({ path: input, type, max_nodes }) => {
1426
+ try {
1427
+ const { abs, rel, root } = resolveInRoot(input);
1428
+ if (!fs.statSync(abs).isDirectory())
1429
+ return errorText("generate_diagram requires a directory.");
1430
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1431
+ const files = collectSourceFiles(abs, opts);
1432
+ const skeletons = [];
1433
+ for (const file of files) {
1434
+ const fileRel = path.relative(root, file).split(path.sep).join("/");
1435
+ try {
1436
+ skeletons.push(await buildSkeleton(file, fileRel, opts));
1437
+ }
1438
+ catch { /* skip */ }
1439
+ }
1440
+ const diagramType = type ?? "deps";
1441
+ let result;
1442
+ if (diagramType === "class") {
1443
+ result = buildClassDiagram(skeletons);
1444
+ }
1445
+ else if (diagramType === "modules") {
1446
+ const graph = buildSymbolGraph(skeletons, root);
1447
+ result = buildModulesDiagram(graph);
1448
+ }
1449
+ else {
1450
+ const graph = buildSymbolGraph(skeletons, root);
1451
+ result = buildDepsDiagram(graph, max_nodes ?? 50);
1452
+ }
1453
+ return jsonText({ path: rel, ...result });
1454
+ }
1455
+ catch (err) {
1456
+ return errorText(describeError(err));
1457
+ }
1458
+ });
1459
+ /* ─────────────────── tool: get_fix_suggestions ─────────────────────────── */
1460
+ server.registerTool("get_fix_suggestions", {
1461
+ title: "Get fix suggestions",
1462
+ description: "Return actionable, prioritised fix suggestions derived from dead exports, code smells, " +
1463
+ "and security issues. Each suggestion has a kind, file, line, description, before/after snippet, " +
1464
+ "and priority (1=must fix, 2=should fix, 3=nice to have).",
1465
+ inputSchema: {
1466
+ path: z.string().describe("File or directory path."),
1467
+ min_priority: z
1468
+ .number()
1469
+ .int()
1470
+ .min(1)
1471
+ .max(3)
1472
+ .optional()
1473
+ .describe("Only return suggestions at or above this priority (1=must, 2=should, 3=nice). Default 3 (all)."),
1474
+ },
1475
+ }, async ({ path: input, min_priority }) => {
1476
+ try {
1477
+ const { abs, rel, root } = resolveInRoot(input);
1478
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
1479
+ const filesToScan = fs.statSync(abs).isDirectory() ? collectSourceFiles(abs, opts) : [abs];
1480
+ const skeletons = [];
1481
+ const allSmells = [];
1482
+ const allSecurity = [];
1483
+ for (const fileAbs of filesToScan) {
1484
+ const fileRel = path.relative(root, fileAbs).split(path.sep).join("/");
1485
+ try {
1486
+ const skel = await buildSkeleton(fileAbs, fileRel, opts);
1487
+ skeletons.push(skel);
1488
+ const source = fs.readFileSync(fileAbs, "utf8");
1489
+ allSmells.push(...detectSmells(skel, source.split("\n").length));
1490
+ allSecurity.push(...scanFileForSecurityIssues(source, fileRel));
1491
+ }
1492
+ catch { /* skip */ }
1493
+ }
1494
+ const graph = buildSymbolGraph(skeletons, root);
1495
+ const dead = findDeadExports(graph);
1496
+ const minP = min_priority ?? 3;
1497
+ const suggestions = buildFixSuggestions({ dead, smells: allSmells, security: allSecurity, skeletons })
1498
+ .filter((s) => s.priority <= minP);
1499
+ return jsonText({ path: rel, scanned: filesToScan.length, total: suggestions.length, suggestions });
1500
+ }
1501
+ catch (err) {
1502
+ return errorText(describeError(err));
1503
+ }
1504
+ });
1505
+ /* ─────────────────── tool: generate_tests ──────────────────────────────── */
1506
+ server.registerTool("generate_tests", {
1507
+ title: "Generate test stubs",
1508
+ description: "Generate test stubs for a source file using its AST skeleton. " +
1509
+ "Supports vitest, jest, mocha, node:test, pytest, and gotest. " +
1510
+ "Returns the generated test file content and metadata (testCount, framework, testFilePath).",
1511
+ inputSchema: {
1512
+ path: z.string().describe("Source file path relative to project root."),
1513
+ framework: z
1514
+ .enum(["vitest", "jest", "mocha", "node", "pytest", "gotest"])
1515
+ .optional()
1516
+ .describe("Test framework. Auto-detected from package.json when omitted."),
1517
+ exported_only: z
1518
+ .boolean()
1519
+ .optional()
1520
+ .describe("Only generate tests for exported symbols (default: true)."),
1521
+ },
1522
+ }, async ({ path: input, framework, exported_only }) => {
1523
+ try {
1524
+ const { abs, rel, root } = resolveInRoot(input);
1525
+ if (fs.statSync(abs).isDirectory())
1526
+ return errorText("generate_tests requires a single file.");
1527
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
1528
+ const skel = await buildSkeleton(abs, rel, opts);
1529
+ const fw = framework ?? detectTestFramework(root);
1530
+ const result = generateTestFile(skel, abs, { framework: fw, exportedOnly: exported_only ?? true });
1531
+ return jsonText(result);
1532
+ }
1533
+ catch (err) {
1534
+ return errorText(describeError(err));
1535
+ }
1536
+ });
1537
+ /* ─────────────────── tool: generate_tests_ai ───────────────────────────── */
1538
+ server.registerTool("generate_tests_ai", {
1539
+ title: "Generate tests with AI (Claude)",
1540
+ description: "Generate tests for a source file using the AST skeleton for structure, then enhance them " +
1541
+ "with Claude to produce real assertions instead of TODO placeholders. " +
1542
+ "Requires ANTHROPIC_API_KEY env var or explicit api_key. Falls back to stubs if the API is unavailable.",
1543
+ inputSchema: {
1544
+ path: z.string().describe("Source file path relative to project root."),
1545
+ framework: z
1546
+ .enum(["vitest", "jest", "mocha", "node", "pytest", "gotest"])
1547
+ .optional()
1548
+ .describe("Test framework. Auto-detected when omitted."),
1549
+ api_key: z.string().optional().describe("Anthropic API key (overrides ANTHROPIC_API_KEY env var)."),
1550
+ model: z.string().optional().describe("Claude model ID (default: claude-sonnet-4-6)."),
1551
+ },
1552
+ }, async ({ path: input, framework, api_key, model }) => {
1553
+ try {
1554
+ const { abs, rel, root } = resolveInRoot(input);
1555
+ if (fs.statSync(abs).isDirectory())
1556
+ return errorText("generate_tests_ai requires a single file.");
1557
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
1558
+ const skel = await buildSkeleton(abs, rel, opts);
1559
+ const fw = framework ?? detectTestFramework(root);
1560
+ const stubs = generateTestFile(skel, abs, { framework: fw, exportedOnly: true });
1561
+ const sourceCode = fs.readFileSync(abs, "utf8");
1562
+ const result = await tryAiEnhanceTests(stubs, sourceCode, skel.language, { apiKey: api_key, model });
1563
+ return jsonText(result);
1564
+ }
1565
+ catch (err) {
1566
+ return errorText(describeError(err));
1567
+ }
1568
+ });
1569
+ /* ─────────────────── tool: ai_refactor ─────────────────────────────────── */
1570
+ server.registerTool("ai_refactor", {
1571
+ title: "AI-powered refactoring suggestions",
1572
+ description: "Send smells or security issues from a file to Claude and receive concrete refactored code. " +
1573
+ "Returns before/after code blocks and an explanation for each issue found. " +
1574
+ "Requires ANTHROPIC_API_KEY env var or explicit api_key.",
1575
+ inputSchema: {
1576
+ path: z.string().describe("Source file to refactor."),
1577
+ kind: z
1578
+ .enum(["smell", "security", "both"])
1579
+ .optional()
1580
+ .describe("Which issues to refactor: smell | security | both (default: both)."),
1581
+ api_key: z.string().optional().describe("Anthropic API key (overrides ANTHROPIC_API_KEY env var)."),
1582
+ model: z.string().optional().describe("Claude model ID (default: claude-sonnet-4-6)."),
1583
+ limit: z
1584
+ .number()
1585
+ .int()
1586
+ .min(1)
1587
+ .max(10)
1588
+ .optional()
1589
+ .describe("Max issues to send to AI (default 3 to control cost)."),
1590
+ },
1591
+ }, async ({ path: input, kind, api_key, model, limit }) => {
1592
+ try {
1593
+ const { abs, rel, root } = resolveInRoot(input);
1594
+ if (fs.statSync(abs).isDirectory())
1595
+ return errorText("ai_refactor requires a single file.");
1596
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
1597
+ const skel = await buildSkeleton(abs, rel, opts);
1598
+ const source = readSource(abs);
1599
+ const lang = skel.language;
1600
+ const cap = limit ?? 3;
1601
+ const targets = [];
1602
+ const wantSmells = (kind ?? "both") !== "security";
1603
+ const wantSecurity = (kind ?? "both") !== "smell";
1604
+ if (wantSmells) {
1605
+ const smells = detectSmells(skel, source.split("\n").length);
1606
+ for (const smell of smells.slice(0, cap - targets.length)) {
1607
+ targets.push({ kind: "smell", smell, sourceCode: source, filePath: rel, language: lang });
1608
+ }
1609
+ }
1610
+ if (wantSecurity && targets.length < cap) {
1611
+ const issues = scanFileForSecurityIssues(source, rel);
1612
+ for (const sec of issues.slice(0, cap - targets.length)) {
1613
+ targets.push({ kind: "security", security: sec, sourceCode: source, filePath: rel, language: lang });
1614
+ }
1615
+ }
1616
+ if (targets.length === 0)
1617
+ return jsonText({ path: rel, message: "No issues found to refactor.", results: [] });
1618
+ const results = await aiRefactorBatch(targets, { apiKey: api_key, model });
1619
+ return jsonText({ path: rel, total: results.length, results });
1620
+ }
1621
+ catch (err) {
1622
+ return errorText(describeError(err));
1623
+ }
1624
+ });
1625
+ /* ─────────────────── tool: explain_symbol ──────────────────────────────── */
1626
+ server.registerTool("explain_symbol", {
1627
+ title: "Explain a symbol (purpose, callers, deps, risk)",
1628
+ description: "Provide a structural explanation of any named symbol: what it does, who calls it, " +
1629
+ "what it depends on, smells, complexity rating, and estimated change risk. " +
1630
+ "With ai=true, Claude writes a prose explanation using the structural data (requires ANTHROPIC_API_KEY).",
1631
+ inputSchema: {
1632
+ path: z.string().describe("File containing the symbol, relative to project root."),
1633
+ symbol: z.string().describe("Symbol name to explain."),
1634
+ scanDir: z.string().optional().describe("Directory to build the dependency graph from. Default: file directory."),
1635
+ ai: z.boolean().optional().describe("Use Claude AI to generate a prose explanation. Default false."),
1636
+ api_key: z.string().optional().describe("Anthropic API key (overrides ANTHROPIC_API_KEY)."),
1637
+ model: z.string().optional().describe("Claude model ID (default: claude-sonnet-4-6)."),
1638
+ },
1639
+ }, async ({ path: input, symbol, scanDir, ai, api_key, model }) => {
1640
+ try {
1641
+ const { abs, rel, root } = resolveInRoot(input);
1642
+ if (fs.statSync(abs).isDirectory())
1643
+ return errorText("explain_symbol requires a single file.");
1644
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
1645
+ const skel = await buildSkeleton(abs, rel, opts);
1646
+ const scanRoot = scanDir ? resolveInRoot(scanDir).abs : path.dirname(abs);
1647
+ const skFiles = collectSourceFiles(scanRoot, opts);
1648
+ const skels = [];
1649
+ for (const f of skFiles) {
1650
+ const r = path.relative(root, f).split(path.sep).join("/");
1651
+ try {
1652
+ skels.push(await buildSkeleton(f, r, opts));
1653
+ }
1654
+ catch { /* skip */ }
1655
+ }
1656
+ const graph = buildSymbolGraph(skels, root);
1657
+ const targetId = `${rel}::${symbol}`;
1658
+ const impact = getChangeImpact(graph, targetId);
1659
+ const sourceCode = fs.readFileSync(abs, "utf8");
1660
+ const smellMessages = detectSmells(skel, sourceCode.split("\n").length).map((s) => s.message);
1661
+ const cx = await computeFileComplexity(abs, rel);
1662
+ const fnCx = cx?.functions.find((f) => f.name === symbol);
1663
+ let result = buildExplainResult(symbol, skel, graph, impact, smellMessages, fnCx?.rating);
1664
+ if (ai) {
1665
+ result = await aiExplain(result, sourceCode, { apiKey: api_key, model });
1666
+ }
1667
+ return jsonText(result);
1668
+ }
1669
+ catch (err) {
1670
+ return errorText(describeError(err));
1671
+ }
1672
+ });
1673
+ /* ─────────────────── tool: find_similar ────────────────────────────────── */
1674
+ server.registerTool("find_similar", {
1675
+ title: "Find structurally similar symbols",
1676
+ description: "Find groups of functions/methods/classes that share the same structural fingerprint " +
1677
+ "(param count, async, return type, size, nesting) across a directory. " +
1678
+ "Highlights duplication and consolidation candidates — no AI or text comparison needed.",
1679
+ inputSchema: {
1680
+ path: z.string().describe("Directory to scan, relative to project root or absolute within it."),
1681
+ kinds: z.array(z.string()).optional().describe("Symbol kinds to include (default: function, method, class)."),
1682
+ min_group_size: z.number().int().min(2).optional().describe("Minimum group size to report (default 2)."),
1683
+ },
1684
+ }, async ({ path: input, kinds, min_group_size }) => {
1685
+ try {
1686
+ const { abs, rel, root } = resolveInRoot(input);
1687
+ if (!fs.statSync(abs).isDirectory())
1688
+ return errorText("find_similar requires a directory.");
1689
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
1690
+ const files = collectSourceFiles(abs, opts);
1691
+ const skels = [];
1692
+ for (const f of files) {
1693
+ const r = path.relative(root, f).split(path.sep).join("/");
1694
+ try {
1695
+ skels.push(await buildSkeleton(f, r, opts));
1696
+ }
1697
+ catch { /* skip */ }
1698
+ }
1699
+ const groups = findSimilar(skels, { kinds, minGroupSize: min_group_size });
1700
+ return jsonText({ directory: rel.split(path.sep).join("/"), groupCount: groups.length, groups });
1701
+ }
1702
+ catch (err) {
1703
+ return errorText(describeError(err));
1704
+ }
1705
+ });
1706
+ /* ─────────────────── tool: merge_coverage ──────────────────────────────── */
1707
+ server.registerTool("merge_coverage", {
1708
+ title: "Merge actual coverage with structural map",
1709
+ description: "Enrich the structural test coverage map (which files have tests) with actual line/branch " +
1710
+ "percentages from a real coverage report. Supports Istanbul JSON, lcov, Clover XML, Cobertura XML. " +
1711
+ "Returns enriched per-file coverage, dead tests (tested but 0% actual), and uncovered files.",
1712
+ inputSchema: {
1713
+ report: z.string().describe("Path to the coverage report file (relative to project root or absolute)."),
1714
+ path: z.string().optional().describe("Project directory to scan for structural map. Default project root."),
1715
+ format: z
1716
+ .enum(["auto", "istanbul", "lcov", "clover", "cobertura"])
1717
+ .optional()
1718
+ .describe("Coverage format. Default auto-detected from file extension/content."),
1719
+ },
1720
+ }, async ({ report, path: input, format }) => {
1721
+ try {
1722
+ const { abs: reportAbs } = resolveInRoot(report);
1723
+ if (!fs.existsSync(reportAbs))
1724
+ return errorText(`Coverage report not found: ${report}`);
1725
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
1726
+ if (!fs.statSync(abs).isDirectory())
1727
+ return errorText("merge_coverage requires a directory.");
1728
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1729
+ const files = collectSourceFiles(abs, opts);
1730
+ const skels = [];
1731
+ for (const f of files) {
1732
+ const r = path.relative(root, f).split(path.sep).join("/");
1733
+ try {
1734
+ skels.push(await buildSkeleton(f, r, opts));
1735
+ }
1736
+ catch { /* skip */ }
1737
+ }
1738
+ const { mapTestCoverage } = await import("./testmap.js");
1739
+ const structuralMap = mapTestCoverage(buildSymbolGraph(skels, root));
1740
+ const merged = mergeCoverage(reportAbs, structuralMap, abs, (format ?? "auto"));
1741
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...merged });
1742
+ }
1743
+ catch (err) {
1744
+ return errorText(describeError(err));
1745
+ }
1746
+ });
1747
+ /* ─────────────────── tool: run_plugins ─────────────────────────────────── */
1748
+ server.registerTool("run_plugins", {
1749
+ title: "Run custom lint plugins",
1750
+ description: "Load and run all `.mjs`/`.js` plugins from `<root>/.ast-map/plugins/` against the current skeletons. " +
1751
+ "Each plugin exports an `AstMapPlugin` with an `id` and a `run(ctx)` function that returns violations. " +
1752
+ "Returns per-plugin violation lists with file, line, symbol, severity, and message.",
1753
+ inputSchema: {
1754
+ path: z.string().optional().describe("Project directory. Defaults to project root."),
1755
+ },
1756
+ }, async ({ path: input }) => {
1757
+ try {
1758
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
1759
+ if (!fs.statSync(abs).isDirectory())
1760
+ return errorText("run_plugins requires a directory.");
1761
+ const plugins = await loadPlugins(abs);
1762
+ if (plugins.length === 0) {
1763
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", plugins: [], message: "No plugins found in .ast-map/plugins/" });
1764
+ }
1765
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
1766
+ const files = collectSourceFiles(abs, opts);
1767
+ const skels = [];
1768
+ for (const f of files) {
1769
+ const r = path.relative(root, f).split(path.sep).join("/");
1770
+ try {
1771
+ skels.push(await buildSkeleton(f, r, opts));
1772
+ }
1773
+ catch { /* skip */ }
1774
+ }
1775
+ const results = await runPlugins(plugins, { root: abs, skeletons: skels });
1776
+ const totalViolations = results.reduce((s, r) => s + r.violations.length, 0);
1777
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", pluginCount: plugins.length, totalViolations, plugins: results });
1778
+ }
1779
+ catch (err) {
1780
+ return errorText(describeError(err));
1781
+ }
1782
+ });
1783
+ function describeError(err) {
1784
+ if (err instanceof UnsupportedLanguageError)
1785
+ return err.message;
1786
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
1787
+ return `File not found. Check the path (resolved against root ${ROOT}).`;
1788
+ }
1789
+ return err instanceof Error ? err.message : String(err);
1790
+ }
1791
+ /* ─────────────────── MCP resources (browseable structure) ──────────────── */
1792
+ server.registerResource("languages", "ast://languages", {
1793
+ title: "Supported languages",
1794
+ description: "Languages and file extensions this server can map.",
1795
+ mimeType: "application/json",
1796
+ }, async (uri) => ({
1797
+ contents: [{
1798
+ uri: uri.href,
1799
+ mimeType: "application/json",
1800
+ text: JSON.stringify({ root: ROOT, languages: supportedLanguages() }, null, 2),
1801
+ }],
1802
+ }));
1803
+ server.registerResource("skeleton", new ResourceTemplate("ast://skeleton/{+path}", {
1804
+ list: async () => {
1805
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1806
+ const files = collectSourceFiles(ROOT, opts);
1807
+ return {
1808
+ resources: files.map((f) => {
1809
+ const rel = path.relative(ROOT, f).split(path.sep).join("/");
1810
+ return { uri: `ast://skeleton/${rel}`, name: rel, mimeType: "application/json" };
1811
+ }),
1812
+ };
1813
+ },
1814
+ }), {
1815
+ title: "File skeleton",
1816
+ description: "Normalized code skeleton (symbols, imports, ranges) for one source file.",
1817
+ mimeType: "application/json",
1818
+ }, async (uri, variables) => {
1819
+ const rel = decodeURIComponent(String(variables.path)).split(path.sep).join("/");
1820
+ const { abs, rel: safeRel } = resolveInRoot(rel);
1821
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1822
+ const skel = await buildSkeleton(abs, safeRel.split(path.sep).join("/"), opts);
1823
+ return {
1824
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(skel, null, 2) }],
1825
+ };
1826
+ });
1827
+ server.registerResource("graph", "ast://graph", {
1828
+ title: "Symbol dependency graph",
1829
+ description: "Symbol-level dependency graph for the whole root (guarded by node count).",
1830
+ mimeType: "application/json",
1831
+ }, async (uri) => {
1832
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1833
+ const files = collectSourceFiles(ROOT, opts);
1834
+ if (files.length > 1500) {
1835
+ return {
1836
+ contents: [{
1837
+ uri: uri.href,
1838
+ mimeType: "application/json",
1839
+ text: JSON.stringify({ note: `Too large to inline (${files.length} files). Use build_symbol_graph on a subdirectory.`, files: files.length }, null, 2),
1840
+ }],
1841
+ };
1842
+ }
1843
+ const skels = [];
1844
+ for (const file of files) {
1845
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
1846
+ try {
1847
+ skels.push(await buildSkeleton(file, fileRel, opts));
1848
+ }
1849
+ catch { /* skip */ }
1850
+ }
1851
+ const graph = buildSymbolGraph(skels, ROOT);
1852
+ return {
1853
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(graph, null, 2) }],
1854
+ };
1855
+ });
1856
+ /* --------------------------- tool: build_index -------------------------------- */
1857
+ server.registerTool("build_index", {
1858
+ title: "Build persistent skeleton index",
1859
+ description: "Builds or refreshes the persistent skeleton index at .ast-map/index.json. " +
1860
+ "Subsequent commands read from the index (hash-verified) for 10-100x faster analysis.",
1861
+ inputSchema: {
1862
+ dir: z.string().optional().describe("Directory to scan (default: root)."),
1863
+ force: z.boolean().optional().describe("Rebuild all files, ignoring cached hashes."),
1864
+ },
1865
+ }, async ({ dir, force }) => {
1866
+ const { abs } = dir ? resolveInRoot(dir) : { abs: ROOT };
1867
+ if (force) {
1868
+ const indexFile = path.join(ROOT, ".ast-map", "index.json");
1869
+ try {
1870
+ fs.unlinkSync(indexFile);
1871
+ }
1872
+ catch { /* fine */ }
1873
+ }
1874
+ const t0 = Date.now();
1875
+ const store = await buildIndex(ROOT, abs);
1876
+ return jsonText({ root: ROOT, scanDir: abs, fileCount: store.fileCount, builtAt: store.builtAt, elapsedMs: Date.now() - t0 });
1877
+ });
1878
+ /* ------------------------- tool: check_arch_rules ----------------------------- */
1879
+ server.registerTool("check_arch_rules", {
1880
+ title: "Check architecture import rules",
1881
+ description: "Enforces forbidden/required import rules declared in .ast-map.json under `arch.rules`. " +
1882
+ "Returns a list of violations with severity (error | warning).",
1883
+ inputSchema: {
1884
+ dir: z.string().optional().describe("Directory to scan (default: root)."),
1885
+ },
1886
+ }, async ({ dir }) => {
1887
+ const { abs } = dir ? resolveInRoot(dir) : { abs: ROOT };
1888
+ const projectConfig = loadProjectConfig(ROOT);
1889
+ const rules = loadArchRules(projectConfig);
1890
+ if (rules.length === 0)
1891
+ return jsonText({ message: "No arch rules configured. Add arch.rules to .ast-map.json.", violations: [] });
1892
+ let skeletons;
1893
+ const store = loadIndex(ROOT);
1894
+ if (store && isIndexFresh(store)) {
1895
+ const prefix = path.relative(ROOT, abs).split(path.sep).join("/");
1896
+ skeletons = getIndexSkeletons(store, prefix || undefined);
1897
+ }
1898
+ else {
1899
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1900
+ const files = collectSourceFiles(abs, opts);
1901
+ const items = files.map(f => ({ abs: f, rel: path.relative(ROOT, f).split(path.sep).join("/") }));
1902
+ const built = await buildSkeletonsBulk(items, opts);
1903
+ skeletons = built.filter(Boolean).map(r => r.skel);
1904
+ }
1905
+ const graph = buildSymbolGraph(skeletons, ROOT);
1906
+ const violations = checkArchRules(graph, rules);
1907
+ return jsonText({ ruleCount: rules.length, violationCount: violations.length, violations });
1908
+ });
1909
+ /* --------------------------- tool: generate_docs ------------------------------ */
1910
+ server.registerTool("generate_docs", {
1911
+ title: "Generate API documentation",
1912
+ description: "Generates Markdown or HTML API documentation from the skeleton of a directory. " +
1913
+ "Optionally enhances descriptions with Claude (requires ANTHROPIC_API_KEY).",
1914
+ inputSchema: {
1915
+ dir: z.string().optional().describe("Directory to document (default: root)."),
1916
+ format: z.enum(["markdown", "html"]).optional().describe("Output format (default: markdown)."),
1917
+ exportedOnly: z.boolean().optional().describe("Include only exported symbols (default: true)."),
1918
+ ai: z.boolean().optional().describe("Use Claude API to add symbol descriptions."),
1919
+ apiKey: z.string().optional().describe("Anthropic API key (overrides env var)."),
1920
+ },
1921
+ }, async ({ dir, format, exportedOnly, ai, apiKey }) => {
1922
+ const { abs } = dir ? resolveInRoot(dir) : { abs: ROOT };
1923
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
1924
+ const files = collectSourceFiles(abs, opts);
1925
+ const items = files.map(f => ({ abs: f, rel: path.relative(ROOT, f).split(path.sep).join("/") }));
1926
+ const built = await buildSkeletonsBulk(items, opts);
1927
+ const skeletons = built.filter(Boolean).map(r => r.skel);
1928
+ let output = buildDocOutput(skeletons, { exportedOnly: exportedOnly !== false });
1929
+ if (ai) {
1930
+ output = await aiEnhanceDocs(output, { apiKey: apiKey ?? process.env.ANTHROPIC_API_KEY });
1931
+ }
1932
+ const rendered = format === "html" ? renderDocHtml(output) : renderMarkdown(output);
1933
+ return { content: [{ type: "text", text: rendered }] };
1934
+ });
1935
+ async function main() {
1936
+ const transport = new StdioServerTransport();
1937
+ await server.connect(transport);
1938
+ // stderr is safe for logging; stdout is reserved for the MCP protocol.
1939
+ process.stderr.write(`universal-ast-mapper running. roots=${ROOTS.roots.join(path.delimiter)}` +
1940
+ (ROOTS.unlocked ? " (UNLOCKED: any absolute path allowed)" : "") + "\n");
1941
+ }
1942
+ main().catch((err) => {
1943
+ process.stderr.write(`Fatal: ${err instanceof Error ? err.stack : String(err)}\n`);
1944
+ process.exit(1);
1945
+ });