universal-ast-mapper 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +261 -12
  3. package/dist/ai-refactor.js +185 -0
  4. package/dist/ai-testgen.js +105 -0
  5. package/dist/analysis.js +134 -0
  6. package/dist/arch-rules.js +82 -0
  7. package/dist/callgraph.js +467 -0
  8. package/dist/check.js +112 -0
  9. package/dist/cli.js +2284 -0
  10. package/dist/complexity.js +98 -0
  11. package/dist/config.js +53 -0
  12. package/dist/contextpack.js +79 -0
  13. package/dist/coupling.js +35 -0
  14. package/dist/covmerge.js +176 -0
  15. package/dist/crosslang.js +425 -0
  16. package/dist/dashboard.js +259 -0
  17. package/dist/diagram.js +264 -0
  18. package/dist/diskcache.js +97 -0
  19. package/dist/docgen.js +156 -0
  20. package/dist/embeddings.js +136 -0
  21. package/dist/explain.js +123 -0
  22. package/dist/explorer.js +123 -0
  23. package/dist/extractors/c.js +204 -0
  24. package/dist/extractors/common.js +56 -0
  25. package/dist/extractors/cpp.js +272 -0
  26. package/dist/extractors/csharp.js +209 -0
  27. package/dist/extractors/go.js +212 -0
  28. package/dist/extractors/java.js +152 -0
  29. package/dist/extractors/kotlin.js +159 -0
  30. package/dist/extractors/php.js +208 -0
  31. package/dist/extractors/python.js +153 -0
  32. package/dist/extractors/ruby.js +146 -0
  33. package/dist/extractors/rust.js +249 -0
  34. package/dist/extractors/swift.js +192 -0
  35. package/dist/extractors/typescript.js +577 -0
  36. package/dist/fix.js +92 -0
  37. package/dist/gitdiff.js +178 -0
  38. package/dist/graph-analysis.js +279 -0
  39. package/dist/graph.js +165 -0
  40. package/dist/history.js +36 -0
  41. package/dist/html.js +658 -0
  42. package/dist/incremental.js +122 -0
  43. package/dist/index.js +1945 -0
  44. package/dist/indexstore.js +105 -0
  45. package/dist/layers.js +36 -0
  46. package/dist/lsp.js +238 -0
  47. package/dist/modulecoupling.js +0 -0
  48. package/dist/parser.js +84 -0
  49. package/dist/patch.js +199 -0
  50. package/dist/plugins.js +88 -0
  51. package/dist/pool.js +114 -0
  52. package/dist/prompts.js +67 -0
  53. package/dist/registry.js +87 -0
  54. package/dist/report.js +441 -0
  55. package/dist/resolver.js +222 -0
  56. package/dist/roots.js +47 -0
  57. package/dist/search.js +68 -0
  58. package/dist/security.js +178 -0
  59. package/dist/semantic.js +365 -0
  60. package/dist/serve.js +185 -0
  61. package/dist/sfc.js +27 -0
  62. package/dist/similar.js +98 -0
  63. package/dist/skeleton.js +132 -0
  64. package/dist/smells.js +285 -0
  65. package/dist/sourcemap.js +60 -0
  66. package/dist/testgen.js +280 -0
  67. package/dist/testmap.js +167 -0
  68. package/dist/tsconfig.js +212 -0
  69. package/dist/typeflow.js +124 -0
  70. package/dist/types.js +5 -0
  71. package/dist/unused-params.js +127 -0
  72. package/dist/webapp.js +341 -0
  73. package/dist/worker.js +27 -0
  74. package/dist/workspace.js +330 -0
  75. package/package.json +2 -1
package/dist/cli.js ADDED
@@ -0,0 +1,2284 @@
1
+ #!/usr/bin/env node
2
+ import { program } from "commander";
3
+ import path from "node:path";
4
+ import fs from "node:fs";
5
+ import { buildSkeleton, collectSourceFiles } from "./skeleton.js";
6
+ import { renderHtml, renderCombinedHtml } from "./html.js";
7
+ import { resolveOptions, loadProjectConfig } from "./config.js";
8
+ import { initDiskCache, defaultCacheDir, diskCacheStats, clearDiskCache } from "./diskcache.js";
9
+ import { buildSkeletonsBulk } from "./pool.js";
10
+ import { supportedLanguages } from "./registry.js";
11
+ import { findSymbol, findRelatedSymbols, findServerImports, isApiRoute, findMissingTryCatch, checkGeneralRules, GENERAL_RULE_DEFAULTS } from "./analysis.js";
12
+ import { resolveFileImports } from "./resolver.js";
13
+ import { buildSymbolGraph } from "./graph.js";
14
+ import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols, findDuplicateSymbols } from "./graph-analysis.js";
15
+ import { computeFileComplexity } from "./complexity.js";
16
+ import { findUnusedParams } from "./unused-params.js";
17
+ import { traceTypeInFile } from "./typeflow.js";
18
+ import { discoverWorkspace, findPackageCycles } from "./workspace.js";
19
+ import { buildExplorerHtml } from "./explorer.js";
20
+ import { readSourceMap } from "./sourcemap.js";
21
+ import { buildReport, buildReportHtml } from "./report.js";
22
+ import { appendHistory, loadHistory } from "./history.js";
23
+ import { buildDashboardHtml } from "./dashboard.js";
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";
32
+ import { computeDiff, computeRisk, isGitRepo } from "./gitdiff.js";
33
+ import { packContext } from "./contextpack.js";
34
+ import { computeCoupling } from "./coupling.js";
35
+ import { findLayerViolations } from "./layers.js";
36
+ import { computeModuleCoupling } from "./modulecoupling.js";
37
+ import { buildCallGraph } from "./callgraph.js";
38
+ import { searchSymbols } from "./search.js";
39
+ import { semanticSearch } from "./semantic.js";
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";
52
+ import { parseRootsFromEnv } from "./roots.js";
53
+ const ROOT = parseRootsFromEnv().roots[0]; // CLI is local — no boundary, primary root only
54
+ // Persistent parse cache (disable with AST_MAP_NO_CACHE=1 or "cache": false in config).
55
+ if (process.env.AST_MAP_NO_CACHE !== "1" && loadProjectConfig(ROOT).cache !== false) {
56
+ initDiskCache(defaultCacheDir(ROOT));
57
+ }
58
+ // ─── ANSI colours (disabled when not a TTY) ───────────────────────────────────
59
+ const tty = process.stdout.isTTY ?? false;
60
+ const esc = (code) => (s) => tty ? `\x1b[${code}m${s}\x1b[0m` : s;
61
+ const bold = esc("1");
62
+ const dim = esc("2");
63
+ const red = esc("31");
64
+ const green = esc("32");
65
+ const yellow = esc("33");
66
+ const blue = esc("34");
67
+ const cyan = esc("36");
68
+ const gray = esc("90");
69
+ // ─── Layout helpers ───────────────────────────────────────────────────────────
70
+ function header(title) {
71
+ console.log(`\n${bold(title)}`);
72
+ console.log(dim("─".repeat(Math.min(title.length + 4, 60))));
73
+ }
74
+ function indent(s, n = 2) { return " ".repeat(n) + s; }
75
+ function col(s, w) { return s.padEnd(w).slice(0, w); }
76
+ /** Minimal ASCII table — cols is array of [header, width] */
77
+ function table(rows, cols) {
78
+ const header_row = cols.map(([h, w]) => bold(col(h, w))).join(" ");
79
+ const sep = cols.map(([, w]) => dim("─".repeat(w))).join(" ");
80
+ console.log(indent(header_row));
81
+ console.log(indent(sep));
82
+ for (const row of rows) {
83
+ console.log(indent(row.map((cell, i) => col(cell, cols[i][1])).join(" ")));
84
+ }
85
+ }
86
+ function jsonOut(data) {
87
+ console.log(JSON.stringify(data, null, 2));
88
+ }
89
+ // ─── Shared utilities ─────────────────────────────────────────────────────────
90
+ function resolveArg(p) {
91
+ const abs = path.resolve(ROOT, p);
92
+ const rel = path.relative(ROOT, abs).split(path.sep).join("/") || ".";
93
+ return { abs, rel };
94
+ }
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
+ }
104
+ const opts = resolveOptions({ detail, emitHtml: false });
105
+ const files = collectSourceFiles(dirAbs, opts);
106
+ const items = files.map((f) => ({ abs: f, rel: path.relative(ROOT, f).split(path.sep).join("/") }));
107
+ const built = await buildSkeletonsBulk(items, opts);
108
+ return built.filter((r) => r !== null).map((r) => r.skel);
109
+ }
110
+ function die(msg) {
111
+ console.error(red("✗") + " " + msg);
112
+ process.exit(1);
113
+ }
114
+ // ─── Command: cache ───────────────────────────────────────────────────────────
115
+ program
116
+ .command("cache [action]")
117
+ .description("Inspect or clear the persistent parse cache (actions: stats, clear)")
118
+ .option("--json", "Output as JSON")
119
+ .action((action, opts) => {
120
+ const dir = defaultCacheDir(ROOT);
121
+ if (action === "clear") {
122
+ const removed = clearDiskCache(dir);
123
+ if (opts.json)
124
+ jsonOut({ dir, removed });
125
+ else
126
+ console.log(green("\u2713") + ` cleared ${removed} cached ${removed === 1 ? "entry" : "entries"} (${dir})`);
127
+ return;
128
+ }
129
+ const stats = diskCacheStats(dir);
130
+ if (opts.json)
131
+ jsonOut(stats);
132
+ else {
133
+ console.log(bold("Parse cache") + " " + dim(stats.dir));
134
+ console.log(` entries: ${stats.entries}`);
135
+ console.log(` size: ${(stats.bytes / 1024).toFixed(1)} KB`);
136
+ }
137
+ });
138
+ // ─── Command: langs ───────────────────────────────────────────────────────────
139
+ program
140
+ .command("langs")
141
+ .description("List all supported languages and file extensions")
142
+ .option("--json", "Output as JSON")
143
+ .action((opts) => {
144
+ const langs = supportedLanguages();
145
+ if (opts.json)
146
+ return jsonOut({ root: ROOT, languages: langs });
147
+ header("Supported Languages");
148
+ for (const { language, extensions } of langs) {
149
+ console.log(indent(`${cyan(col(language, 14))} ${dim(extensions.join(" "))}`));
150
+ }
151
+ console.log();
152
+ });
153
+ // ─── Command: skeleton ────────────────────────────────────────────────────────
154
+ program
155
+ .command("skeleton <path>")
156
+ .description("Parse a file or directory into a normalized code skeleton")
157
+ .option("-d, --detail <level>", "outline or full", "outline")
158
+ .option("--html", "Write per-file HTML views to .ast-map/")
159
+ .option("--combine", "Write a combined index.html (directory mode only)")
160
+ .option("-o, --output <dir>", "HTML output directory (default: .ast-map)")
161
+ .option("--json", "Output raw skeleton JSON")
162
+ .action(async (inputPath, opts) => {
163
+ const { abs, rel } = resolveArg(inputPath);
164
+ const detail = (opts.detail ?? "outline");
165
+ const skOpts = resolveOptions({ detail, emitHtml: opts.html, combineHtml: opts.combine, outputDir: opts.output });
166
+ try {
167
+ if (fs.statSync(abs).isDirectory()) {
168
+ const files = collectSourceFiles(abs, skOpts);
169
+ const skeletons = [];
170
+ const errors = [];
171
+ for (const file of files) {
172
+ const fr = path.relative(ROOT, file).split(path.sep).join("/");
173
+ try {
174
+ const skel = await buildSkeleton(file, fr, skOpts);
175
+ skeletons.push(skel);
176
+ if (opts.html) {
177
+ const outDir = opts.output ? path.resolve(ROOT, opts.output) : path.join(ROOT, ".ast-map");
178
+ fs.mkdirSync(path.dirname(path.join(outDir, fr)), { recursive: true });
179
+ fs.writeFileSync(path.join(outDir, `${fr}-skeleton.html`), renderHtml(skel), "utf8");
180
+ }
181
+ }
182
+ catch (e) {
183
+ errors.push(`${fr}: ${e instanceof Error ? e.message : String(e)}`);
184
+ }
185
+ }
186
+ let combinedPath = null;
187
+ if (opts.combine && skeletons.length > 0) {
188
+ const outDir = opts.output ? path.resolve(ROOT, opts.output) : path.join(ROOT, ".ast-map");
189
+ fs.mkdirSync(outDir, { recursive: true });
190
+ combinedPath = path.join(outDir, "index.html");
191
+ fs.writeFileSync(combinedPath, renderCombinedHtml(skeletons), "utf8");
192
+ }
193
+ if (opts.json)
194
+ return jsonOut(skeletons);
195
+ header(`Skeleton — ${rel}/ (${skeletons.length} files)`);
196
+ table(skeletons.map(s => [s.file, s.language, String(s.symbolCount)]), [["File", 44], ["Lang", 12], ["Symbols", 7]]);
197
+ if (errors.length > 0) {
198
+ console.log(`\n${yellow("Errors:")} ${errors.length}`);
199
+ for (const e of errors)
200
+ console.log(indent(dim(e)));
201
+ }
202
+ if (combinedPath)
203
+ console.log(`\n${green("✓")} Combined HTML → ${combinedPath}`);
204
+ console.log();
205
+ }
206
+ else {
207
+ const skel = await buildSkeleton(abs, rel, skOpts);
208
+ if (opts.json)
209
+ return jsonOut(skel);
210
+ header(`Skeleton — ${skel.file} ${dim("(" + skel.language + ")")}`);
211
+ for (const sym of skel.symbols) {
212
+ const exp = sym.exported ? green(" ✓") : "";
213
+ const range = dim(`L${sym.range.startLine}–${sym.range.endLine}`);
214
+ console.log(indent(`${cyan(col(sym.kind, 12))} ${bold(sym.name)}${exp} ${range}`));
215
+ for (const child of sym.children) {
216
+ console.log(indent(indent(`${dim(col(child.kind, 12))} ${dim(child.name)} ${dim(`L${child.range.startLine}`)}`)));
217
+ }
218
+ }
219
+ if (opts.html) {
220
+ const outDir = opts.output ? path.resolve(ROOT, opts.output) : path.join(ROOT, ".ast-map");
221
+ const htmlPath = path.join(outDir, `${rel}-skeleton.html`);
222
+ fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
223
+ fs.writeFileSync(htmlPath, renderHtml(skel), "utf8");
224
+ console.log(`\n${green("✓")} HTML → ${htmlPath}`);
225
+ }
226
+ console.log();
227
+ }
228
+ }
229
+ catch (e) {
230
+ die(e instanceof Error ? e.message : String(e));
231
+ }
232
+ });
233
+ // ─── Command: symbol ──────────────────────────────────────────────────────────
234
+ program
235
+ .command("symbol <file> <name>")
236
+ .description("Extract exact source lines of a named symbol")
237
+ .option("-k, --kind <kind>", "Narrow by symbol kind (function/class/etc)")
238
+ .option("--related", "Also show related types referenced in the signature")
239
+ .option("--json", "Output as JSON")
240
+ .action(async (inputPath, name, opts) => {
241
+ const { abs, rel } = resolveArg(inputPath);
242
+ try {
243
+ const source = fs.readFileSync(abs, "utf8");
244
+ const sourceLines = source.split("\n");
245
+ const skOpts = resolveOptions({ detail: "full", emitHtml: false });
246
+ const skel = await buildSkeleton(abs, rel, skOpts);
247
+ const found = findSymbol(skel.symbols, name, opts.kind);
248
+ if (!found)
249
+ die(`Symbol "${name}" not found in ${rel}`);
250
+ const code = sourceLines.slice(found.range.startLine - 1, found.range.endLine).join("\n");
251
+ const related = opts.related ? findRelatedSymbols(skel.symbols, found, sourceLines) : [];
252
+ if (opts.json)
253
+ return jsonOut({ file: rel, symbol: found.name, kind: found.kind, range: found.range, code, related });
254
+ header(`${found.kind} ${bold(found.name)} ${dim(rel)}`);
255
+ console.log(dim(` Lines ${found.range.startLine}–${found.range.endLine}\n`));
256
+ console.log(code);
257
+ if (related.length > 0) {
258
+ console.log(`\n${bold("Related types:")}`);
259
+ for (const r of related) {
260
+ console.log(`\n${dim(`── ${r.name} (${r.kind})`)} ${dim(`L${r.range.startLine}`)}`);
261
+ console.log(r.code);
262
+ }
263
+ }
264
+ console.log();
265
+ }
266
+ catch (e) {
267
+ die(e instanceof Error ? e.message : String(e));
268
+ }
269
+ });
270
+ // ─── Command: imports ─────────────────────────────────────────────────────────
271
+ program
272
+ .command("imports <file>")
273
+ .description("Resolve all import statements to their source definitions")
274
+ .option("--json", "Output as JSON")
275
+ .action(async (inputPath, opts) => {
276
+ const { abs, rel } = resolveArg(inputPath);
277
+ try {
278
+ const skOpts = resolveOptions({ detail: "full", emitHtml: false });
279
+ const skel = await buildSkeleton(abs, rel, skOpts);
280
+ const resolved = await resolveFileImports(skel, abs, ROOT);
281
+ if (opts.json)
282
+ return jsonOut({ file: rel, importCount: resolved.length, resolved });
283
+ header(`Imports — ${rel} (${resolved.length})`);
284
+ for (const r of resolved) {
285
+ const status = r.found ? green("✓") : r.importKind === "external" ? blue("pkg") : red("✗");
286
+ const alias = r.alias ? dim(` as ${r.alias}`) : "";
287
+ const target = r.resolvedRel ?? r.from;
288
+ const kind = r.kind ? dim(` [${r.kind}]`) : "";
289
+ console.log(indent(`${status} ${col(r.symbol, 28)}${alias}${kind} ${dim(target)}`));
290
+ }
291
+ console.log();
292
+ }
293
+ catch (e) {
294
+ die(e instanceof Error ? e.message : String(e));
295
+ }
296
+ });
297
+ // ─── Command: graph ───────────────────────────────────────────────────────────
298
+ program
299
+ .command("graph <dir>")
300
+ .description("Build and inspect the symbol-level dependency graph")
301
+ .option("-d, --detail <level>", "outline or full", "outline")
302
+ .option("-o, --out <file>", "Write graph JSON to a file")
303
+ .option("--json", "Output graph as JSON (stdout)")
304
+ .action(async (inputPath, opts) => {
305
+ const { abs, rel } = resolveArg(inputPath);
306
+ if (!fs.statSync(abs).isDirectory())
307
+ die(`"${rel}" is not a directory`);
308
+ const skeletons = await gatherSkeletons(abs, (opts.detail ?? "outline"));
309
+ const graph = buildSymbolGraph(skeletons, ROOT);
310
+ if (opts.out) {
311
+ const outAbs = path.resolve(ROOT, opts.out);
312
+ fs.mkdirSync(path.dirname(outAbs), { recursive: true });
313
+ fs.writeFileSync(outAbs, JSON.stringify(graph, null, 2), "utf8");
314
+ console.log(green("✓") + ` Graph written → ${opts.out}`);
315
+ return;
316
+ }
317
+ if (opts.json)
318
+ return jsonOut(graph);
319
+ const importEdges = graph.edges.filter(e => e.edgeType === "imports").length;
320
+ header(`Symbol Graph — ${rel}/`);
321
+ console.log(indent(`${bold("Files:")} ${graph.stats.fileCount}`));
322
+ console.log(indent(`${bold("Symbols:")} ${graph.stats.symbolNodeCount}`));
323
+ console.log(indent(`${bold("Edges:")} ${graph.stats.edgeCount} ${dim(`(${importEdges} cross-file imports)`)}`));
324
+ console.log();
325
+ });
326
+ // ─── Command: validate ────────────────────────────────────────────────────────
327
+ program
328
+ .command("validate <path>")
329
+ .description("Scan for architecture violations (boundary rules + general structural rules)")
330
+ .option("--max-lines <n>", `Flag files over N lines (default: ${GENERAL_RULE_DEFAULTS.largeFileLines})`)
331
+ .option("--max-imports <n>", `Flag files with over N imports (default: ${GENERAL_RULE_DEFAULTS.tooManyImports})`)
332
+ .option("--max-exports <n>", `Flag files with over N exports (default: ${GENERAL_RULE_DEFAULTS.godExportCount})`)
333
+ .option("--json", "Output as JSON")
334
+ .action(async (inputPath, opts) => {
335
+ const { abs } = resolveArg(inputPath);
336
+ const projectConfig = loadProjectConfig(ROOT);
337
+ const skOpts = resolveOptions({ detail: "full", emitHtml: false }, projectConfig);
338
+ const stat = fs.statSync(abs);
339
+ const filesToCheck = stat.isDirectory() ? collectSourceFiles(abs, skOpts) : [abs];
340
+ const thresholds = {
341
+ largeFileLines: opts.maxLines
342
+ ? parseInt(opts.maxLines, 10)
343
+ : (projectConfig.rules?.["large-file"]?.maxLines ?? GENERAL_RULE_DEFAULTS.largeFileLines),
344
+ tooManyImports: opts.maxImports
345
+ ? parseInt(opts.maxImports, 10)
346
+ : (projectConfig.rules?.["too-many-imports"]?.maxImports ?? GENERAL_RULE_DEFAULTS.tooManyImports),
347
+ godExportCount: opts.maxExports
348
+ ? parseInt(opts.maxExports, 10)
349
+ : (projectConfig.rules?.["god-export"]?.maxExports ?? GENERAL_RULE_DEFAULTS.godExportCount),
350
+ };
351
+ const violations = [];
352
+ for (const file of filesToCheck) {
353
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
354
+ let source;
355
+ try {
356
+ source = fs.readFileSync(file, "utf8");
357
+ }
358
+ catch {
359
+ continue;
360
+ }
361
+ let skel;
362
+ try {
363
+ skel = await buildSkeleton(file, fileRel, skOpts);
364
+ }
365
+ catch {
366
+ continue;
367
+ }
368
+ if (skel.directives?.includes("use client")) {
369
+ for (const imp of findServerImports(source)) {
370
+ violations.push({ file: fileRel, rule: "client-server-boundary", severity: "error",
371
+ message: `"use client" imports server-only module "${imp.label}" (${imp.module})`, line: imp.line });
372
+ }
373
+ }
374
+ if (isApiRoute(fileRel)) {
375
+ const sourceLines = source.split("\n");
376
+ for (const sym of findMissingTryCatch(skel.symbols, sourceLines)) {
377
+ violations.push({ file: fileRel, rule: "api-missing-try-catch", severity: "warning",
378
+ message: `API handler "${sym.name}" has no try/catch`, line: sym.range.startLine });
379
+ }
380
+ }
381
+ const importCount = skel.imports?.length ?? 0;
382
+ for (const v of checkGeneralRules(fileRel, source, skel.symbols, importCount, thresholds)) {
383
+ violations.push(v);
384
+ }
385
+ }
386
+ if (opts.json)
387
+ return jsonOut({ scanned: filesToCheck.length, violations });
388
+ const errors = violations.filter(v => v.severity === "error");
389
+ const warnings = violations.filter(v => v.severity === "warning");
390
+ header(`Validate — ${filesToCheck.length} files scanned`);
391
+ if (violations.length === 0) {
392
+ console.log(indent(green("✓ No architecture violations found.")));
393
+ }
394
+ else {
395
+ for (const v of violations) {
396
+ const icon = v.severity === "error" ? red("✗") : yellow("⚠");
397
+ const line = v.line ? dim(`:${v.line}`) : "";
398
+ console.log(indent(`${icon} ${dim(v.file + line)} ${v.message}`));
399
+ }
400
+ console.log(`\n ${red(`${errors.length} error(s)`)}, ${yellow(`${warnings.length} warning(s)`)}`);
401
+ }
402
+ console.log();
403
+ });
404
+ // ─── Command: dead ────────────────────────────────────────────────────────────
405
+ program
406
+ .command("dead <dir>")
407
+ .description("Find exported symbols that are never imported within the directory")
408
+ .option("--json", "Output as JSON")
409
+ .action(async (inputPath, opts) => {
410
+ const { abs, rel } = resolveArg(inputPath);
411
+ if (!fs.statSync(abs).isDirectory())
412
+ die(`"${rel}" is not a directory`);
413
+ const skeletons = await gatherSkeletons(abs);
414
+ const graph = buildSymbolGraph(skeletons, ROOT);
415
+ const dead = findDeadExports(graph);
416
+ if (opts.json)
417
+ return jsonOut({ directory: rel, scanned: skeletons.length, deadExportCount: dead.length, deadExports: dead });
418
+ const highConf = dead.filter(d => d.confidence === "high");
419
+ const lowConf = dead.filter(d => d.confidence === "low");
420
+ header(`Dead Code — ${rel}/ ${dim(`(${skeletons.length} files scanned)`)}`);
421
+ if (dead.length === 0) {
422
+ console.log(indent(green("✓ No dead exports found.")));
423
+ }
424
+ else {
425
+ if (highConf.length > 0) {
426
+ console.log(indent(`${bold("High confidence")} ${dim("— functions / classes / consts")}`));
427
+ table(highConf.map(d => [d.file, d.symbol, d.kind]), [["File", 44], ["Symbol", 28], ["Kind", 10]]);
428
+ }
429
+ if (lowConf.length > 0) {
430
+ console.log(`\n${indent(`${bold("Low confidence")} ${dim("— types / interfaces / enums (may be used as type annotations)")}`)}`);
431
+ table(lowConf.map(d => [d.file, d.symbol, d.kind]), [["File", 44], ["Symbol", 28], ["Kind", 10]]);
432
+ }
433
+ console.log(`\n ${yellow(`${highConf.length} high`)} · ${dim(`${lowConf.length} low`)} confidence dead export(s)`);
434
+ }
435
+ console.log();
436
+ });
437
+ // ─── Command: watch ───────────────────────────────────────────────────────────
438
+ program
439
+ .command("watch [dir]")
440
+ .description("Rebuild analysis when files change; optionally serve a live-reload dashboard")
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)")
444
+ .action(async (dir, opts) => {
445
+ const { abs, rel } = resolveArg(dir ?? ".");
446
+ if (!fs.statSync(abs).isDirectory())
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
+ }
469
+ let building = false;
470
+ let queued = false;
471
+ async function rebuild(reason) {
472
+ if (building) {
473
+ queued = true;
474
+ return;
475
+ }
476
+ building = true;
477
+ try {
478
+ const skels = await gatherSkeletons(abs);
479
+ const graph = buildSymbolGraph(skels, ROOT);
480
+ const dead = findDeadExports(graph).filter((d) => d.confidence === "high").length;
481
+ const cycles = findCircularDeps(graph).length;
482
+ let line = `${dim(new Date().toLocaleTimeString())} ${bold(String(skels.length))} files · ${dead} dead · ${cycles} cycle(s)`;
483
+ if (opts.out) {
484
+ fs.writeFileSync(path.resolve(process.cwd(), opts.out), buildExplorerHtml(graph, abs), "utf8");
485
+ line += ` · ${green("explorer updated")}`;
486
+ }
487
+ if (opts.port) {
488
+ await buildDash(skels, graph);
489
+ broadcast();
490
+ line += ` · ${green("dashboard rebuilt")}`;
491
+ }
492
+ line += ` ${dim(reason)}`;
493
+ console.log(line);
494
+ }
495
+ finally {
496
+ building = false;
497
+ if (queued) {
498
+ queued = false;
499
+ rebuild("(coalesced)");
500
+ }
501
+ }
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
+ }
529
+ header(`Watching ${rel}/ ${dim("(Ctrl+C to stop)")}`);
530
+ await rebuild("initial");
531
+ let timer = null;
532
+ const exts = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rs", ".java", ".cs", ".c", ".cpp", ".h", ".hpp", ".kt", ".swift"]);
533
+ fs.watch(abs, { recursive: true }, (_evt, file) => {
534
+ if (!file)
535
+ return;
536
+ const f = String(file).split(path.sep).join("/");
537
+ if (/(^|\/)(node_modules|\.git|dist|\.ast-map)(\/|$)/.test(f))
538
+ return;
539
+ if (!exts.has(path.extname(f).toLowerCase()))
540
+ return;
541
+ if (timer)
542
+ clearTimeout(timer);
543
+ timer = setTimeout(() => rebuild(`(${f.split("/").pop()} changed)`), 300);
544
+ });
545
+ await new Promise(() => { }); // keep the process alive
546
+ });
547
+ // ─── Command: sourcemap ───────────────────────────────────────────────────────
548
+ program
549
+ .command("sourcemap <file>")
550
+ .description("Show the original sources a compiled file maps back to")
551
+ .option("--json", "Output as JSON")
552
+ .action(async (inputPath, opts) => {
553
+ const { abs, rel } = resolveArg(inputPath);
554
+ const info = readSourceMap(abs, rel);
555
+ if (!info)
556
+ die(`No source map found for "${rel}"`);
557
+ if (opts.json)
558
+ return jsonOut(info);
559
+ header(`Source Map — ${rel} ${dim("(" + info.mapKind + ")")}`);
560
+ for (const sourceFile of info.sources)
561
+ console.log(indent(green("←") + " " + sourceFile));
562
+ console.log(`\n ${info.sources.length} original source(s)` + (info.hasContent ? dim(" · embeds sourcesContent") : ""));
563
+ console.log();
564
+ });
565
+ // ─── Command: pack ────────────────────────────────────────────────────────────
566
+ program
567
+ .command("pack <file> [symbol]")
568
+ .description("Minimal context pack for a symbol (source + dep signatures + dependents)")
569
+ .option("--scan <dir>", "Directory to scan for dependents", ".")
570
+ .option("--json", "Output as JSON")
571
+ .action(async (file, symbol, opts) => {
572
+ const { abs, rel } = resolveArg(file);
573
+ if (fs.statSync(abs).isDirectory())
574
+ die(`"${rel}" is a directory; pass a file`);
575
+ const scanAbs = resolveArg(opts.scan).abs;
576
+ const pack = await packContext(abs, rel, ROOT, symbol, scanAbs);
577
+ if (opts.json)
578
+ return jsonOut(pack);
579
+ header(`Context Pack \u2014 ${rel}${symbol ? "::" + symbol : ""} ${dim("(~" + pack.tokenEstimate + " tokens)")}`);
580
+ console.log(indent(bold("Primary") + dim(` lines ${pack.primary.startLine}-${pack.primary.endLine}`)));
581
+ console.log();
582
+ console.log(indent(bold("Depends on:")));
583
+ if (pack.dependencies.length === 0)
584
+ console.log(indent(dim("(none in-project)"), 4));
585
+ for (const d of pack.dependencies) {
586
+ console.log(indent(green(d.file), 4));
587
+ for (const sym of d.symbols)
588
+ console.log(indent(dim((sym.signature || sym.name)), 6));
589
+ }
590
+ console.log();
591
+ console.log(indent(bold("Depended on by:")));
592
+ if (pack.dependents.length === 0)
593
+ console.log(indent(dim("(none found in scan)"), 4));
594
+ for (const dep of pack.dependents)
595
+ console.log(indent(yellow(dep.file), 4));
596
+ console.log();
597
+ });
598
+ // ─── Command: diff ────────────────────────────────────────────────────────────
599
+ program
600
+ .command("diff [base]")
601
+ .description("Symbols changed since a git ref + breaking changes + blast radius")
602
+ .option("--dir <dir>", "Limit to a subdirectory", ".")
603
+ .option("--json", "Output as JSON")
604
+ .action(async (base, opts) => {
605
+ if (!isGitRepo(ROOT))
606
+ die("not a git repository (or git is unavailable)");
607
+ const { abs, rel } = resolveArg(opts.dir);
608
+ const ref = base ?? "HEAD";
609
+ const d = await computeDiff(abs, ROOT, ref);
610
+ if (opts.json)
611
+ return jsonOut(d);
612
+ header(`Diff since ${bold(ref)} ${dim(`(${d.summary.filesChanged} file(s) · +${d.summary.added} ~${d.summary.modified} -${d.summary.removed})`)}`);
613
+ if (d.files.length === 0) {
614
+ console.log(indent(dim("No source-symbol changes.")));
615
+ console.log();
616
+ return;
617
+ }
618
+ for (const f of d.files) {
619
+ console.log(indent(`${bold(f.file)} ${dim("[" + f.status + "]")}`));
620
+ for (const a of f.added)
621
+ console.log(indent(green("+ ") + a.symbol + dim(a.exported ? " (exported)" : ""), 4));
622
+ for (const m of f.modified)
623
+ console.log(indent(yellow("~ ") + m.symbol + dim(m.exported ? " (exported)" : ""), 4));
624
+ for (const r of f.removed)
625
+ console.log(indent(red("- ") + r.symbol + dim(r.exported ? " (exported)" : ""), 4));
626
+ }
627
+ if (d.breaking.length > 0) {
628
+ console.log(`\n${indent(bold(red("\u26a0 Breaking changes (" + d.breaking.length + ")")))}`);
629
+ for (const b of d.breaking)
630
+ console.log(indent(`${red(b.symbol)} ${dim(b.reason)} ${dim(b.file)}`, 4));
631
+ console.log(`\n${indent(yellow(d.impactedFiles.length + " file(s) impacted") + dim(" by breaking changes"))}`);
632
+ for (const f of d.impactedFiles.slice(0, 20))
633
+ console.log(indent(dim(f), 4));
634
+ }
635
+ console.log();
636
+ });
637
+ // ─── Command: risk ────────────────────────────────────────────────────────────
638
+ program
639
+ .command("risk [dir]")
640
+ .description("Rank files by refactor risk (git churn × complexity)")
641
+ .option("--json", "Output as JSON")
642
+ .option("-n, --top <n>", "Show top N", (v) => parseInt(v, 10), 15)
643
+ .action(async (dir, opts) => {
644
+ if (!isGitRepo(ROOT))
645
+ die("not a git repository (or git is unavailable)");
646
+ const { abs, rel } = resolveArg(dir ?? ".");
647
+ const files = await computeRisk(abs, ROOT);
648
+ if (opts.json)
649
+ return jsonOut({ count: files.length, files });
650
+ header(`Refactor Risk \u2014 ${rel}/ ${dim("(churn × max complexity)")}`);
651
+ if (files.length === 0) {
652
+ console.log(indent(green("✓ nothing risky (no churn × complexity)")));
653
+ console.log();
654
+ return;
655
+ }
656
+ table(files.slice(0, opts.top).map((f) => [String(f.risk), `${f.churn} × ${f.maxComplexity}`, f.file]), [["Risk", 7], ["churn×cx", 12], ["File", 44]]);
657
+ console.log();
658
+ });
659
+ // ─── Command: coupling ────────────────────────────────────────────────────────
660
+ program
661
+ .command("coupling [dir]")
662
+ .description("Per-file coupling metrics: afferent (Ca), efferent (Ce), instability")
663
+ .option("--json", "Output as JSON")
664
+ .option("-n, --top <n>", "Show top N by total coupling", (v) => parseInt(v, 10), 25)
665
+ .action(async (dir, opts) => {
666
+ const { abs, rel } = resolveArg(dir ?? ".");
667
+ if (!fs.statSync(abs).isDirectory())
668
+ die(`"${rel}" is not a directory`);
669
+ const skeletons = await gatherSkeletons(abs);
670
+ const metrics = computeCoupling(buildSymbolGraph(skeletons, ROOT));
671
+ if (opts.json)
672
+ return jsonOut({ count: metrics.length, files: metrics });
673
+ header(`Coupling \u2014 ${rel}/ ${dim("(Ca = fan-in, Ce = fan-out, I = instability)")}`);
674
+ if (metrics.length === 0) {
675
+ console.log(indent(dim("No import edges found.")));
676
+ console.log();
677
+ return;
678
+ }
679
+ const icolor = (i) => (i >= 0.8 ? red : i <= 0.2 ? green : yellow);
680
+ table(metrics.slice(0, opts.top).map((m) => [String(m.afferent), String(m.efferent), icolor(m.instability)(m.instability.toFixed(2)), m.file]), [["Ca", 4], ["Ce", 4], ["I", 6], ["File", 46]]);
681
+ console.log(indent(dim("high Ca = load-bearing (break carefully) · high I = volatile")));
682
+ console.log();
683
+ });
684
+ // ─── Command: layers ──────────────────────────────────────────────────────────
685
+ program
686
+ .command("layers [dir]")
687
+ .alias("sdp")
688
+ .description("Stable Dependencies Principle: stable files that depend on volatile ones")
689
+ .option("--json", "Output as JSON")
690
+ .option("-g, --min-gap <n>", "Only show violations with instability gap > n", (v) => parseFloat(v), 0)
691
+ .action(async (dir, opts) => {
692
+ const { abs, rel } = resolveArg(dir ?? ".");
693
+ if (!fs.statSync(abs).isDirectory())
694
+ die(`"${rel}" is not a directory`);
695
+ const skeletons = await gatherSkeletons(abs);
696
+ const violations = findLayerViolations(buildSymbolGraph(skeletons, ROOT), opts.minGap);
697
+ if (opts.json)
698
+ return jsonOut({ count: violations.length, violations });
699
+ header(`Layer Violations \u2014 ${rel}/ ${dim("(stable \u2192 volatile dependencies, SDP)")}`);
700
+ if (violations.length === 0) {
701
+ console.log(indent(green("\u2713 No SDP violations \u2014 dependencies flow toward stability.")));
702
+ console.log();
703
+ return;
704
+ }
705
+ for (const v of violations) {
706
+ const sev = v.severity >= 0.4 ? red : v.severity >= 0.2 ? yellow : dim;
707
+ console.log(indent(`${sev(v.severity.toFixed(2))} ${bold(v.from)} ${dim(`(I=${v.fromInstability})`)} ${red("\u2192")} ${v.to} ${dim(`(I=${v.toInstability})`)}`));
708
+ }
709
+ console.log();
710
+ console.log(indent(dim(`${violations.length} stable file(s) depend on more volatile ones \u2014 they churn when those do`)));
711
+ console.log();
712
+ });
713
+ // ─── Command: modules ─────────────────────────────────────────────────────────
714
+ program
715
+ .command("modules [dir]")
716
+ .alias("mods")
717
+ .description("Directory/module-level coupling: per-module Ca / Ce / instability + edges")
718
+ .option("--json", "Output as JSON")
719
+ .action(async (dir, opts) => {
720
+ const { abs, rel } = resolveArg(dir ?? ".");
721
+ if (!fs.statSync(abs).isDirectory())
722
+ die(`"${rel}" is not a directory`);
723
+ const skeletons = await gatherSkeletons(abs);
724
+ const mc = computeModuleCoupling(buildSymbolGraph(skeletons, ROOT));
725
+ if (opts.json)
726
+ return jsonOut(mc);
727
+ header(`Module Coupling \u2014 ${rel}/ ${dim("(directory-level Ca / Ce / instability)")}`);
728
+ if (mc.modules.length === 0) {
729
+ console.log(indent(dim("No cross-module imports found.")));
730
+ console.log();
731
+ return;
732
+ }
733
+ const icolor = (i) => (i >= 0.8 ? red : i <= 0.2 ? green : yellow);
734
+ table(mc.modules.map((m) => [String(m.files), String(m.afferent), String(m.efferent), icolor(m.instability)(m.instability.toFixed(2)), m.module]), [["Files", 6], ["Ca", 4], ["Ce", 4], ["I", 6], ["Module", 40]]);
735
+ if (mc.edges.length) {
736
+ console.log(indent(bold("Inter-module dependencies:")));
737
+ for (const e of mc.edges.slice(0, 20))
738
+ console.log(indent(` ${e.from} ${dim("\u2192")} ${e.to} ${dim(`(${e.weight})`)}`));
739
+ }
740
+ console.log();
741
+ });
742
+ // ─── Command: report ──────────────────────────────────────────────────────────
743
+ program
744
+ .command("report [dir]")
745
+ .description("Generate a code-health dashboard (HTML)")
746
+ .option("-o, --out <file>", "Output HTML path", "ast-report.html")
747
+ .option("--json", "Print the report data as JSON")
748
+ .action(async (dir, opts) => {
749
+ const { abs, rel } = resolveArg(dir ?? ".");
750
+ if (!fs.statSync(abs).isDirectory())
751
+ die(`"${rel}" is not a directory`);
752
+ const data = await buildReport(abs, ROOT);
753
+ if (opts.json)
754
+ return jsonOut(data);
755
+ const history = appendHistory(ROOT, data);
756
+ const out = path.resolve(process.cwd(), opts.out);
757
+ fs.mkdirSync(path.dirname(out), { recursive: true });
758
+ fs.writeFileSync(out, buildReportHtml(data, history), "utf8");
759
+ header(`Code Health \u2014 ${rel}/ ${dim(`(${data.fileCount} files)`)}`);
760
+ const gcolor = data.grade === "A" || data.grade === "B" ? green : data.grade === "C" || data.grade === "D" ? yellow : (x) => x;
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)}%`));
762
+ console.log(indent(green("✓ wrote " + path.relative(process.cwd(), out))));
763
+ console.log();
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
+ });
830
+ // ─── Command: check ───────────────────────────────────────────────────────────
831
+ const num = (v) => Number.parseFloat(v);
832
+ program
833
+ .command("check [dir]")
834
+ .description("CI quality gate: absolute thresholds + baseline ratchet (cycles, dead exports, SDP, complexity, score)")
835
+ .option("--baseline <file>", `Baseline file (default ${BASELINE_FILENAME})`)
836
+ .option("--update-baseline", "Write current metrics as the new baseline")
837
+ .option("--max-cycles <n>", "Fail when circular dependencies exceed n", num)
838
+ .option("--max-dead-exports <n>", "Fail when dead exports exceed n", num)
839
+ .option("--max-sdp-violations <n>", "Fail when SDP/layer violations exceed n", num)
840
+ .option("--max-very-high-complexity <n>", "Fail when functions with complexity > 20 exceed n", num)
841
+ .option("--max-complexity <n>", "Fail when any function's complexity exceeds n", num)
842
+ .option("--min-score <n>", "Fail when the health score drops below n", num)
843
+ .option("--json", "Output the gate result as JSON")
844
+ .action(async (dir, o) => {
845
+ const { abs, rel } = resolveArg(dir ?? ".");
846
+ if (!fs.statSync(abs).isDirectory())
847
+ die(`"${rel}" is not a directory`);
848
+ const fromConfig = loadProjectConfig(ROOT).check ?? {};
849
+ const thresholds = {
850
+ maxCycles: o.maxCycles ?? fromConfig.maxCycles,
851
+ maxDeadExports: o.maxDeadExports ?? fromConfig.maxDeadExports,
852
+ maxSdpViolations: o.maxSdpViolations ?? fromConfig.maxSdpViolations,
853
+ maxVeryHighComplexity: o.maxVeryHighComplexity ?? fromConfig.maxVeryHighComplexity,
854
+ maxComplexity: o.maxComplexity ?? fromConfig.maxComplexity,
855
+ minScore: o.minScore ?? fromConfig.minScore,
856
+ };
857
+ const result = await runQualityGate(abs, ROOT, {
858
+ baselinePath: o.baseline,
859
+ thresholds,
860
+ updateBaseline: o.updateBaseline,
861
+ });
862
+ if (o.json) {
863
+ jsonOut(result);
864
+ if (!result.passed)
865
+ process.exit(1);
866
+ return;
867
+ }
868
+ header(`Quality gate \u2014 ${rel}/`);
869
+ const m = result.metrics;
870
+ const b = result.baseline;
871
+ const delta = (key) => b ? dim(` (baseline ${String(b[key])})`) : "";
872
+ console.log(indent(`score ${bold(String(m.score))}/100 (${m.grade})${delta("score")}`));
873
+ console.log(indent(`cycles ${m.cycles}${delta("cycles")} · dead exports ${m.deadExports}${delta("deadExports")} · SDP ${m.sdpViolations}${delta("sdpViolations")}`));
874
+ console.log(indent(`complexity: max ${m.maxComplexity} · very-high (>20) ${m.veryHighComplexity}${delta("veryHighComplexity")}`));
875
+ if (result.baselineUpdated) {
876
+ console.log(indent(green("\u2713") + " baseline updated: " + path.relative(process.cwd(), result.baselinePath)));
877
+ }
878
+ else if (!b) {
879
+ console.log(indent(dim(`no baseline (${path.relative(process.cwd(), result.baselinePath)}) \u2014 run with --update-baseline to create one`)));
880
+ }
881
+ if (result.failures.length > 0) {
882
+ console.log();
883
+ for (const f of result.failures) {
884
+ console.log(indent(red("\u2717") + ` [${f.kind}] ${f.message}`));
885
+ }
886
+ console.log();
887
+ console.log(indent(red(`gate FAILED \u2014 ${result.failures.length} violation(s)`)));
888
+ process.exit(1);
889
+ }
890
+ console.log(indent(green("\u2713 gate passed")));
891
+ console.log();
892
+ });
893
+ // ─── Command: explore ─────────────────────────────────────────────────────────
894
+ program
895
+ .command("explore [dir]")
896
+ .description("Generate an interactive HTML dependency-graph explorer")
897
+ .option("-o, --out <file>", "Output HTML path")
898
+ .action(async (dir, opts) => {
899
+ const { abs, rel } = resolveArg(dir ?? ".");
900
+ if (!fs.statSync(abs).isDirectory())
901
+ die(`"${rel}" is not a directory`);
902
+ const skeletons = await gatherSkeletons(abs);
903
+ const graph = buildSymbolGraph(skeletons, ROOT);
904
+ const html = buildExplorerHtml(graph, abs);
905
+ const outPath = opts.out
906
+ ? path.resolve(process.cwd(), opts.out)
907
+ : path.join(abs, "ast-explorer.html");
908
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
909
+ fs.writeFileSync(outPath, html, "utf8");
910
+ header(`Graph Explorer — ${rel}/ ${dim(`(${skeletons.length} files)`)}`);
911
+ console.log(indent(green("✓ wrote " + path.relative(process.cwd(), outPath))));
912
+ console.log(indent(dim("open it in a browser — drag nodes, scroll to zoom, click to highlight, filter by name")));
913
+ console.log();
914
+ });
915
+ // ─── Command: workspace ───────────────────────────────────────────────────────
916
+ program
917
+ .command("workspace [dir]")
918
+ .alias("ws")
919
+ .description("Discover monorepo packages and their internal dependency graph")
920
+ .option("--json", "Output as JSON")
921
+ .action(async (dir, opts) => {
922
+ const { abs, rel } = resolveArg(dir ?? ".");
923
+ if (!fs.statSync(abs).isDirectory())
924
+ die(`"${rel}" is not a directory`);
925
+ const info = discoverWorkspace(abs);
926
+ const cycles = findPackageCycles(info);
927
+ if (opts.json) {
928
+ return jsonOut({ root: rel, tool: info.tool, packageCount: info.packages.length, packages: info.packages, edges: info.edges, packageCycles: cycles });
929
+ }
930
+ header(`Workspace — ${rel}/ ${dim(`(${info.tool}, ${info.packages.length} package(s))`)}`);
931
+ if (info.packages.length === 0) {
932
+ console.log(indent(dim("No workspace packages found (no workspaces/pnpm-workspace.yaml/lerna.json).")));
933
+ }
934
+ else {
935
+ table(info.packages.map((p) => [
936
+ p.name,
937
+ p.dir,
938
+ p.internalDeps.length > 0 ? yellow(`→ ${p.internalDeps.join(", ")}`) : dim("(no internal deps)"),
939
+ ]), [["Package", 24], ["Dir", 22], ["Internal deps", 34]]);
940
+ if (cycles.length > 0) {
941
+ console.log(`\n${indent(bold(yellow("Circular package dependencies:")))}`);
942
+ for (const c of cycles)
943
+ console.log(indent(`${yellow("↻")} ${c.join(dim(" → "))}`));
944
+ }
945
+ console.log(`\n ${info.edges.length} internal edge(s)` + (cycles.length ? ` · ${yellow(`${cycles.length} cycle(s)`)}` : ""));
946
+ }
947
+ console.log();
948
+ });
949
+ // ─── Command: trace-type ──────────────────────────────────────────────────────
950
+ program
951
+ .command("trace-type <type> [dir]")
952
+ .alias("flow")
953
+ .description("Trace a type through params, returns, variables and fields")
954
+ .option("--json", "Output as JSON")
955
+ .action(async (typeName, dir, opts) => {
956
+ const { abs, rel } = resolveArg(dir ?? ".");
957
+ if (!fs.statSync(abs).isDirectory())
958
+ die(`"${rel}" is not a directory`);
959
+ const sopts = resolveOptions({ detail: "outline", emitHtml: false });
960
+ const refs = [];
961
+ for (const file of collectSourceFiles(abs, sopts)) {
962
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
963
+ refs.push(...(await traceTypeInFile(file, fileRel, typeName)));
964
+ }
965
+ if (opts.json)
966
+ return jsonOut({ type: typeName, dir: rel, refCount: refs.length, refs });
967
+ header(`Type Flow: ${bold(typeName)} — ${rel}/ ${dim(`(${refs.length} ref(s))`)}`);
968
+ if (refs.length === 0) {
969
+ console.log(indent(dim(`No references to type "${typeName}" found in signatures.`)));
970
+ }
971
+ else {
972
+ const roleColor = (r) => (r === "return" ? green : r === "param" ? yellow : dim);
973
+ table(refs.map((r) => [
974
+ roleColor(r.role)(r.role),
975
+ r.symbol + (r.detail ? `(${r.detail})` : ""),
976
+ `:${r.line}`,
977
+ r.file,
978
+ ]), [["Role", 9], ["Symbol", 24], ["Line", 6], ["File", 34]]);
979
+ }
980
+ console.log();
981
+ });
982
+ // ─── Command: unused-params ───────────────────────────────────────────────────
983
+ program
984
+ .command("unused-params <path>")
985
+ .alias("unused")
986
+ .description("Find function parameters that are never used in the body")
987
+ .option("--json", "Output as JSON")
988
+ .action(async (inputPath, opts) => {
989
+ const { abs, rel } = resolveArg(inputPath);
990
+ const stat = fs.statSync(abs);
991
+ const results = [];
992
+ if (stat.isDirectory()) {
993
+ const sopts = resolveOptions({ detail: "outline", emitHtml: false });
994
+ for (const file of collectSourceFiles(abs, sopts)) {
995
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
996
+ const r = await findUnusedParams(file, fileRel);
997
+ if (r && r.functions.length > 0)
998
+ results.push(r);
999
+ }
1000
+ }
1001
+ else {
1002
+ const r = await findUnusedParams(abs, rel);
1003
+ if (!r)
1004
+ die(`Unsupported file type: ${rel}`);
1005
+ if (r.functions.length > 0)
1006
+ results.push(r);
1007
+ }
1008
+ const rows = results.flatMap((r) => r.functions.map((f) => ({ file: r.file, ...f })));
1009
+ if (opts.json)
1010
+ return jsonOut({ path: rel, count: rows.length, functions: rows });
1011
+ header(`Unused Parameters — ${rel}`);
1012
+ if (rows.length === 0) {
1013
+ console.log(indent(green("✓ No unused parameters found.")));
1014
+ }
1015
+ else {
1016
+ table(rows.map((f) => [f.function, yellow(f.unused.join(", ")), f.file]), [["Function", 26], ["Unused params", 28], ["File", 36]]);
1017
+ const totalP = rows.reduce((a, f) => a + f.unused.length, 0);
1018
+ console.log(`\n ${yellow(`${totalP} unused parameter(s)`)} in ${rows.length} function(s)`);
1019
+ }
1020
+ console.log();
1021
+ });
1022
+ // ─── Command: complexity ──────────────────────────────────────────────────────
1023
+ program
1024
+ .command("complexity <path>")
1025
+ .alias("cx")
1026
+ .description("Cyclomatic complexity per function (file or directory)")
1027
+ .option("--json", "Output as JSON")
1028
+ .option("--min <n>", "Only show functions with complexity >= n", (v) => parseInt(v, 10))
1029
+ .action(async (inputPath, opts) => {
1030
+ const { abs, rel } = resolveArg(inputPath);
1031
+ const stat = fs.statSync(abs);
1032
+ const min = opts.min ?? 1;
1033
+ const fileResults = [];
1034
+ if (stat.isDirectory()) {
1035
+ const sopts = resolveOptions({ detail: "outline", emitHtml: false });
1036
+ for (const file of collectSourceFiles(abs, sopts)) {
1037
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
1038
+ const fc = await computeFileComplexity(file, fileRel);
1039
+ if (fc)
1040
+ fileResults.push(fc);
1041
+ }
1042
+ }
1043
+ else {
1044
+ const fc = await computeFileComplexity(abs, rel);
1045
+ if (!fc)
1046
+ die(`Unsupported file type: ${rel}`);
1047
+ fileResults.push(fc);
1048
+ }
1049
+ const rows = fileResults
1050
+ .flatMap((r) => r.functions.map((f) => ({ file: r.file, ...f })))
1051
+ .filter((f) => f.complexity >= min)
1052
+ .sort((a, b) => b.complexity - a.complexity);
1053
+ if (opts.json)
1054
+ return jsonOut({ path: rel, functionCount: rows.length, functions: rows });
1055
+ header(`Cyclomatic Complexity — ${rel} ${dim(`(${fileResults.length} file(s))`)}`);
1056
+ if (rows.length === 0) {
1057
+ console.log(indent(green("✓ No functions found.")));
1058
+ }
1059
+ else {
1060
+ const colorFor = (r) => (r === "very-high" || r === "high" ? yellow : r === "moderate" ? bold : dim);
1061
+ table(rows.slice(0, 40).map((f) => [String(f.complexity), colorFor(f.rating)(f.rating), f.name, f.file]), [["Cx", 4], ["Rating", 11], ["Function", 26], ["File", 38]]);
1062
+ const high = rows.filter((f) => f.complexity > 10).length;
1063
+ console.log(`\n ${rows.length} function(s)` + (high > 0 ? ` · ${yellow(`${high} above 10`)}` : ""));
1064
+ }
1065
+ console.log();
1066
+ });
1067
+ // ─── Command: duplicates ──────────────────────────────────────────────────────
1068
+ program
1069
+ .command("duplicates <dir>")
1070
+ .alias("dupes")
1071
+ .description("Find symbol names exported from more than one file")
1072
+ .option("--json", "Output as JSON")
1073
+ .action(async (inputPath, opts) => {
1074
+ const { abs, rel } = resolveArg(inputPath);
1075
+ if (!fs.statSync(abs).isDirectory())
1076
+ die(`"${rel}" is not a directory`);
1077
+ const skeletons = await gatherSkeletons(abs);
1078
+ const graph = buildSymbolGraph(skeletons, ROOT);
1079
+ const duplicates = findDuplicateSymbols(graph);
1080
+ if (opts.json)
1081
+ return jsonOut({ directory: rel, scanned: skeletons.length, duplicateCount: duplicates.length, duplicates });
1082
+ header(`Duplicate Symbols — ${rel}/ ${dim(`(${skeletons.length} files scanned)`)}`);
1083
+ if (duplicates.length === 0) {
1084
+ console.log(indent(green("✓ No duplicate exported symbols found.")));
1085
+ }
1086
+ else {
1087
+ for (const d of duplicates) {
1088
+ console.log(indent(`${yellow(d.symbol)} ${dim(`— exported from ${d.count} files`)}`));
1089
+ for (const loc of d.locations) {
1090
+ console.log(indent(`${dim(col(loc.kind, 10))} ${loc.file}`, 5));
1091
+ }
1092
+ }
1093
+ console.log(`\n ${yellow(`${duplicates.length} duplicated name(s)`)}`);
1094
+ }
1095
+ console.log();
1096
+ });
1097
+ // ─── Command: cycles ──────────────────────────────────────────────────────────
1098
+ program
1099
+ .command("cycles <dir>")
1100
+ .description("Detect circular import dependencies")
1101
+ .option("--json", "Output as JSON")
1102
+ .action(async (inputPath, opts) => {
1103
+ const { abs, rel } = resolveArg(inputPath);
1104
+ if (!fs.statSync(abs).isDirectory())
1105
+ die(`"${rel}" is not a directory`);
1106
+ const skeletons = await gatherSkeletons(abs);
1107
+ const graph = buildSymbolGraph(skeletons, ROOT);
1108
+ const cycles = findCircularDeps(graph);
1109
+ if (opts.json)
1110
+ return jsonOut({ directory: rel, scanned: skeletons.length, cycleCount: cycles.length, cycles });
1111
+ header(`Circular Dependencies — ${rel}/ ${dim(`(${skeletons.length} files scanned)`)}`);
1112
+ if (cycles.length === 0) {
1113
+ console.log(indent(green("✓ No circular dependencies found.")));
1114
+ }
1115
+ else {
1116
+ for (const { cycle, length } of cycles) {
1117
+ const arrow = dim(" → ");
1118
+ console.log(indent(`${yellow("↻")} ${dim(`(${length}-cycle)`)} ${cycle.join(arrow)}`));
1119
+ }
1120
+ console.log(`\n ${yellow(`${cycles.length} cycle(s) found`)}`);
1121
+ }
1122
+ console.log();
1123
+ });
1124
+ // ─── Command: impact ──────────────────────────────────────────────────────────
1125
+ program
1126
+ .command("impact <file> <symbol>")
1127
+ .description("Show the blast radius of changing a symbol (all dependents)")
1128
+ .option("--scan <dir>", "Directory to build the graph from (default: file's directory)")
1129
+ .option("--json", "Output as JSON")
1130
+ .action(async (inputPath, symbol, opts) => {
1131
+ const { abs, rel } = resolveArg(inputPath);
1132
+ if (fs.statSync(abs).isDirectory())
1133
+ die(`Provide a single file path, not a directory`);
1134
+ const scanRoot = opts.scan ? resolveArg(opts.scan).abs : path.dirname(abs);
1135
+ const skeletons = await gatherSkeletons(scanRoot);
1136
+ const graph = buildSymbolGraph(skeletons, ROOT);
1137
+ const targetId = `${rel}::${symbol}`;
1138
+ const impact = getChangeImpact(graph, targetId);
1139
+ if (!impact)
1140
+ die(`Symbol "${symbol}" not found in graph for "${rel}". Check that the symbol is exported and the scan dir includes this file.`);
1141
+ if (opts.json)
1142
+ return jsonOut(impact);
1143
+ header(`Change Impact — ${bold(symbol)} ${dim(rel)}`);
1144
+ console.log(indent(`${bold("Direct")} ${dim(`(${impact.direct.length})`)}`));
1145
+ if (impact.direct.length === 0) {
1146
+ console.log(indent(dim(" (none)"), 2));
1147
+ }
1148
+ else {
1149
+ for (const d of impact.direct) {
1150
+ console.log(indent(`${cyan("→")} ${d.file}${d.symbol ? dim("::" + d.symbol) : ""}`, 4));
1151
+ }
1152
+ }
1153
+ console.log(`\n${indent(`${bold("Transitive")} ${dim(`(${impact.transitive.length})`)}`)}`);
1154
+ if (impact.transitive.length === 0) {
1155
+ console.log(indent(dim(" (none)"), 2));
1156
+ }
1157
+ else {
1158
+ for (const t of impact.transitive) {
1159
+ console.log(indent(`${gray("↝")} ${t.file}${t.symbol ? dim("::" + t.symbol) : ""}`, 4));
1160
+ }
1161
+ }
1162
+ console.log(`\n ${bold("Total affected files:")} ${impact.totalFiles}`);
1163
+ console.log();
1164
+ });
1165
+ // ─── Command: calls ───────────────────────────────────────────────────────────
1166
+ program
1167
+ .command("calls <file> <function>")
1168
+ .description("Show the call graph for a function (what it calls + who calls it)")
1169
+ .option("--scan <dir>", "Directory to scan for reverse lookup (calledBy)")
1170
+ .option("--json", "Output as JSON")
1171
+ .action(async (inputPath, funcName, opts) => {
1172
+ const { abs, rel } = resolveArg(inputPath);
1173
+ if (fs.statSync(abs).isDirectory())
1174
+ die(`Provide a single file path, not a directory`);
1175
+ const scanRoot = opts.scan ? resolveArg(opts.scan).abs : path.dirname(abs);
1176
+ const skeletons = await gatherSkeletons(scanRoot);
1177
+ const result = await buildCallGraph(abs, funcName, ROOT, skeletons);
1178
+ if (!result)
1179
+ die(`Function "${funcName}" not found in "${rel}". Check the name and ensure the language is supported.`);
1180
+ if (opts.json)
1181
+ return jsonOut(result);
1182
+ header(`Call Graph — ${bold(funcName + "()")} ${dim(rel)}`);
1183
+ console.log(dim(` Lines ${result.functionRange.startLine}–${result.functionRange.endLine}`));
1184
+ console.log(`\n${indent(`${bold("Calls")} ${dim(`(${result.calls.length})`)}`)}`);
1185
+ if (result.calls.length === 0) {
1186
+ console.log(indent(dim(" (no calls detected)"), 2));
1187
+ }
1188
+ else {
1189
+ for (const call of result.calls) {
1190
+ const loc = dim(`L${call.line}`);
1191
+ let origin;
1192
+ if (call.isLocal)
1193
+ origin = dim("local");
1194
+ else if (call.isExternal)
1195
+ origin = blue(call.calleeFileRel ?? "external");
1196
+ else if (call.calleeFileRel)
1197
+ origin = cyan(call.calleeFileRel);
1198
+ else
1199
+ origin = dim("?");
1200
+ console.log(indent(`${green("→")} ${col(call.callee, 32)} ${loc} ${origin}`, 4));
1201
+ }
1202
+ }
1203
+ console.log(`\n${indent(`${bold("Called By")} ${dim(`(${result.calledBy.length})`)}`)}`);
1204
+ if (result.calledBy.length === 0) {
1205
+ console.log(indent(dim(" (no importers found in scan dir)"), 2));
1206
+ }
1207
+ else {
1208
+ for (const cb of result.calledBy) {
1209
+ console.log(indent(`${gray("←")} ${cb.file}`, 4));
1210
+ }
1211
+ }
1212
+ console.log();
1213
+ });
1214
+ // ─── Command: search ─────────────────────────────────────────────────────────
1215
+ program
1216
+ .command("search <pattern> [dir]")
1217
+ .description("Find symbols by name across all files in a directory")
1218
+ .option("-m, --match <type>", "contains (default) | exact | regex", "contains")
1219
+ .option("-k, --kind <kind>", "Filter by kind: function, class, interface, type, method, const…")
1220
+ .option("-e, --exported", "Only show exported symbols")
1221
+ .option("--json", "Output as JSON")
1222
+ .action(async (pattern, dir, opts) => {
1223
+ const searchDir = dir ?? ".";
1224
+ const { abs, rel } = resolveArg(searchDir);
1225
+ if (!fs.statSync(abs).isDirectory())
1226
+ die(`"${rel}" is not a directory`);
1227
+ const matchType = (opts.match ?? "contains");
1228
+ const matches = await searchSymbols(abs, pattern, ROOT, {
1229
+ matchType,
1230
+ kind: opts.kind,
1231
+ exportedOnly: opts.exported,
1232
+ });
1233
+ if (opts.json)
1234
+ return jsonOut({ directory: rel, pattern, matchCount: matches.length, matches });
1235
+ header(`Symbol Search — ${bold(`"${pattern}"`)} in ${rel}/`);
1236
+ if (matches.length === 0) {
1237
+ console.log(indent(dim("No matches found.")));
1238
+ }
1239
+ else {
1240
+ table(matches.map(m => [m.file, m.symbol, m.kind, m.exported ? green("✓") : dim("–")]), [["File", 40], ["Symbol", 30], ["Kind", 12], ["Exported", 8]]);
1241
+ console.log(`\n ${matches.length} match(es)`);
1242
+ }
1243
+ console.log();
1244
+ });
1245
+ // ─── Command: find (semantic search) ─────────────────────────────────────────
1246
+ program
1247
+ .command("find <query> [dir]")
1248
+ .description("Semantic symbol search — find symbols by meaning, not exact name")
1249
+ .option("-l, --limit <n>", "Max results (default 20)", "20")
1250
+ .option("-k, --kind <kind>", "Filter by kind: function, class, interface, type, method, const…")
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")
1254
+ .option("--json", "Output as JSON")
1255
+ .action(async (query, dir, opts) => {
1256
+ const searchDir = dir ?? ".";
1257
+ const { abs, rel } = resolveArg(searchDir);
1258
+ if (!fs.statSync(abs).isDirectory())
1259
+ die(`"${rel}" is not a directory`);
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
+ }
1283
+ const matches = await semanticSearch(abs, query, ROOT, {
1284
+ limit,
1285
+ kind: opts.kind,
1286
+ exportedOnly: opts.exported,
1287
+ });
1288
+ if (opts.json)
1289
+ return jsonOut({ directory: rel, query, matchCount: matches.length, matches });
1290
+ header(`Semantic Search — ${bold(`"${query}"`)} in ${rel}/`);
1291
+ if (matches.length === 0) {
1292
+ console.log(indent(dim("No matches found.")));
1293
+ }
1294
+ else {
1295
+ table(matches.map(m => [
1296
+ m.score.toFixed(3),
1297
+ m.file,
1298
+ m.symbol,
1299
+ m.kind,
1300
+ m.matchedTerms.slice(0, 4).join(", "),
1301
+ ]), [["Score", 6], ["File", 34], ["Symbol", 26], ["Kind", 10], ["Matched", 30]]);
1302
+ console.log(`\n ${matches.length} match(es)`);
1303
+ }
1304
+ console.log();
1305
+ });
1306
+ // ─── Command: tests (coverage map) ───────────────────────────────────────────
1307
+ program
1308
+ .command("tests [dir]")
1309
+ .alias("coverage")
1310
+ .description("Map test files to the sources they cover; list untested sources")
1311
+ .option("-u, --untested", "Only show untested source files")
1312
+ .option("--links", "Show every test→source link")
1313
+ .option("-n, --top <n>", "Max untested files to show", (v) => parseInt(v, 10), 25)
1314
+ .option("--json", "Output as JSON")
1315
+ .action(async (dir, opts) => {
1316
+ const { abs, rel } = resolveArg(dir ?? ".");
1317
+ if (!fs.statSync(abs).isDirectory())
1318
+ die(`"${rel}" is not a directory`);
1319
+ const skeletons = await gatherSkeletons(abs);
1320
+ const map = mapTestCoverage(buildSymbolGraph(skeletons, ROOT));
1321
+ if (opts.json)
1322
+ return jsonOut({ directory: rel, ...map });
1323
+ header(`Test Coverage — ${rel}/ ${dim(`(${map.testFiles} test files · ${map.sourceFiles} sources)`)}`);
1324
+ const pct = Math.round(map.coverageRatio * 100);
1325
+ const pcolor = pct >= 70 ? green : pct >= 40 ? yellow : red;
1326
+ console.log(indent(`${bold("Covered:")} ${pcolor(`${map.testedSources}/${map.sourceFiles} (${pct}%)`)} of source files have at least one test`));
1327
+ if (!opts.untested && opts.links && map.links.length > 0) {
1328
+ console.log(`\n${indent(bold("Links:"))}`);
1329
+ table(map.links.map((l) => [l.via === "import" ? green(l.via) : yellow(l.via), l.test, "→ " + l.source]), [["Via", 7], ["Test", 38], ["Source", 40]]);
1330
+ }
1331
+ if (map.untested.length > 0) {
1332
+ console.log(`\n${indent(`${bold("Untested sources")} ${dim("(by risk: fan-in, then symbols)")}`)}`);
1333
+ table(map.untested.slice(0, opts.top).map((u) => [String(u.afferent), String(u.symbols), u.file]), [["Ca", 4], ["Syms", 5], ["File", 52]]);
1334
+ if (map.untested.length > opts.top)
1335
+ console.log(indent(dim(`… ${map.untested.length - opts.top} more (use -n)`)));
1336
+ }
1337
+ else if (map.sourceFiles > 0) {
1338
+ console.log(indent(green("✓ every source file has at least one test")));
1339
+ }
1340
+ if (!opts.untested && map.orphanTests.length > 0) {
1341
+ console.log(`\n${indent(`${bold("Orphan tests")} ${dim("(no source matched — integration/e2e?)")}`)}`);
1342
+ for (const t of map.orphanTests.slice(0, 10))
1343
+ console.log(indent(dim(t), 4));
1344
+ }
1345
+ console.log();
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
+ });
1789
+ // ─── Command: deps ────────────────────────────────────────────────────────────
1790
+ program
1791
+ .command("deps <file>")
1792
+ .description("Show what a file imports and what imports it")
1793
+ .option("--scan <dir>", "Directory to build the graph from (default: file's directory)")
1794
+ .option("--json", "Output as JSON")
1795
+ .action(async (inputPath, opts) => {
1796
+ const { abs, rel } = resolveArg(inputPath);
1797
+ if (fs.statSync(abs).isDirectory())
1798
+ die(`Provide a single file path, not a directory`);
1799
+ const scanRoot = opts.scan ? resolveArg(opts.scan).abs : path.dirname(abs);
1800
+ const skeletons = await gatherSkeletons(scanRoot);
1801
+ const graph = buildSymbolGraph(skeletons, ROOT);
1802
+ const fileId = rel;
1803
+ const result = getFileDeps(graph, fileId);
1804
+ if (!result)
1805
+ die(`"${rel}" not found in graph — check it's inside the scan directory and is a supported source file`);
1806
+ if (opts.json)
1807
+ return jsonOut(result);
1808
+ header(`File Dependencies — ${bold(rel)}`);
1809
+ console.log(`\n${indent(`${bold("Imports from")} ${dim(`(${result.imports.length} files)`)}`)}`);
1810
+ if (result.imports.length === 0) {
1811
+ console.log(indent(dim(" (no local imports)"), 2));
1812
+ }
1813
+ else {
1814
+ for (const dep of result.imports) {
1815
+ const syms = dep.symbols.length > 0 ? dim(` [${dep.symbols.slice(0, 5).join(", ")}${dep.symbols.length > 5 ? ` +${dep.symbols.length - 5}` : ""}]`) : "";
1816
+ console.log(indent(`${green("→")} ${dep.file}${syms}`, 4));
1817
+ }
1818
+ }
1819
+ console.log(`\n${indent(`${bold("Imported by")} ${dim(`(${result.importedBy.length} files)`)}`)}`);
1820
+ if (result.importedBy.length === 0) {
1821
+ console.log(indent(dim(" (no files import this)"), 2));
1822
+ }
1823
+ else {
1824
+ for (const dep of result.importedBy) {
1825
+ const syms = dep.symbols.length > 0 ? dim(` [${dep.symbols.slice(0, 5).join(", ")}${dep.symbols.length > 5 ? ` +${dep.symbols.length - 5}` : ""}]`) : "";
1826
+ console.log(indent(`${gray("←")} ${dep.file}${syms}`, 4));
1827
+ }
1828
+ }
1829
+ console.log();
1830
+ });
1831
+ // ─── Command: top ─────────────────────────────────────────────────────────────
1832
+ program
1833
+ .command("top <dir>")
1834
+ .description("Show the most-imported symbols — find God Nodes before they hurt you")
1835
+ .option("-n, --limit <n>", "Number of results to show", "10")
1836
+ .option("--json", "Output as JSON")
1837
+ .action(async (inputPath, opts) => {
1838
+ const { abs, rel } = resolveArg(inputPath);
1839
+ if (!fs.statSync(abs).isDirectory())
1840
+ die(`"${rel}" is not a directory`);
1841
+ const skeletons = await gatherSkeletons(abs);
1842
+ const graph = buildSymbolGraph(skeletons, ROOT);
1843
+ const limit = Math.max(1, parseInt(opts.limit ?? "10", 10) || 10);
1844
+ const top = getTopSymbols(graph, limit);
1845
+ if (opts.json)
1846
+ return jsonOut({ directory: rel, scanned: skeletons.length, topSymbols: top });
1847
+ header(`Top Imported Symbols — ${rel}/ ${dim(`(${skeletons.length} files)`)}`);
1848
+ if (top.length === 0) {
1849
+ console.log(indent(dim("No import edges found.")));
1850
+ }
1851
+ else {
1852
+ table(top.map((s, i) => [
1853
+ String(i + 1).padStart(2),
1854
+ s.symbol,
1855
+ s.file,
1856
+ s.kind,
1857
+ yellow(String(s.importCount)),
1858
+ ]), [["#", 3], ["Symbol", 28], ["File", 38], ["Kind", 10], ["Used by", 7]]);
1859
+ }
1860
+ console.log();
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
+ });
2228
+ // ─── Root metadata ────────────────────────────────────────────────────────────
2229
+ program
2230
+ .name("ast-map")
2231
+ .description("CLI for universal-ast-mapper — structural code analysis tools")
2232
+ .version("0.5.3")
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.
2280
+ `);
2281
+ program.parseAsync(process.argv).catch(err => {
2282
+ console.error(red("Fatal: ") + (err instanceof Error ? err.message : String(err)));
2283
+ process.exit(1);
2284
+ });