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/dist/cli.js CHANGED
@@ -19,7 +19,16 @@ import { discoverWorkspace, findPackageCycles } from "./workspace.js";
19
19
  import { buildExplorerHtml } from "./explorer.js";
20
20
  import { readSourceMap } from "./sourcemap.js";
21
21
  import { buildReport, buildReportHtml } from "./report.js";
22
+ import { appendHistory, loadHistory } from "./history.js";
23
+ import { buildDashboardHtml } from "./dashboard.js";
22
24
  import { runQualityGate, BASELINE_FILENAME } from "./check.js";
25
+ import { generateTestFile, detectTestFramework } from "./testgen.js";
26
+ import { tryAiEnhanceTests } from "./ai-testgen.js";
27
+ import { detectSmells } from "./smells.js";
28
+ import { scanFileForSecurityIssues } from "./security.js";
29
+ import { buildClassDiagram, buildDepsDiagram, buildModulesDiagram } from "./diagram.js";
30
+ import { buildFixSuggestions } from "./fix.js";
31
+ import { aiRefactorBatch, readSource } from "./ai-refactor.js";
23
32
  import { computeDiff, computeRisk, isGitRepo } from "./gitdiff.js";
24
33
  import { packContext } from "./contextpack.js";
25
34
  import { computeCoupling } from "./coupling.js";
@@ -29,6 +38,17 @@ import { buildCallGraph } from "./callgraph.js";
29
38
  import { searchSymbols } from "./search.js";
30
39
  import { semanticSearch } from "./semantic.js";
31
40
  import { mapTestCoverage } from "./testmap.js";
41
+ import { buildExplainResult, aiExplain } from "./explain.js";
42
+ import { findSimilar } from "./similar.js";
43
+ import { filterToGitChanged } from "./incremental.js";
44
+ import { mergeCoverage } from "./covmerge.js";
45
+ import { loadPlugins, runPlugins, EXAMPLE_PLUGIN } from "./plugins.js";
46
+ import { startServe } from "./serve.js";
47
+ import { buildIndex, loadIndex, getSkeletons as getIndexSkeletons, isIndexFresh } from "./indexstore.js";
48
+ import { checkArchRules, loadArchRules } from "./arch-rules.js";
49
+ import { interactivePatch } from "./patch.js";
50
+ import { buildDocOutput, renderMarkdown, renderDocHtml, aiEnhanceDocs } from "./docgen.js";
51
+ import { buildTfIdfVectors, cosineSearch, rerankWithClaude } from "./embeddings.js";
32
52
  import { parseRootsFromEnv } from "./roots.js";
33
53
  const ROOT = parseRootsFromEnv().roots[0]; // CLI is local — no boundary, primary root only
34
54
  // Persistent parse cache (disable with AST_MAP_NO_CACHE=1 or "cache": false in config).
@@ -73,6 +93,14 @@ function resolveArg(p) {
73
93
  return { abs, rel };
74
94
  }
75
95
  async function gatherSkeletons(dirAbs, detail = "outline") {
96
+ // Use persistent index when available, fresh, and outline detail requested
97
+ if (detail === "outline") {
98
+ const store = loadIndex(ROOT);
99
+ if (store && isIndexFresh(store)) {
100
+ const prefix = path.relative(ROOT, dirAbs).split(path.sep).join("/");
101
+ return getIndexSkeletons(store, prefix || undefined);
102
+ }
103
+ }
76
104
  const opts = resolveOptions({ detail, emitHtml: false });
77
105
  const files = collectSourceFiles(dirAbs, opts);
78
106
  const items = files.map((f) => ({ abs: f, rel: path.relative(ROOT, f).split(path.sep).join("/") }));
@@ -409,12 +437,35 @@ program
409
437
  // ─── Command: watch ───────────────────────────────────────────────────────────
410
438
  program
411
439
  .command("watch [dir]")
412
- .description("Rebuild analysis (and optionally the explorer) when files change")
440
+ .description("Rebuild analysis when files change; optionally serve a live-reload dashboard")
413
441
  .option("-o, --out <file>", "Also regenerate the explorer HTML on each change")
442
+ .option("-p, --port <n>", "Serve live dashboard on this port (enables SSE live-reload)", (v) => parseInt(v, 10))
443
+ .option("--title <title>", "Dashboard title (used with --port)")
414
444
  .action(async (dir, opts) => {
415
445
  const { abs, rel } = resolveArg(dir ?? ".");
416
446
  if (!fs.statSync(abs).isDirectory())
417
447
  die(`"${rel}" is not a directory`);
448
+ // SSE clients registry (only used when --port is given)
449
+ const sseClients = new Set();
450
+ function broadcast() {
451
+ for (const res of sseClients) {
452
+ try {
453
+ res.write("event: reload\ndata: reload\n\n");
454
+ }
455
+ catch {
456
+ sseClients.delete(res);
457
+ }
458
+ }
459
+ }
460
+ // Current dashboard HTML (updated on each rebuild when --port given)
461
+ let dashboardHtml = "";
462
+ async function buildDash(skels, graph) {
463
+ const data = await buildReport(abs, ROOT);
464
+ const history = appendHistory(ROOT, data);
465
+ const title = opts.title ?? rel + "/";
466
+ dashboardHtml = buildDashboardHtml(buildReportHtml(data, history), renderCombinedHtml(skels), buildExplorerHtml(graph, abs), skels, title, opts.port);
467
+ return data;
468
+ }
418
469
  let building = false;
419
470
  let queued = false;
420
471
  async function rebuild(reason) {
@@ -433,6 +484,11 @@ program
433
484
  fs.writeFileSync(path.resolve(process.cwd(), opts.out), buildExplorerHtml(graph, abs), "utf8");
434
485
  line += ` · ${green("explorer updated")}`;
435
486
  }
487
+ if (opts.port) {
488
+ await buildDash(skels, graph);
489
+ broadcast();
490
+ line += ` · ${green("dashboard rebuilt")}`;
491
+ }
436
492
  line += ` ${dim(reason)}`;
437
493
  console.log(line);
438
494
  }
@@ -444,6 +500,32 @@ program
444
500
  }
445
501
  }
446
502
  }
503
+ // Start HTTP server when --port is given
504
+ if (opts.port) {
505
+ const http = await import("node:http");
506
+ const server = http.createServer((req, res) => {
507
+ const url = req.url ?? "/";
508
+ if (url === "/events") {
509
+ res.writeHead(200, {
510
+ "Content-Type": "text/event-stream",
511
+ "Cache-Control": "no-cache",
512
+ "Connection": "keep-alive",
513
+ "Access-Control-Allow-Origin": "*",
514
+ });
515
+ res.write(":ok\n\n");
516
+ sseClients.add(res);
517
+ req.on("close", () => sseClients.delete(res));
518
+ }
519
+ else {
520
+ const body = dashboardHtml || "<html><body>Building…</body></html>";
521
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
522
+ res.end(body);
523
+ }
524
+ });
525
+ server.listen(opts.port, () => {
526
+ console.log(green("✓") + ` Dashboard served at ${cyan(`http://localhost:${opts.port}`)} (SSE live-reload active)`);
527
+ });
528
+ }
447
529
  header(`Watching ${rel}/ ${dim("(Ctrl+C to stop)")}`);
448
530
  await rebuild("initial");
449
531
  let timer = null;
@@ -670,15 +752,81 @@ program
670
752
  const data = await buildReport(abs, ROOT);
671
753
  if (opts.json)
672
754
  return jsonOut(data);
755
+ const history = appendHistory(ROOT, data);
673
756
  const out = path.resolve(process.cwd(), opts.out);
674
757
  fs.mkdirSync(path.dirname(out), { recursive: true });
675
- fs.writeFileSync(out, buildReportHtml(data), "utf8");
758
+ fs.writeFileSync(out, buildReportHtml(data, history), "utf8");
676
759
  header(`Code Health \u2014 ${rel}/ ${dim(`(${data.fileCount} files)`)}`);
677
760
  const gcolor = data.grade === "A" || data.grade === "B" ? green : data.grade === "C" || data.grade === "D" ? yellow : (x) => x;
678
761
  console.log(indent(`Grade ${bold(gcolor(data.grade))} ${dim("(" + data.score + "/100)")} · ${data.dead.count} dead · ${data.cycles.count} cycles · max cx ${data.complexity.max} · tests ${Math.round(data.testCoverage.coverageRatio * 100)}%`));
679
762
  console.log(indent(green("✓ wrote " + path.relative(process.cwd(), out))));
680
763
  console.log();
681
764
  });
765
+ // ─── Command: dashboard ───────────────────────────────────────────────────────
766
+ program
767
+ .command("dashboard [dir]")
768
+ .description("Generate a unified HTML dashboard (report + skeleton + explorer + symbol table)")
769
+ .option("-o, --out <file>", "Output HTML path", "ast-dashboard.html")
770
+ .option("--title <title>", "Dashboard title")
771
+ .action(async (dir, opts) => {
772
+ const { abs, rel } = resolveArg(dir ?? ".");
773
+ if (!fs.statSync(abs).isDirectory())
774
+ die(`"${rel}" is not a directory`);
775
+ console.log(dim("Building analysis…"));
776
+ const skeletons = await gatherSkeletons(abs, "outline");
777
+ const graph = buildSymbolGraph(skeletons, ROOT);
778
+ const explorerHtml = buildExplorerHtml(graph, abs);
779
+ const skeletonHtml = renderCombinedHtml(skeletons);
780
+ const data = await buildReport(abs, ROOT);
781
+ const history = appendHistory(ROOT, data);
782
+ const reportHtml = buildReportHtml(data, history);
783
+ const title = opts.title ?? rel + "/";
784
+ const html = buildDashboardHtml(reportHtml, skeletonHtml, explorerHtml, skeletons, title);
785
+ const out = path.resolve(process.cwd(), opts.out);
786
+ fs.mkdirSync(path.dirname(out), { recursive: true });
787
+ fs.writeFileSync(out, html, "utf8");
788
+ header(`Dashboard — ${rel}/ ${dim(`(${skeletons.length} files)`)}`);
789
+ const gcolor = data.grade === "A" || data.grade === "B" ? green : data.grade === "C" || data.grade === "D" ? yellow : (x) => x;
790
+ console.log(indent(`Grade ${bold(gcolor(data.grade))} ${dim("(" + data.score + "/100)")} · ${data.dead.count} dead · ${data.cycles.count} cycles`));
791
+ console.log(indent(green("✓ wrote " + path.relative(process.cwd(), out))));
792
+ console.log(indent(dim("open in a browser — Overview · Files · Dependencies · Symbols tabs")));
793
+ console.log();
794
+ });
795
+ // ─── Command: history ─────────────────────────────────────────────────────────
796
+ program
797
+ .command("history [dir]")
798
+ .description("Show historical score trend from .ast-map/history.json")
799
+ .option("--json", "Output as JSON")
800
+ .option("-n, --limit <n>", "Max entries to show", (v) => parseInt(v, 10), 30)
801
+ .action((dir, opts) => {
802
+ const { rel } = resolveArg(dir ?? ".");
803
+ const history = loadHistory(ROOT);
804
+ if (opts.json)
805
+ return jsonOut({ directory: rel, entryCount: history.length, history });
806
+ const entries = history.slice(-opts.limit);
807
+ header(`Score History — ${rel}/ ${dim(`(${entries.length} entries)`)}`);
808
+ if (entries.length === 0) {
809
+ console.log(indent(dim("No history yet. Run `ast-map report` to start tracking.")));
810
+ }
811
+ else {
812
+ const maxScore = 100;
813
+ const barW = 20;
814
+ for (const e of entries) {
815
+ const bar = "█".repeat(Math.round((e.score / maxScore) * barW)).padEnd(barW, "░");
816
+ const gcolor = e.grade === "A" || e.grade === "B" ? green : e.grade === "C" || e.grade === "D" ? yellow : red;
817
+ const dateStr = e.date.slice(0, 10);
818
+ console.log(indent(`${dim(dateStr)} ${gcolor(bar)} ${bold(String(e.score))} ${dim(`(${e.grade})`)} ${dim(`${e.dead}d · ${e.cycles}c · cx${e.maxComplexity}`)}`));
819
+ }
820
+ const first = entries[0];
821
+ const last = entries[entries.length - 1];
822
+ if (entries.length > 1) {
823
+ const delta = last.score - first.score;
824
+ const arrow = delta > 0 ? green(`↑ +${delta}`) : delta < 0 ? red(`↓ ${delta}`) : dim("→ 0");
825
+ console.log(`\n ${dim(`Trend over ${entries.length} entries:`)} ${bold(arrow)}`);
826
+ }
827
+ }
828
+ console.log();
829
+ });
682
830
  // ─── Command: check ───────────────────────────────────────────────────────────
683
831
  const num = (v) => Number.parseFloat(v);
684
832
  program
@@ -1101,6 +1249,8 @@ program
1101
1249
  .option("-l, --limit <n>", "Max results (default 20)", "20")
1102
1250
  .option("-k, --kind <kind>", "Filter by kind: function, class, interface, type, method, const…")
1103
1251
  .option("-e, --exported", "Only show exported symbols")
1252
+ .option("--rerank", "Re-rank results with Claude API (requires ANTHROPIC_API_KEY)")
1253
+ .option("--api-key <key>", "Anthropic API key for --rerank")
1104
1254
  .option("--json", "Output as JSON")
1105
1255
  .action(async (query, dir, opts) => {
1106
1256
  const searchDir = dir ?? ".";
@@ -1108,6 +1258,28 @@ program
1108
1258
  if (!fs.statSync(abs).isDirectory())
1109
1259
  die(`"${rel}" is not a directory`);
1110
1260
  const limit = Math.max(1, parseInt(opts.limit ?? "20", 10) || 20);
1261
+ // TF-IDF embeddings path when --rerank is set
1262
+ if (opts.rerank) {
1263
+ const skeletons = await gatherSkeletons(abs);
1264
+ const vectors = buildTfIdfVectors(skeletons);
1265
+ let results = cosineSearch(vectors, query, limit);
1266
+ if (opts.kind)
1267
+ results = results.filter(m => m.kind === opts.kind);
1268
+ console.log(dim("Re-ranking with Claude…"));
1269
+ results = await rerankWithClaude(results, query, { apiKey: opts.apiKey });
1270
+ if (opts.json)
1271
+ return jsonOut({ directory: rel, query, matchCount: results.length, results });
1272
+ header(`Semantic Search (re-ranked) — ${bold(`"${query}"`)} in ${rel}/`);
1273
+ if (results.length === 0) {
1274
+ console.log(indent(dim("No matches found.")));
1275
+ }
1276
+ else {
1277
+ table(results.map((m, i) => [String(i + 1), m.file, m.symbol, m.kind, m.score.toFixed(3)]), [["#", 3], ["File", 38], ["Symbol", 28], ["Kind", 10], ["Score", 6]]);
1278
+ console.log(`\n ${results.length} match(es)`);
1279
+ }
1280
+ console.log();
1281
+ return;
1282
+ }
1111
1283
  const matches = await semanticSearch(abs, query, ROOT, {
1112
1284
  limit,
1113
1285
  kind: opts.kind,
@@ -1172,6 +1344,448 @@ program
1172
1344
  }
1173
1345
  console.log();
1174
1346
  });
1347
+ // ─── Command: testgen ─────────────────────────────────────────────────────────
1348
+ program
1349
+ .command("testgen <path>")
1350
+ .description("Generate test stubs for a file or every uncovered file in a directory")
1351
+ .option("-f, --framework <fw>", "vitest | jest | mocha | node | pytest | gotest (auto-detected)")
1352
+ .option("-o, --out <dir>", "Output directory for generated test files (default: alongside source)")
1353
+ .option("--all", "Include non-exported symbols too")
1354
+ .option("--uncovered", "Directory mode: only generate for files that have no tests yet")
1355
+ .option("--dry-run", "Print generated content to stdout, do not write files")
1356
+ .option("--ai", "Use Claude API to fill in real assertions (requires ANTHROPIC_API_KEY)")
1357
+ .option("--api-key <key>", "Anthropic API key (overrides ANTHROPIC_API_KEY env var)")
1358
+ .option("--model <id>", "Claude model ID (default: claude-sonnet-4-6)")
1359
+ .option("--json", "Output metadata as JSON")
1360
+ .action(async (inputPath, opts) => {
1361
+ const { abs, rel } = resolveArg(inputPath);
1362
+ const isDir = fs.statSync(abs).isDirectory();
1363
+ const fw = opts.framework ?? detectTestFramework(ROOT);
1364
+ const skOpts = resolveOptions({ detail: "full", emitHtml: false });
1365
+ const exportedOnly = !opts.all;
1366
+ const aiOpts = opts.ai ? { apiKey: opts.apiKey, model: opts.model } : null;
1367
+ async function processFile(fileAbs, fileRel) {
1368
+ const skel = await buildSkeleton(fileAbs, fileRel, skOpts);
1369
+ let result = generateTestFile(skel, fileAbs, { framework: fw, exportedOnly, outDir: opts.out ? path.resolve(process.cwd(), opts.out) : undefined });
1370
+ // Skip if no tests could be generated
1371
+ if (result.testCount === 0)
1372
+ return { written: false, skipped: true, result };
1373
+ let aiEnhanced = false;
1374
+ if (aiOpts) {
1375
+ const sourceCode = fs.readFileSync(fileAbs, "utf8");
1376
+ const aiResult = await tryAiEnhanceTests(result, sourceCode, skel.language, aiOpts);
1377
+ if (aiResult.aiEnhanced) {
1378
+ result = aiResult;
1379
+ aiEnhanced = true;
1380
+ }
1381
+ else if (aiResult.error) {
1382
+ process.stderr.write(yellow("⚠") + ` AI testgen failed for ${fileRel}: ${aiResult.error}\n`);
1383
+ }
1384
+ }
1385
+ if (opts.dryRun) {
1386
+ console.log(bold(`\n── ${result.sourceFile} ──`) + dim(` → ${path.relative(process.cwd(), result.testFilePath)}`));
1387
+ console.log(result.content);
1388
+ return { written: false, skipped: false, result, aiEnhanced };
1389
+ }
1390
+ // Don't overwrite existing test files
1391
+ if (fs.existsSync(result.testFilePath))
1392
+ return { written: false, skipped: true, result };
1393
+ fs.mkdirSync(path.dirname(result.testFilePath), { recursive: true });
1394
+ fs.writeFileSync(result.testFilePath, result.content, "utf8");
1395
+ return { written: true, skipped: false, result, aiEnhanced };
1396
+ }
1397
+ if (!isDir) {
1398
+ // Single file mode
1399
+ try {
1400
+ const { written, skipped, result, aiEnhanced } = await processFile(abs, rel);
1401
+ if (opts.json)
1402
+ return jsonOut({ ...result, aiEnhanced });
1403
+ if (skipped && fs.existsSync(result.testFilePath)) {
1404
+ console.log(yellow("⚠") + ` test file already exists: ${path.relative(process.cwd(), result.testFilePath)}`);
1405
+ }
1406
+ else if (skipped) {
1407
+ console.log(dim("(no testable symbols found)"));
1408
+ }
1409
+ else if (written) {
1410
+ const aiTag = aiEnhanced ? cyan(" [AI]") : "";
1411
+ console.log(green("✓") + ` ${path.relative(process.cwd(), result.testFilePath)} ${dim(`(${result.testCount} test(s), ${fw})`)}${aiTag}`);
1412
+ }
1413
+ }
1414
+ catch (e) {
1415
+ die(e instanceof Error ? e.message : String(e));
1416
+ }
1417
+ return;
1418
+ }
1419
+ // Directory mode
1420
+ let filesToProcess = collectSourceFiles(abs, skOpts);
1421
+ if (opts.uncovered) {
1422
+ const allSkels = await gatherSkeletons(abs);
1423
+ const graph = buildSymbolGraph(allSkels, ROOT);
1424
+ const coverageMap = mapTestCoverage(graph);
1425
+ const untestedSet = new Set(coverageMap.untested.map((u) => path.resolve(ROOT, u.file)));
1426
+ filesToProcess = filesToProcess.filter((f) => untestedSet.has(f));
1427
+ }
1428
+ const results = [];
1429
+ let written = 0, skipped = 0, errors = 0, aiCount = 0;
1430
+ for (const fileAbs of filesToProcess) {
1431
+ const fileRel = path.relative(ROOT, fileAbs).split(path.sep).join("/");
1432
+ try {
1433
+ const { written: w, skipped: s, result, aiEnhanced: ae } = await processFile(fileAbs, fileRel);
1434
+ results.push(result);
1435
+ if (w)
1436
+ written++;
1437
+ if (s)
1438
+ skipped++;
1439
+ if (ae)
1440
+ aiCount++;
1441
+ }
1442
+ catch {
1443
+ errors++;
1444
+ }
1445
+ }
1446
+ if (opts.json)
1447
+ return jsonOut({ directory: rel, framework: fw, written, skipped, errors, aiEnhanced: aiCount, files: results });
1448
+ if (!opts.dryRun) {
1449
+ header(`Test Generation — ${rel}/ ${dim(`(${fw})`)}`);
1450
+ const generated = results.filter((r) => r.testCount > 0);
1451
+ table(generated
1452
+ .filter((r) => !fs.existsSync(r.testFilePath) || written > 0)
1453
+ .map((r) => [
1454
+ r.sourceFile,
1455
+ path.relative(process.cwd(), r.testFilePath),
1456
+ String(r.testCount),
1457
+ ]), [["Source", 36], ["Test file", 40], ["Tests", 5]]);
1458
+ const aiTag = aiCount > 0 ? ` · ${cyan(`${aiCount} AI-enhanced`)}` : "";
1459
+ console.log(`\n ${green(`${written} file(s) written`)} · ${dim(`${skipped} skipped`)}${aiTag}`);
1460
+ if (errors > 0)
1461
+ console.log(indent(yellow(`${errors} file(s) errored`)));
1462
+ }
1463
+ console.log();
1464
+ });
1465
+ // ─── Command: smells ──────────────────────────────────────────────────────────
1466
+ program
1467
+ .command("smells [path]")
1468
+ .description("Detect code smells: god classes, long methods, long param lists, primitive obsession")
1469
+ .option("--max-methods <n>", "God-class threshold: public methods per class", (v) => parseInt(v, 10), 10)
1470
+ .option("--max-fields <n>", "God-class threshold: fields per class", (v) => parseInt(v, 10), 8)
1471
+ .option("--max-lines <n>", "Long-method threshold: lines per function", (v) => parseInt(v, 10), 60)
1472
+ .option("--max-params <n>", "Long-param-list threshold: parameters per function", (v) => parseInt(v, 10), 4)
1473
+ .option("--changed-since <ref>", "Only scan files changed since this git ref (e.g. HEAD, main)")
1474
+ .option("--json", "Output as JSON")
1475
+ .action(async (inputPath, opts) => {
1476
+ const { abs, rel } = resolveArg(inputPath ?? ".");
1477
+ const stat = fs.statSync(abs);
1478
+ const skOpts = resolveOptions({ detail: "full", emitHtml: false });
1479
+ const smellOpts = { maxMethods: opts.maxMethods, maxFields: opts.maxFields, maxMethodLines: opts.maxLines, maxParams: opts.maxParams };
1480
+ const allSmells = [];
1481
+ let filesToScan = stat.isDirectory() ? collectSourceFiles(abs, skOpts) : [abs];
1482
+ if (opts.changedSince && stat.isDirectory()) {
1483
+ const { files, fromGit } = filterToGitChanged(filesToScan, ROOT, opts.changedSince);
1484
+ filesToScan = files;
1485
+ if (fromGit)
1486
+ console.log(dim(`(incremental: ${filesToScan.length} file(s) changed since ${opts.changedSince})`));
1487
+ }
1488
+ for (const fileAbs of filesToScan) {
1489
+ const fileRel = path.relative(ROOT, fileAbs).split(path.sep).join("/");
1490
+ try {
1491
+ const skel = await buildSkeleton(fileAbs, fileRel, skOpts);
1492
+ const lineCount = fs.readFileSync(fileAbs, "utf8").split("\n").length;
1493
+ allSmells.push(...detectSmells(skel, lineCount, smellOpts));
1494
+ }
1495
+ catch { /* skip unsupported */ }
1496
+ }
1497
+ if (opts.json)
1498
+ return jsonOut({ scanned: filesToScan.length, smellCount: allSmells.length, smells: allSmells });
1499
+ const warnings = allSmells.filter((s) => s.severity === "warning");
1500
+ const infos = allSmells.filter((s) => s.severity === "info");
1501
+ header(`Code Smells — ${rel}${stat.isDirectory() ? "/" : ""} ${dim(`(${filesToScan.length} files)`)}`);
1502
+ if (allSmells.length === 0) {
1503
+ console.log(indent(green("✓ No code smells detected.")));
1504
+ }
1505
+ else {
1506
+ const byFile = new Map();
1507
+ for (const s of allSmells) {
1508
+ const list = byFile.get(s.file) ?? byFile.set(s.file, []).get(s.file);
1509
+ list.push(s);
1510
+ }
1511
+ for (const [file, smells] of byFile) {
1512
+ console.log(indent(bold(file)));
1513
+ for (const s of smells) {
1514
+ const icon = s.severity === "warning" ? yellow("⚠") : dim("ℹ");
1515
+ const loc = s.line ? dim(`:${s.line}`) : "";
1516
+ console.log(indent(`${icon} [${s.smell}]${loc} ${s.message}`, 4));
1517
+ }
1518
+ }
1519
+ console.log(`\n ${yellow(`${warnings.length} warning(s)`)} · ${dim(`${infos.length} info(s)`)}`);
1520
+ }
1521
+ console.log();
1522
+ });
1523
+ // ─── Command: security ────────────────────────────────────────────────────────
1524
+ program
1525
+ .command("security [path]")
1526
+ .description("Static security scan: eval, innerHTML, weak crypto, hardcoded secrets, SQLi, and more")
1527
+ .option("--json", "Output as JSON")
1528
+ .option("-s, --severity <level>", "Minimum severity: critical|high|medium|low", "low")
1529
+ .option("--changed-since <ref>", "Only scan files changed since this git ref (e.g. HEAD, main)")
1530
+ .action(async (inputPath, opts) => {
1531
+ const { abs, rel } = resolveArg(inputPath ?? ".");
1532
+ const stat = fs.statSync(abs);
1533
+ const skOpts = resolveOptions({ detail: "outline", emitHtml: false });
1534
+ let filesToScan = stat.isDirectory() ? collectSourceFiles(abs, skOpts) : [abs];
1535
+ if (opts.changedSince && stat.isDirectory()) {
1536
+ const { files, fromGit } = filterToGitChanged(filesToScan, ROOT, opts.changedSince);
1537
+ filesToScan = files;
1538
+ if (fromGit)
1539
+ console.log(dim(`(incremental: ${filesToScan.length} file(s) changed since ${opts.changedSince})`));
1540
+ }
1541
+ const severityRank = { critical: 4, high: 3, medium: 2, low: 1 };
1542
+ const minRank = severityRank[opts.severity] ?? 1;
1543
+ const allIssues = [];
1544
+ for (const fileAbs of filesToScan) {
1545
+ const fileRel = path.relative(ROOT, fileAbs).split(path.sep).join("/");
1546
+ try {
1547
+ const src = fs.readFileSync(fileAbs, "utf8");
1548
+ const issues = scanFileForSecurityIssues(src, fileRel).filter((i) => (severityRank[i.severity] ?? 0) >= minRank);
1549
+ allIssues.push(...issues);
1550
+ }
1551
+ catch { /* skip */ }
1552
+ }
1553
+ if (opts.json)
1554
+ return jsonOut({ scanned: filesToScan.length, issueCount: allIssues.length, issues: allIssues });
1555
+ const bySev = { critical: allIssues.filter(i => i.severity === "critical"), high: allIssues.filter(i => i.severity === "high"), medium: allIssues.filter(i => i.severity === "medium"), low: allIssues.filter(i => i.severity === "low") };
1556
+ const sevColor = (s) => s === "critical" || s === "high" ? red : s === "medium" ? yellow : dim;
1557
+ header(`Security Scan — ${rel}${stat.isDirectory() ? "/" : ""} ${dim(`(${filesToScan.length} files)`)}`);
1558
+ if (allIssues.length === 0) {
1559
+ console.log(indent(green("✓ No security issues found.")));
1560
+ }
1561
+ else {
1562
+ for (const issue of allIssues) {
1563
+ const sev = sevColor(issue.severity)(issue.severity.toUpperCase().padEnd(8));
1564
+ console.log(indent(`${sev} ${dim(issue.file + ":" + issue.line)} [${issue.rule}] ${dim(issue.snippet.slice(0, 80))}`));
1565
+ }
1566
+ console.log(`\n ${red(`${bySev.critical.length} critical`)} · ${red(`${bySev.high.length} high`)} · ${yellow(`${bySev.medium.length} medium`)} · ${dim(`${bySev.low.length} low`)}`);
1567
+ }
1568
+ console.log();
1569
+ });
1570
+ // ─── Command: diagram ─────────────────────────────────────────────────────────
1571
+ program
1572
+ .command("diagram [dir]")
1573
+ .alias("mermaid")
1574
+ .description("Generate a Mermaid diagram: class (default), deps, or modules")
1575
+ .option("-t, --type <type>", "Diagram type: class | deps | modules", "class")
1576
+ .option("-o, --out <file>", "Write to file (default: print to stdout)")
1577
+ .option("--md", "Wrap output in a Markdown ```mermaid fence")
1578
+ .action(async (dir, opts) => {
1579
+ const { abs, rel } = resolveArg(dir ?? ".");
1580
+ if (!fs.statSync(abs).isDirectory())
1581
+ die(`"${rel}" is not a directory`);
1582
+ const skeletons = await gatherSkeletons(abs, "outline");
1583
+ const graph = buildSymbolGraph(skeletons, ROOT);
1584
+ let result;
1585
+ if (opts.type === "deps")
1586
+ result = buildDepsDiagram(graph);
1587
+ else if (opts.type === "modules")
1588
+ result = buildModulesDiagram(graph);
1589
+ else
1590
+ result = buildClassDiagram(skeletons);
1591
+ const output = opts.md
1592
+ ? "```mermaid\n" + result.mermaid + "\n```"
1593
+ : result.mermaid;
1594
+ if (opts.out) {
1595
+ const outAbs = path.resolve(process.cwd(), opts.out);
1596
+ fs.mkdirSync(path.dirname(outAbs), { recursive: true });
1597
+ fs.writeFileSync(outAbs, output, "utf8");
1598
+ header(`Diagram (${result.type}) — ${rel}/`);
1599
+ console.log(indent(`${bold("Nodes:")} ${result.nodeCount} · ${bold("Edges:")} ${result.edgeCount}`));
1600
+ console.log(indent(green("✓ wrote " + path.relative(process.cwd(), outAbs))));
1601
+ }
1602
+ else {
1603
+ console.log(output);
1604
+ }
1605
+ console.log();
1606
+ });
1607
+ // ─── Command: fix ─────────────────────────────────────────────────────────────
1608
+ program
1609
+ .command("fix [dir]")
1610
+ .description("Show actionable fix suggestions: dead exports, code smells, security issues")
1611
+ .option("--json", "Output as JSON")
1612
+ .option("-p, --priority <n>", "Only show fixes of priority ≤ n (1=must, 2=should, 3=nice)", (v) => parseInt(v, 10), 3)
1613
+ .option("--ai", "Use Claude API to generate concrete refactored code for each issue (requires ANTHROPIC_API_KEY)")
1614
+ .option("--api-key <key>", "Anthropic API key (overrides ANTHROPIC_API_KEY env var)")
1615
+ .option("--model <id>", "Claude model ID (default: claude-sonnet-4-6)")
1616
+ .option("--limit <n>", "Max issues to send to AI per run (default 3)", (v) => parseInt(v, 10), 3)
1617
+ .action(async (dir, opts) => {
1618
+ const { abs, rel } = resolveArg(dir ?? ".");
1619
+ if (!fs.statSync(abs).isDirectory())
1620
+ die(`"${rel}" is not a directory`);
1621
+ const skeletons = await gatherSkeletons(abs, "full");
1622
+ const graph = buildSymbolGraph(skeletons, ROOT);
1623
+ const dead = findDeadExports(graph).filter((d) => d.confidence === "high");
1624
+ const skOpts = resolveOptions({ detail: "full", emitHtml: false });
1625
+ const allSmells = [];
1626
+ const allSecurity = [];
1627
+ for (const skel of skeletons) {
1628
+ const fileAbs = path.resolve(ROOT, skel.file);
1629
+ try {
1630
+ const src = fs.readFileSync(fileAbs, "utf8");
1631
+ allSmells.push(...detectSmells(skel, src.split("\n").length));
1632
+ allSecurity.push(...scanFileForSecurityIssues(src, skel.file));
1633
+ }
1634
+ catch { /* skip */ }
1635
+ }
1636
+ const suggestions = buildFixSuggestions({ dead, smells: allSmells, security: allSecurity })
1637
+ .filter((s) => s.priority <= opts.priority)
1638
+ .sort((a, b) => a.priority - b.priority || a.file.localeCompare(b.file));
1639
+ if (opts.ai) {
1640
+ // ── AI refactor mode ────────────────────────────────────────────────────
1641
+ const aiOpts = { apiKey: opts.apiKey, model: opts.model };
1642
+ const targets = [];
1643
+ for (const skel of skeletons.slice(0, opts.limit)) {
1644
+ const fileAbs = path.resolve(ROOT, skel.file);
1645
+ const source = readSource(fileAbs);
1646
+ const smells = detectSmells(skel, source.split("\n").length);
1647
+ for (const smell of smells.slice(0, Math.max(1, Math.floor(opts.limit / skeletons.length) || 1))) {
1648
+ if (targets.length >= opts.limit)
1649
+ break;
1650
+ targets.push({ kind: "smell", smell, sourceCode: source, filePath: skel.file, language: skel.language });
1651
+ }
1652
+ const secIssues = scanFileForSecurityIssues(source, skel.file);
1653
+ for (const sec of secIssues) {
1654
+ if (targets.length >= opts.limit)
1655
+ break;
1656
+ targets.push({ kind: "security", security: sec, sourceCode: source, filePath: skel.file, language: skel.language });
1657
+ }
1658
+ }
1659
+ if (targets.length === 0) {
1660
+ console.log(green("✓ No issues found to refactor."));
1661
+ return;
1662
+ }
1663
+ console.log(dim(`Sending ${targets.length} issue(s) to Claude…`));
1664
+ const results = await aiRefactorBatch(targets, aiOpts);
1665
+ if (opts.json)
1666
+ return jsonOut({ directory: rel, results });
1667
+ header(`AI Refactor — ${rel}/`);
1668
+ for (const r of results) {
1669
+ if (r.error) {
1670
+ console.log(indent(yellow(`⚠ ${r.issue}: ${r.error}`)));
1671
+ continue;
1672
+ }
1673
+ console.log(indent(`${cyan(bold(r.issue))} ${dim(r.filePath)}`));
1674
+ console.log(indent(dim("before:"), 4));
1675
+ for (const line of r.before.split("\n").slice(0, 8))
1676
+ console.log(indent(red(line), 6));
1677
+ console.log(indent(dim("after:"), 4));
1678
+ for (const line of r.after.split("\n").slice(0, 8))
1679
+ console.log(indent(green(line), 6));
1680
+ console.log(indent(r.explanation, 4));
1681
+ console.log();
1682
+ }
1683
+ return;
1684
+ }
1685
+ if (opts.json)
1686
+ return jsonOut({ directory: rel, count: suggestions.length, suggestions });
1687
+ header(`Fix Suggestions — ${rel}/`);
1688
+ if (suggestions.length === 0) {
1689
+ console.log(indent(green("✓ Nothing to fix.")));
1690
+ }
1691
+ else {
1692
+ const priLabel = (p) => p === 1 ? red("[P1 must]") : p === 2 ? yellow("[P2 should]") : dim("[P3 nice]");
1693
+ for (const s of suggestions) {
1694
+ const loc = s.line ? dim(`:${s.line}`) : "";
1695
+ console.log(indent(`${priLabel(s.priority)} ${bold(s.kind)} ${dim(s.file + loc)}`));
1696
+ console.log(indent(s.description, 6));
1697
+ if (s.before && s.after) {
1698
+ console.log(indent(red("- " + s.before), 6));
1699
+ console.log(indent(green("+ " + s.after), 6));
1700
+ }
1701
+ console.log();
1702
+ }
1703
+ const p1 = suggestions.filter(s => s.priority === 1).length;
1704
+ const p2 = suggestions.filter(s => s.priority === 2).length;
1705
+ const p3 = suggestions.filter(s => s.priority === 3).length;
1706
+ console.log(indent(`${red(`${p1} must`)} · ${yellow(`${p2} should`)} · ${dim(`${p3} nice`)}`));
1707
+ }
1708
+ console.log();
1709
+ });
1710
+ // ─── Command: init ────────────────────────────────────────────────────────────
1711
+ program
1712
+ .command("init")
1713
+ .description("Create .ast-map.json config file with sensible defaults (interactive)")
1714
+ .option("--defaults", "Write defaults without prompting")
1715
+ .option("--json", "Output the generated config as JSON (no file written)")
1716
+ .action(async (opts) => {
1717
+ const configPath = path.join(ROOT, ".ast-map.json");
1718
+ const defaults = {
1719
+ cache: true,
1720
+ detail: "outline",
1721
+ ignore: ["dist", "build", "node_modules", ".next", "out", "coverage", "__pycache__"],
1722
+ thresholds: {
1723
+ minScore: 70,
1724
+ maxCycles: 0,
1725
+ maxDeadExports: 10,
1726
+ maxComplexity: 20,
1727
+ },
1728
+ smells: {
1729
+ maxMethods: 10,
1730
+ maxFields: 8,
1731
+ maxMethodLines: 60,
1732
+ maxParams: 4,
1733
+ },
1734
+ security: {
1735
+ minSeverity: "medium",
1736
+ },
1737
+ layers: {
1738
+ rules: [],
1739
+ },
1740
+ };
1741
+ if (opts.json) {
1742
+ jsonOut(defaults);
1743
+ return;
1744
+ }
1745
+ if (!opts.defaults) {
1746
+ // Simple prompt loop via readline (Node.js built-in)
1747
+ const { createInterface } = await import("node:readline");
1748
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1749
+ const ask = (q, def) => new Promise((res) => rl.question(`${dim(q)} ${gray(`[${def}]`)} `, (ans) => res(ans.trim() || def)));
1750
+ header("AST Map — Config Init");
1751
+ console.log(dim(" Press Enter to accept defaults.\n"));
1752
+ const minScore = parseInt(await ask("Min health score (0-100):", String(defaults.thresholds.minScore)), 10);
1753
+ const maxCycles = parseInt(await ask("Max circular deps:", String(defaults.thresholds.maxCycles)), 10);
1754
+ const maxComplexity = parseInt(await ask("Max cyclomatic complexity:", String(defaults.thresholds.maxComplexity)), 10);
1755
+ const maxMethodLines = parseInt(await ask("Max method lines (smell):", String(defaults.smells.maxMethodLines)), 10);
1756
+ const minSev = await ask("Min security severity (critical/high/medium/low):", defaults.security.minSeverity);
1757
+ const ignoreRaw = await ask("Additional ignore dirs (comma-separated):", "");
1758
+ rl.close();
1759
+ if (!isNaN(minScore))
1760
+ defaults.thresholds.minScore = minScore;
1761
+ if (!isNaN(maxCycles))
1762
+ defaults.thresholds.maxCycles = maxCycles;
1763
+ if (!isNaN(maxComplexity))
1764
+ defaults.thresholds.maxComplexity = maxComplexity;
1765
+ if (!isNaN(maxMethodLines))
1766
+ defaults.smells.maxMethodLines = maxMethodLines;
1767
+ if (["critical", "high", "medium", "low"].includes(minSev))
1768
+ defaults.security.minSeverity = minSev;
1769
+ if (ignoreRaw.trim()) {
1770
+ defaults.ignore.push(...ignoreRaw.split(",").map((s) => s.trim()).filter(Boolean));
1771
+ }
1772
+ }
1773
+ if (fs.existsSync(configPath)) {
1774
+ console.log(yellow("⚠") + ` .ast-map.json already exists — overwriting.`);
1775
+ }
1776
+ fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2) + "\n", "utf8");
1777
+ console.log(green("✓") + ` .ast-map.json created at ${configPath}`);
1778
+ // Scaffold example plugin
1779
+ const pluginsDir = path.join(ROOT, ".ast-map", "plugins");
1780
+ const examplePlugin = path.join(pluginsDir, "example.mjs");
1781
+ if (!fs.existsSync(examplePlugin)) {
1782
+ fs.mkdirSync(pluginsDir, { recursive: true });
1783
+ fs.writeFileSync(examplePlugin, EXAMPLE_PLUGIN, "utf8");
1784
+ console.log(green("✓") + ` Example plugin scaffolded at ${examplePlugin}`);
1785
+ }
1786
+ console.log(dim(" Edit .ast-map.json freely — ast-map reads it on every run."));
1787
+ console.log();
1788
+ });
1175
1789
  // ─── Command: deps ────────────────────────────────────────────────────────────
1176
1790
  program
1177
1791
  .command("deps <file>")
@@ -1245,29 +1859,424 @@ program
1245
1859
  }
1246
1860
  console.log();
1247
1861
  });
1862
+ // ─── Command: explain ─────────────────────────────────────────────────────────
1863
+ program
1864
+ .command("explain <file> <symbol>")
1865
+ .description("Explain what a symbol does: purpose, callers, dependencies, change risk")
1866
+ .option("--scan <dir>", "Directory to build the dependency graph from (default: file's directory)")
1867
+ .option("--ai", "Use Claude API to generate a prose explanation (requires ANTHROPIC_API_KEY)")
1868
+ .option("--api-key <key>", "Anthropic API key (overrides ANTHROPIC_API_KEY env var)")
1869
+ .option("--model <id>", "Claude model ID (default: claude-sonnet-4-6)")
1870
+ .option("--json", "Output as JSON")
1871
+ .action(async (inputPath, symbolName, opts) => {
1872
+ const { abs, rel } = resolveArg(inputPath);
1873
+ if (fs.statSync(abs).isDirectory())
1874
+ die(`Provide a single file path, not a directory`);
1875
+ const scanRoot = opts.scan ? resolveArg(opts.scan).abs : path.dirname(abs);
1876
+ const skOpts = resolveOptions({ detail: "full", emitHtml: false });
1877
+ const skel = await buildSkeleton(abs, rel, skOpts);
1878
+ const skeletons = await gatherSkeletons(scanRoot);
1879
+ const graph = buildSymbolGraph(skeletons, ROOT);
1880
+ const targetId = `${rel}::${symbolName}`;
1881
+ const impact = getChangeImpact(graph, targetId);
1882
+ const sourceCode = fs.readFileSync(abs, "utf8");
1883
+ const lineCount = sourceCode.split("\n").length;
1884
+ const smellMessages = detectSmells(skel, lineCount).map((s) => s.message);
1885
+ const cx = await computeFileComplexity(abs, rel);
1886
+ const fnCx = cx?.functions.find((f) => f.name === symbolName);
1887
+ let result = buildExplainResult(symbolName, skel, graph, impact, smellMessages, fnCx?.rating);
1888
+ if (opts.ai) {
1889
+ try {
1890
+ result = await aiExplain(result, sourceCode, { apiKey: opts.apiKey, model: opts.model });
1891
+ }
1892
+ catch (e) {
1893
+ process.stderr.write(yellow("⚠") + ` AI explain failed: ${e instanceof Error ? e.message : String(e)}\n`);
1894
+ }
1895
+ }
1896
+ if (opts.json)
1897
+ return jsonOut(result);
1898
+ header(`Explain — ${bold(symbolName)} ${dim(rel)}`);
1899
+ console.log(indent(`${bold("Kind:")} ${result.kind}`));
1900
+ if (result.signature)
1901
+ console.log(indent(`${bold("Sig:")} ${dim(result.signature)}`));
1902
+ const asyncTag = result.summary.isAsync ? cyan(" async") : "";
1903
+ const expTag = result.summary.isExported ? green(" exported") : dim(" unexported");
1904
+ console.log(indent(`${bold("Lines:")} ${result.summary.lineCount} · ${bold("Children:")} ${result.summary.childCount}${asyncTag}${expTag}`));
1905
+ if (result.complexityRating)
1906
+ console.log(indent(`${bold("Complexity:")} ${result.complexityRating}`));
1907
+ console.log(`\n${indent(`${bold("Used by")} ${dim(`(${result.summary.callerCount} file(s))`)}`)}`);
1908
+ for (const f of result.summary.callerFiles.slice(0, 8))
1909
+ console.log(indent(dim(f), 4));
1910
+ if (result.summary.callerCount === 0)
1911
+ console.log(indent(dim("(none detected)"), 4));
1912
+ if (result.summary.dependsOn.length > 0) {
1913
+ console.log(`\n${indent(bold("Depends on"))}`);
1914
+ for (const d of result.summary.dependsOn)
1915
+ console.log(indent(dim(d), 4));
1916
+ }
1917
+ if (result.smells.length > 0) {
1918
+ console.log(`\n${indent(bold("Smells"))}`);
1919
+ for (const s of result.smells)
1920
+ console.log(indent(yellow("⚠ ") + s, 4));
1921
+ }
1922
+ if (result.aiExplanation) {
1923
+ console.log(`\n${indent(bold("AI Explanation"))}`);
1924
+ for (const line of result.aiExplanation.split("\n"))
1925
+ console.log(indent(line, 4));
1926
+ }
1927
+ console.log();
1928
+ });
1929
+ // ─── Command: similar ─────────────────────────────────────────────────────────
1930
+ program
1931
+ .command("similar [dir]")
1932
+ .description("Find structurally similar/duplicate functions via AST fingerprinting")
1933
+ .option("--kinds <list>", "Comma-sep symbol kinds to check (default: function,method,class)", "function,method,class")
1934
+ .option("--min <n>", "Min group size to report (default 2)", (v) => parseInt(v, 10), 2)
1935
+ .option("--json", "Output as JSON")
1936
+ .action(async (dir, opts) => {
1937
+ const { abs, rel } = resolveArg(dir ?? ".");
1938
+ if (!fs.statSync(abs).isDirectory())
1939
+ die(`"${rel}" is not a directory`);
1940
+ const skeletons = await gatherSkeletons(abs, "full");
1941
+ const kinds = opts.kinds.split(",").map((k) => k.trim()).filter(Boolean);
1942
+ const groups = findSimilar(skeletons, { minGroupSize: opts.min, kinds });
1943
+ if (opts.json)
1944
+ return jsonOut({ directory: rel, groupCount: groups.length, groups });
1945
+ header(`Similar Symbols — ${rel}/ ${dim(`(${skeletons.length} files, ${groups.length} group(s))`)}`);
1946
+ if (groups.length === 0) {
1947
+ console.log(indent(green("✓ No structurally similar symbol groups found.")));
1948
+ }
1949
+ else {
1950
+ for (const g of groups.slice(0, 20)) {
1951
+ console.log(indent(`${yellow(`×${g.count}`)} ${bold(g.description)}`));
1952
+ for (const e of g.entries) {
1953
+ const loc = dim(`${e.file}:${e.line}`);
1954
+ console.log(indent(`${dim(col(e.kind, 9))} ${e.symbol} ${loc}`, 6));
1955
+ }
1956
+ console.log();
1957
+ }
1958
+ console.log(indent(`${yellow(String(groups.length))} similar group(s) found`));
1959
+ }
1960
+ console.log();
1961
+ });
1962
+ // ─── Command: serve ───────────────────────────────────────────────────────────
1963
+ program
1964
+ .command("serve [dir]")
1965
+ .description("Start an interactive web UI for code analysis (default port 7337)")
1966
+ .option("-p, --port <n>", "Port to listen on (default 7337)", (v) => parseInt(v, 10), 7337)
1967
+ .option("--open", "Open the browser after starting")
1968
+ .action(async (dir, opts) => {
1969
+ const { abs, rel } = resolveArg(dir ?? ".");
1970
+ if (!fs.statSync(abs).isDirectory())
1971
+ die(`"${rel}" is not a directory`);
1972
+ const port = opts.port;
1973
+ console.log(dim(`Serving ${rel}/ on port ${port}…`));
1974
+ await startServe({ root: abs, scanDir: abs, port });
1975
+ console.log(green("✓") + ` Web UI at ${cyan(`http://localhost:${port}`)}`);
1976
+ console.log(dim(" Press Ctrl+C to stop."));
1977
+ if (opts.open) {
1978
+ const cp = await import("node:child_process");
1979
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1980
+ try {
1981
+ cp.execSync(`${cmd} http://localhost:${port}`);
1982
+ }
1983
+ catch { /* ignore */ }
1984
+ }
1985
+ await new Promise(() => { }); // keep process alive
1986
+ });
1987
+ // ─── Command: covmerge ────────────────────────────────────────────────────────
1988
+ program
1989
+ .command("covmerge <report>")
1990
+ .description("Merge structural coverage map with an actual coverage report (Istanbul/lcov/Clover/Cobertura)")
1991
+ .option("--dir <dir>", "Project directory to scan (default: .)", ".")
1992
+ .option("-f, --format <fmt>", "Report format: auto|istanbul|lcov|clover|cobertura (default: auto)", "auto")
1993
+ .option("--json", "Output as JSON")
1994
+ .action(async (reportPath, opts) => {
1995
+ const reportAbs = path.resolve(ROOT, reportPath);
1996
+ if (!fs.existsSync(reportAbs))
1997
+ die(`Coverage report not found: ${reportPath}`);
1998
+ const { abs, rel } = resolveArg(opts.dir);
1999
+ const skeletons = await gatherSkeletons(abs);
2000
+ const graph = buildSymbolGraph(skeletons, ROOT);
2001
+ const structuralMap = mapTestCoverage(graph);
2002
+ const merged = mergeCoverage(reportAbs, structuralMap, abs, opts.format);
2003
+ if (opts.json)
2004
+ return jsonOut(merged);
2005
+ const pct = Math.round(merged.summary.avgLineCoverage * 100);
2006
+ const pcolor = pct >= 70 ? green : pct >= 40 ? yellow : red;
2007
+ header(`Coverage Merge — ${rel}/ ${dim(`(${merged.format} format)`)}`);
2008
+ console.log(indent(`${bold("Files:")} ${merged.summary.totalFiles} covered ${merged.summary.coveredFiles}`));
2009
+ console.log(indent(`${bold("Line cov:")} ${pcolor(`${pct}%`)}`));
2010
+ if (merged.summary.avgBranchCoverage !== undefined) {
2011
+ console.log(indent(`${bold("Branch cov:")} ${Math.round(merged.summary.avgBranchCoverage * 100)}%`));
2012
+ }
2013
+ if (merged.deadTests.length > 0) {
2014
+ console.log(`\n${indent(`${bold("Dead tests")} ${dim("(0% actual coverage)")}`)}`);
2015
+ for (const f of merged.deadTests.slice(0, 10))
2016
+ console.log(indent(red("✗ ") + f, 4));
2017
+ }
2018
+ if (merged.uncovered.length > 0) {
2019
+ console.log(`\n${indent(`${bold("Uncovered")} ${dim("(no tests + 0% coverage)")}`)}`);
2020
+ for (const f of merged.uncovered.slice(0, 15))
2021
+ console.log(indent(dim(" " + f), 4));
2022
+ }
2023
+ console.log();
2024
+ });
2025
+ // ─── Command: plugins ─────────────────────────────────────────────────────────
2026
+ program
2027
+ .command("plugins [dir]")
2028
+ .description("Run custom lint plugins from .ast-map/plugins/ (*.mjs / *.js)")
2029
+ .option("--json", "Output as JSON")
2030
+ .action(async (dir, opts) => {
2031
+ const { abs, rel } = resolveArg(dir ?? ".");
2032
+ if (!fs.statSync(abs).isDirectory())
2033
+ die(`"${rel}" is not a directory`);
2034
+ const plugins = await loadPlugins(abs);
2035
+ if (plugins.length === 0) {
2036
+ console.log(dim(`No plugins found in ${path.join(rel, ".ast-map/plugins/")}`));
2037
+ console.log(dim(" Run ast-map init to scaffold an example plugin."));
2038
+ return;
2039
+ }
2040
+ const skeletons = await gatherSkeletons(abs);
2041
+ const results = await runPlugins(plugins, { root: abs, skeletons });
2042
+ if (opts.json)
2043
+ return jsonOut({ directory: rel, plugins: results });
2044
+ const totalViolations = results.reduce((s, r) => s + r.violations.length, 0);
2045
+ header(`Plugins — ${rel}/ ${dim(`(${plugins.length} plugin(s), ${totalViolations} violation(s))`)}`);
2046
+ for (const r of results) {
2047
+ const icon = r.error ? red("✗") : r.violations.length > 0 ? yellow("⚠") : green("✓");
2048
+ console.log(indent(`${icon} ${bold(r.pluginId)} ${dim(r.description ?? "")}`));
2049
+ if (r.error)
2050
+ console.log(indent(red(r.error), 6));
2051
+ for (const v of r.violations) {
2052
+ const loc = v.line ? dim(`:${v.line}`) : "";
2053
+ const sevIcon = v.severity === "error" ? red("✗") : v.severity === "warning" ? yellow("⚠") : dim("ℹ");
2054
+ console.log(indent(`${sevIcon} ${dim(v.file + loc)} ${v.message}`, 6));
2055
+ }
2056
+ }
2057
+ console.log();
2058
+ });
2059
+ // ─── Command: index ───────────────────────────────────────────────────────────
2060
+ program
2061
+ .command("index [dir]")
2062
+ .description("Build or refresh the persistent skeleton index (.ast-map/index.json) for faster analysis")
2063
+ .option("--force", "Rebuild all files, ignoring cached hashes")
2064
+ .option("--json", "Output build stats as JSON")
2065
+ .action(async (dir, opts) => {
2066
+ const { abs, rel } = resolveArg(dir ?? ".");
2067
+ if (!fs.statSync(abs).isDirectory())
2068
+ die(`"${rel}" is not a directory`);
2069
+ if (opts.force) {
2070
+ const indexFile = path.join(ROOT, ".ast-map", "index.json");
2071
+ try {
2072
+ fs.unlinkSync(indexFile);
2073
+ }
2074
+ catch { /* fine */ }
2075
+ }
2076
+ console.log(dim(`Building index for ${rel}/…`));
2077
+ const t0 = Date.now();
2078
+ const store = await buildIndex(ROOT, abs);
2079
+ const elapsed = Date.now() - t0;
2080
+ if (opts.json)
2081
+ return jsonOut({ root: ROOT, scanDir: abs, fileCount: store.fileCount, builtAt: store.builtAt, elapsedMs: elapsed });
2082
+ console.log(green("✓") + ` Index built — ${bold(String(store.fileCount))} files in ${elapsed}ms`);
2083
+ console.log(dim(` Saved to ${path.join(ROOT, ".ast-map", "index.json")}`));
2084
+ console.log();
2085
+ });
2086
+ // ─── Command: arch ────────────────────────────────────────────────────────────
2087
+ program
2088
+ .command("arch [dir]")
2089
+ .description("Check architecture import rules from .ast-map.json (arch.rules)")
2090
+ .option("--json", "Output as JSON")
2091
+ .action(async (dir, opts) => {
2092
+ const { abs, rel } = resolveArg(dir ?? ".");
2093
+ if (!fs.statSync(abs).isDirectory())
2094
+ die(`"${rel}" is not a directory`);
2095
+ const projectConfig = loadProjectConfig(ROOT);
2096
+ const rules = loadArchRules(projectConfig);
2097
+ if (rules.length === 0) {
2098
+ console.log(yellow("⚠") + ` No architecture rules found in .ast-map.json`);
2099
+ console.log(dim(` Add an "arch": { "rules": [...] } section to .ast-map.json`));
2100
+ return;
2101
+ }
2102
+ const skeletons = await gatherSkeletons(abs);
2103
+ const graph = buildSymbolGraph(skeletons, ROOT);
2104
+ const violations = checkArchRules(graph, rules);
2105
+ if (opts.json)
2106
+ return jsonOut({ directory: rel, ruleCount: rules.length, violationCount: violations.length, violations });
2107
+ header(`Architecture Rules — ${rel}/ ${dim(`(${rules.length} rule(s))`)}`);
2108
+ if (violations.length === 0) {
2109
+ console.log(indent(green("✓ No architecture violations.")));
2110
+ }
2111
+ else {
2112
+ for (const v of violations) {
2113
+ const icon = v.severity === "error" ? red("✗") : yellow("⚠");
2114
+ console.log(indent(`${icon} ${bold(v.rule)}`));
2115
+ console.log(indent(dim(v.file), 6));
2116
+ console.log(indent(v.message, 6));
2117
+ console.log();
2118
+ }
2119
+ const errors = violations.filter(v => v.severity === "error").length;
2120
+ console.log(indent(`${red(String(errors))} error(s) · ${yellow(String(violations.length - errors))} warning(s)`));
2121
+ if (errors > 0)
2122
+ process.exitCode = 1;
2123
+ }
2124
+ console.log();
2125
+ });
2126
+ // ─── Command: patch ───────────────────────────────────────────────────────────
2127
+ program
2128
+ .command("patch [dir]")
2129
+ .description("Auto-patch: send smells/security issues to Claude, show colored diff, apply with y/n")
2130
+ .option("--severity <level>", "Min security severity to patch: critical|high|medium|low", "high")
2131
+ .option("--smells-only", "Only patch code smells (skip security)")
2132
+ .option("--security-only", "Only patch security issues (skip smells)")
2133
+ .option("-y, --yes", "Apply all patches without prompting")
2134
+ .option("--api-key <key>", "Anthropic API key")
2135
+ .option("--model <id>", "Claude model ID")
2136
+ .option("--json", "Output results as JSON")
2137
+ .action(async (dir, opts) => {
2138
+ const { abs, rel } = resolveArg(dir ?? ".");
2139
+ if (!fs.statSync(abs).isDirectory())
2140
+ die(`"${rel}" is not a directory`);
2141
+ const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY;
2142
+ if (!apiKey)
2143
+ die("ANTHROPIC_API_KEY not set — pass --api-key or set the env var");
2144
+ const skeletons = await gatherSkeletons(abs, "full");
2145
+ const patchIssues = [];
2146
+ for (const skel of skeletons) {
2147
+ const fileAbs = path.resolve(ROOT, skel.file);
2148
+ let src;
2149
+ try {
2150
+ src = fs.readFileSync(fileAbs, "utf8");
2151
+ }
2152
+ catch {
2153
+ continue;
2154
+ }
2155
+ if (!opts.securityOnly) {
2156
+ const smells = detectSmells(skel, src.split("\n").length);
2157
+ for (const smell of smells) {
2158
+ patchIssues.push({ kind: "smell", smell, filePath: fileAbs, sourceCode: src, language: skel.language });
2159
+ }
2160
+ }
2161
+ if (!opts.smellsOnly) {
2162
+ const sevOrder = ["critical", "high", "medium", "low"];
2163
+ const minIdx = sevOrder.indexOf(opts.severity);
2164
+ const secIssues = scanFileForSecurityIssues(src, skel.file)
2165
+ .filter(i => sevOrder.indexOf(i.severity) <= minIdx);
2166
+ for (const issue of secIssues) {
2167
+ patchIssues.push({ kind: "security", security: issue, filePath: fileAbs, sourceCode: src, language: skel.language });
2168
+ }
2169
+ }
2170
+ }
2171
+ if (patchIssues.length === 0) {
2172
+ console.log(green("✓") + " No issues found to patch.");
2173
+ return;
2174
+ }
2175
+ console.log(dim(`Found ${patchIssues.length} issue(s) to patch in ${rel}/`));
2176
+ const results = await interactivePatch(patchIssues, { apiKey, model: opts.model, yes: opts.yes });
2177
+ if (opts.json)
2178
+ return jsonOut({ directory: rel, results });
2179
+ const applied = results.filter(r => r.applied).length;
2180
+ console.log(`\n${green("✓")} ${applied}/${results.length} patch(es) applied`);
2181
+ console.log();
2182
+ });
2183
+ // ─── Command: doc ─────────────────────────────────────────────────────────────
2184
+ program
2185
+ .command("doc [dir]")
2186
+ .description("Generate Markdown + HTML API docs from skeletons")
2187
+ .option("-o, --out <file>", "Output file (default: stdout for md, .ast-map/api.html for html)")
2188
+ .option("--html", "Emit HTML instead of Markdown")
2189
+ .option("--exported-only", "Only include exported symbols (default: true)", true)
2190
+ .option("--ai", "Use Claude API to add descriptions (requires ANTHROPIC_API_KEY)")
2191
+ .option("--api-key <key>", "Anthropic API key")
2192
+ .option("--model <id>", "Claude model ID")
2193
+ .option("--json", "Output raw DocOutput JSON")
2194
+ .action(async (dir, opts) => {
2195
+ const { abs, rel } = resolveArg(dir ?? ".");
2196
+ if (!fs.statSync(abs).isDirectory())
2197
+ die(`"${rel}" is not a directory`);
2198
+ const skeletons = await gatherSkeletons(abs, "full");
2199
+ let output = buildDocOutput(skeletons, { exportedOnly: opts.exportedOnly });
2200
+ if (opts.ai) {
2201
+ const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY;
2202
+ if (!apiKey)
2203
+ die("ANTHROPIC_API_KEY not set — pass --api-key or set the env var");
2204
+ console.log(dim("Enhancing descriptions with Claude…"));
2205
+ output = await aiEnhanceDocs(output, { apiKey, model: opts.model });
2206
+ }
2207
+ if (opts.json)
2208
+ return jsonOut(output);
2209
+ if (opts.html) {
2210
+ const html = renderDocHtml(output);
2211
+ const outFile = opts.out ?? path.join(ROOT, ".ast-map", "api.html");
2212
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
2213
+ fs.writeFileSync(outFile, html, "utf8");
2214
+ console.log(green("✓") + ` HTML API docs → ${outFile}`);
2215
+ }
2216
+ else {
2217
+ const md = renderMarkdown(output);
2218
+ if (opts.out) {
2219
+ fs.writeFileSync(opts.out, md, "utf8");
2220
+ console.log(green("✓") + ` Markdown API docs → ${opts.out}`);
2221
+ }
2222
+ else {
2223
+ console.log(md);
2224
+ }
2225
+ }
2226
+ console.log();
2227
+ });
1248
2228
  // ─── Root metadata ────────────────────────────────────────────────────────────
1249
2229
  program
1250
2230
  .name("ast-map")
1251
2231
  .description("CLI for universal-ast-mapper — structural code analysis tools")
1252
2232
  .version("0.5.3")
1253
- .addHelpText("after", `
1254
- ${bold("Examples:")}
1255
- ast-map langs
1256
- ast-map skeleton src/
1257
- ast-map symbol src/utils.ts sanitize --related
1258
- ast-map imports src/pages/login.tsx
1259
- ast-map graph src/ -o graph.json
1260
- ast-map validate src/
1261
- ast-map dead src/
1262
- ast-map cycles src/
1263
- ast-map search validateSession src/ --exported
1264
- ast-map deps src/lib/auth.ts --scan src/
1265
- ast-map top src/ -n 15
1266
- ast-map impact src/utils.ts sanitize --scan src/
1267
- ast-map calls src/utils.ts buildCallGraph --scan src/
1268
-
1269
- ${bold("Root:")}
1270
- Defaults to cwd. Override with AST_MAP_ROOT=<path> or run from your project root.
2233
+ .addHelpText("after", `
2234
+ ${bold("Examples:")}
2235
+ ast-map langs
2236
+ ast-map skeleton src/
2237
+ ast-map symbol src/utils.ts sanitize --related
2238
+ ast-map imports src/pages/login.tsx
2239
+ ast-map graph src/ -o graph.json
2240
+ ast-map validate src/
2241
+ ast-map dead src/
2242
+ ast-map cycles src/
2243
+ ast-map search validateSession src/ --exported
2244
+ ast-map deps src/lib/auth.ts --scan src/
2245
+ ast-map top src/ -n 15
2246
+ ast-map impact src/utils.ts sanitize --scan src/
2247
+ ast-map calls src/utils.ts buildCallGraph --scan src/
2248
+ ast-map dashboard src/ -o dash.html
2249
+ ast-map history
2250
+ ast-map watch src/ --port 4321
2251
+ ast-map testgen src/utils.ts --framework vitest
2252
+ ast-map testgen src/utils.ts --framework vitest --ai
2253
+ ast-map testgen src/ --uncovered --framework jest --ai
2254
+ ast-map smells src/
2255
+ ast-map security src/ --severity high
2256
+ ast-map diagram src/ --type deps -o graph.md --md
2257
+ ast-map fix src/ --priority 2
2258
+ ast-map fix src/ --ai
2259
+ ast-map init
2260
+ ast-map init --defaults
2261
+ ast-map explain src/utils.ts buildReport
2262
+ ast-map explain src/utils.ts buildReport --ai
2263
+ ast-map similar src/
2264
+ ast-map serve src/ --port 7337
2265
+ ast-map covmerge coverage/coverage-summary.json --dir src/
2266
+ ast-map plugins src/
2267
+ ast-map smells src/ --changed-since HEAD
2268
+ ast-map security src/ --changed-since main
2269
+ ast-map index src/
2270
+ ast-map arch src/
2271
+ ast-map patch src/ --severity high
2272
+ ast-map patch src/ -y
2273
+ ast-map doc src/
2274
+ ast-map doc src/ --html -o .ast-map/api.html
2275
+ ast-map doc src/ --ai
2276
+ ast-map find "parse config" src/ --rerank
2277
+
2278
+ ${bold("Root:")}
2279
+ Defaults to cwd. Override with AST_MAP_ROOT=<path> or run from your project root.
1271
2280
  `);
1272
2281
  program.parseAsync(process.argv).catch(err => {
1273
2282
  console.error(red("Fatal: ") + (err instanceof Error ? err.message : String(err)));