universal-ast-mapper 1.28.0 → 2.0.0

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