universal-ast-mapper 1.28.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.
- package/BLUEPRINT.md +230 -230
- package/CHANGELOG.md +475 -338
- package/README.md +1127 -878
- package/dist/ai-refactor.js +185 -0
- package/dist/ai-testgen.js +105 -0
- package/dist/arch-rules.js +82 -0
- package/dist/cli.js +1029 -20
- package/dist/covmerge.js +176 -0
- package/dist/dashboard.js +259 -0
- package/dist/diagram.js +264 -0
- package/dist/docgen.js +156 -0
- package/dist/embeddings.js +136 -0
- package/dist/explain.js +123 -0
- package/dist/fix.js +92 -0
- package/dist/history.js +36 -0
- package/dist/html.js +602 -270
- package/dist/incremental.js +122 -0
- package/dist/index.js +537 -0
- package/dist/indexstore.js +105 -0
- package/dist/lsp.js +238 -0
- package/dist/patch.js +199 -0
- package/dist/plugins.js +88 -0
- package/dist/report.js +285 -76
- package/dist/security.js +178 -0
- package/dist/serve.js +185 -0
- package/dist/similar.js +98 -0
- package/dist/smells.js +285 -0
- package/dist/testgen.js +280 -0
- package/dist/webapp.js +341 -0
- package/package.json +49 -47
- package/scripts/install-skill.mjs +187 -187
package/dist/index.js
CHANGED
|
@@ -32,6 +32,20 @@ import { computeCoupling } from "./coupling.js";
|
|
|
32
32
|
import { findLayerViolations } from "./layers.js";
|
|
33
33
|
import { computeModuleCoupling } from "./modulecoupling.js";
|
|
34
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";
|
|
35
49
|
import { parseRootsFromEnv, resolvePathInRoots } from "./roots.js";
|
|
36
50
|
/**
|
|
37
51
|
* Security boundary. AST_MAP_ROOT may list several roots (path-delimiter
|
|
@@ -1322,6 +1336,450 @@ server.registerTool("get_top_symbols", {
|
|
|
1322
1336
|
return errorText(describeError(err));
|
|
1323
1337
|
}
|
|
1324
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
|
+
});
|
|
1325
1783
|
function describeError(err) {
|
|
1326
1784
|
if (err instanceof UnsupportedLanguageError)
|
|
1327
1785
|
return err.message;
|
|
@@ -1395,6 +1853,85 @@ server.registerResource("graph", "ast://graph", {
|
|
|
1395
1853
|
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(graph, null, 2) }],
|
|
1396
1854
|
};
|
|
1397
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
|
+
});
|
|
1398
1935
|
async function main() {
|
|
1399
1936
|
const transport = new StdioServerTransport();
|
|
1400
1937
|
await server.connect(transport);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { buildSkeletonsBulk } from "./pool.js";
|
|
5
|
+
import { collectSourceFiles } from "./skeleton.js";
|
|
6
|
+
import { resolveOptions } from "./config.js";
|
|
7
|
+
const INDEX_VERSION = "2";
|
|
8
|
+
const INDEX_FILE = ".ast-map/index.json";
|
|
9
|
+
function indexPath(root) {
|
|
10
|
+
return path.join(root, INDEX_FILE);
|
|
11
|
+
}
|
|
12
|
+
export function loadIndex(root) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = fs.readFileSync(indexPath(root), "utf8");
|
|
15
|
+
const store = JSON.parse(raw);
|
|
16
|
+
if (store.version !== INDEX_VERSION)
|
|
17
|
+
return null;
|
|
18
|
+
return store;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function saveIndex(root, store) {
|
|
25
|
+
const p = indexPath(root);
|
|
26
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
27
|
+
fs.writeFileSync(p, JSON.stringify(store, null, 2), "utf8");
|
|
28
|
+
}
|
|
29
|
+
export function hashFile(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
const content = fs.readFileSync(filePath);
|
|
32
|
+
return crypto.createHash("sha1").update(content).digest("hex").slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function isIndexFresh(store) {
|
|
39
|
+
for (const [, entry] of Object.entries(store.entries)) {
|
|
40
|
+
const abs = path.join(store.root, entry.rel);
|
|
41
|
+
if (hashFile(abs) !== entry.hash)
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
export function getSkeletons(store, filterPrefix) {
|
|
47
|
+
const entries = Object.values(store.entries);
|
|
48
|
+
if (filterPrefix) {
|
|
49
|
+
const norm = filterPrefix.split(path.sep).join("/");
|
|
50
|
+
return entries.filter(e => e.rel.startsWith(norm)).map(e => e.skel);
|
|
51
|
+
}
|
|
52
|
+
return entries.map(e => e.skel);
|
|
53
|
+
}
|
|
54
|
+
export async function buildIndex(root, scanDir, opts) {
|
|
55
|
+
const skOpts = resolveOptions({ ...opts, detail: "outline", emitHtml: false });
|
|
56
|
+
const files = collectSourceFiles(scanDir, skOpts);
|
|
57
|
+
const items = files.map(f => ({
|
|
58
|
+
abs: f,
|
|
59
|
+
rel: path.relative(root, f).split(path.sep).join("/"),
|
|
60
|
+
}));
|
|
61
|
+
const existing = loadIndex(root);
|
|
62
|
+
const existingEntries = existing?.entries ?? {};
|
|
63
|
+
const toRebuild = [];
|
|
64
|
+
const reused = [];
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
const h = hashFile(item.abs);
|
|
67
|
+
const cached = existingEntries[item.rel];
|
|
68
|
+
if (cached && cached.hash === h) {
|
|
69
|
+
reused.push(cached);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
toRebuild.push(item);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const built = await buildSkeletonsBulk(toRebuild, skOpts);
|
|
76
|
+
const entries = {};
|
|
77
|
+
for (const e of reused) {
|
|
78
|
+
entries[e.rel] = e;
|
|
79
|
+
}
|
|
80
|
+
for (let i = 0; i < toRebuild.length; i++) {
|
|
81
|
+
const r = built[i];
|
|
82
|
+
if (!r)
|
|
83
|
+
continue;
|
|
84
|
+
const rel = toRebuild[i].rel;
|
|
85
|
+
entries[rel] = {
|
|
86
|
+
rel,
|
|
87
|
+
hash: hashFile(toRebuild[i].abs),
|
|
88
|
+
builtAt: new Date().toISOString(),
|
|
89
|
+
skel: r.skel,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const store = {
|
|
93
|
+
version: INDEX_VERSION,
|
|
94
|
+
root,
|
|
95
|
+
scanDir,
|
|
96
|
+
builtAt: new Date().toISOString(),
|
|
97
|
+
fileCount: Object.keys(entries).length,
|
|
98
|
+
entries,
|
|
99
|
+
};
|
|
100
|
+
saveIndex(root, store);
|
|
101
|
+
return store;
|
|
102
|
+
}
|
|
103
|
+
export async function refreshIndex(root, scanDir) {
|
|
104
|
+
return buildIndex(root, scanDir);
|
|
105
|
+
}
|