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/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
|
|
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
|
-
|
|
1270
|
-
|
|
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)));
|