universal-ast-mapper 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +261 -12
- package/dist/ai-refactor.js +185 -0
- package/dist/ai-testgen.js +105 -0
- package/dist/analysis.js +134 -0
- package/dist/arch-rules.js +82 -0
- package/dist/callgraph.js +467 -0
- package/dist/check.js +112 -0
- package/dist/cli.js +2284 -0
- package/dist/complexity.js +98 -0
- package/dist/config.js +53 -0
- package/dist/contextpack.js +79 -0
- package/dist/coupling.js +35 -0
- package/dist/covmerge.js +176 -0
- package/dist/crosslang.js +425 -0
- package/dist/dashboard.js +259 -0
- package/dist/diagram.js +264 -0
- package/dist/diskcache.js +97 -0
- package/dist/docgen.js +156 -0
- package/dist/embeddings.js +136 -0
- package/dist/explain.js +123 -0
- package/dist/explorer.js +123 -0
- package/dist/extractors/c.js +204 -0
- package/dist/extractors/common.js +56 -0
- package/dist/extractors/cpp.js +272 -0
- package/dist/extractors/csharp.js +209 -0
- package/dist/extractors/go.js +212 -0
- package/dist/extractors/java.js +152 -0
- package/dist/extractors/kotlin.js +159 -0
- package/dist/extractors/php.js +208 -0
- package/dist/extractors/python.js +153 -0
- package/dist/extractors/ruby.js +146 -0
- package/dist/extractors/rust.js +249 -0
- package/dist/extractors/swift.js +192 -0
- package/dist/extractors/typescript.js +577 -0
- package/dist/fix.js +92 -0
- package/dist/gitdiff.js +178 -0
- package/dist/graph-analysis.js +279 -0
- package/dist/graph.js +165 -0
- package/dist/history.js +36 -0
- package/dist/html.js +658 -0
- package/dist/incremental.js +122 -0
- package/dist/index.js +1945 -0
- package/dist/indexstore.js +105 -0
- package/dist/layers.js +36 -0
- package/dist/lsp.js +238 -0
- package/dist/modulecoupling.js +0 -0
- package/dist/parser.js +84 -0
- package/dist/patch.js +199 -0
- package/dist/plugins.js +88 -0
- package/dist/pool.js +114 -0
- package/dist/prompts.js +67 -0
- package/dist/registry.js +87 -0
- package/dist/report.js +441 -0
- package/dist/resolver.js +222 -0
- package/dist/roots.js +47 -0
- package/dist/search.js +68 -0
- package/dist/security.js +178 -0
- package/dist/semantic.js +365 -0
- package/dist/serve.js +328 -0
- package/dist/sfc.js +27 -0
- package/dist/similar.js +98 -0
- package/dist/skeleton.js +132 -0
- package/dist/smells.js +285 -0
- package/dist/sourcemap.js +60 -0
- package/dist/testgen.js +280 -0
- package/dist/testmap.js +167 -0
- package/dist/tsconfig.js +212 -0
- package/dist/typeflow.js +124 -0
- package/dist/types.js +5 -0
- package/dist/unused-params.js +127 -0
- package/dist/webapp.js +646 -0
- package/dist/worker.js +27 -0
- package/dist/workspace.js +330 -0
- 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
|
+
});
|