universal-ast-mapper 0.5.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/dist/cli.js ADDED
@@ -0,0 +1,617 @@
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 { supportedLanguages } from "./registry.js";
9
+ import { findSymbol, findRelatedSymbols, findServerImports, isApiRoute, findMissingTryCatch, checkGeneralRules, GENERAL_RULE_DEFAULTS } from "./analysis.js";
10
+ import { resolveFileImports } from "./resolver.js";
11
+ import { buildSymbolGraph } from "./graph.js";
12
+ import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols } from "./graph-analysis.js";
13
+ import { buildCallGraph } from "./callgraph.js";
14
+ import { searchSymbols } from "./search.js";
15
+ const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
16
+ // ─── ANSI colours (disabled when not a TTY) ───────────────────────────────────
17
+ const tty = process.stdout.isTTY ?? false;
18
+ const esc = (code) => (s) => tty ? `\x1b[${code}m${s}\x1b[0m` : s;
19
+ const bold = esc("1");
20
+ const dim = esc("2");
21
+ const red = esc("31");
22
+ const green = esc("32");
23
+ const yellow = esc("33");
24
+ const blue = esc("34");
25
+ const cyan = esc("36");
26
+ const gray = esc("90");
27
+ // ─── Layout helpers ───────────────────────────────────────────────────────────
28
+ function header(title) {
29
+ console.log(`\n${bold(title)}`);
30
+ console.log(dim("─".repeat(Math.min(title.length + 4, 60))));
31
+ }
32
+ function indent(s, n = 2) { return " ".repeat(n) + s; }
33
+ function col(s, w) { return s.padEnd(w).slice(0, w); }
34
+ /** Minimal ASCII table — cols is array of [header, width] */
35
+ function table(rows, cols) {
36
+ const header_row = cols.map(([h, w]) => bold(col(h, w))).join(" ");
37
+ const sep = cols.map(([, w]) => dim("─".repeat(w))).join(" ");
38
+ console.log(indent(header_row));
39
+ console.log(indent(sep));
40
+ for (const row of rows) {
41
+ console.log(indent(row.map((cell, i) => col(cell, cols[i][1])).join(" ")));
42
+ }
43
+ }
44
+ function jsonOut(data) {
45
+ console.log(JSON.stringify(data, null, 2));
46
+ }
47
+ // ─── Shared utilities ─────────────────────────────────────────────────────────
48
+ function resolveArg(p) {
49
+ const abs = path.resolve(ROOT, p);
50
+ const rel = path.relative(ROOT, abs).split(path.sep).join("/") || ".";
51
+ return { abs, rel };
52
+ }
53
+ async function gatherSkeletons(dirAbs, detail = "outline") {
54
+ const opts = resolveOptions({ detail, emitHtml: false });
55
+ const files = collectSourceFiles(dirAbs, opts);
56
+ const skeletons = [];
57
+ for (const file of files) {
58
+ const fr = path.relative(ROOT, file).split(path.sep).join("/");
59
+ try {
60
+ skeletons.push(await buildSkeleton(file, fr, opts));
61
+ }
62
+ catch { /* skip parse errors */ }
63
+ }
64
+ return skeletons;
65
+ }
66
+ function die(msg) {
67
+ console.error(red("✗") + " " + msg);
68
+ process.exit(1);
69
+ }
70
+ // ─── Command: langs ───────────────────────────────────────────────────────────
71
+ program
72
+ .command("langs")
73
+ .description("List all supported languages and file extensions")
74
+ .option("--json", "Output as JSON")
75
+ .action((opts) => {
76
+ const langs = supportedLanguages();
77
+ if (opts.json)
78
+ return jsonOut({ root: ROOT, languages: langs });
79
+ header("Supported Languages");
80
+ for (const { language, extensions } of langs) {
81
+ console.log(indent(`${cyan(col(language, 14))} ${dim(extensions.join(" "))}`));
82
+ }
83
+ console.log();
84
+ });
85
+ // ─── Command: skeleton ────────────────────────────────────────────────────────
86
+ program
87
+ .command("skeleton <path>")
88
+ .description("Parse a file or directory into a normalized code skeleton")
89
+ .option("-d, --detail <level>", "outline or full", "outline")
90
+ .option("--html", "Write per-file HTML views to .ast-map/")
91
+ .option("--combine", "Write a combined index.html (directory mode only)")
92
+ .option("-o, --output <dir>", "HTML output directory (default: .ast-map)")
93
+ .option("--json", "Output raw skeleton JSON")
94
+ .action(async (inputPath, opts) => {
95
+ const { abs, rel } = resolveArg(inputPath);
96
+ const detail = (opts.detail ?? "outline");
97
+ const skOpts = resolveOptions({ detail, emitHtml: opts.html, combineHtml: opts.combine, outputDir: opts.output });
98
+ try {
99
+ if (fs.statSync(abs).isDirectory()) {
100
+ const files = collectSourceFiles(abs, skOpts);
101
+ const skeletons = [];
102
+ const errors = [];
103
+ for (const file of files) {
104
+ const fr = path.relative(ROOT, file).split(path.sep).join("/");
105
+ try {
106
+ const skel = await buildSkeleton(file, fr, skOpts);
107
+ skeletons.push(skel);
108
+ if (opts.html) {
109
+ const outDir = opts.output ? path.resolve(ROOT, opts.output) : path.join(ROOT, ".ast-map");
110
+ fs.mkdirSync(path.dirname(path.join(outDir, fr)), { recursive: true });
111
+ fs.writeFileSync(path.join(outDir, `${fr}-skeleton.html`), renderHtml(skel), "utf8");
112
+ }
113
+ }
114
+ catch (e) {
115
+ errors.push(`${fr}: ${e instanceof Error ? e.message : String(e)}`);
116
+ }
117
+ }
118
+ let combinedPath = null;
119
+ if (opts.combine && skeletons.length > 0) {
120
+ const outDir = opts.output ? path.resolve(ROOT, opts.output) : path.join(ROOT, ".ast-map");
121
+ fs.mkdirSync(outDir, { recursive: true });
122
+ combinedPath = path.join(outDir, "index.html");
123
+ fs.writeFileSync(combinedPath, renderCombinedHtml(skeletons), "utf8");
124
+ }
125
+ if (opts.json)
126
+ return jsonOut(skeletons);
127
+ header(`Skeleton — ${rel}/ (${skeletons.length} files)`);
128
+ table(skeletons.map(s => [s.file, s.language, String(s.symbolCount)]), [["File", 44], ["Lang", 12], ["Symbols", 7]]);
129
+ if (errors.length > 0) {
130
+ console.log(`\n${yellow("Errors:")} ${errors.length}`);
131
+ for (const e of errors)
132
+ console.log(indent(dim(e)));
133
+ }
134
+ if (combinedPath)
135
+ console.log(`\n${green("✓")} Combined HTML → ${combinedPath}`);
136
+ console.log();
137
+ }
138
+ else {
139
+ const skel = await buildSkeleton(abs, rel, skOpts);
140
+ if (opts.json)
141
+ return jsonOut(skel);
142
+ header(`Skeleton — ${skel.file} ${dim("(" + skel.language + ")")}`);
143
+ for (const sym of skel.symbols) {
144
+ const exp = sym.exported ? green(" ✓") : "";
145
+ const range = dim(`L${sym.range.startLine}–${sym.range.endLine}`);
146
+ console.log(indent(`${cyan(col(sym.kind, 12))} ${bold(sym.name)}${exp} ${range}`));
147
+ for (const child of sym.children) {
148
+ console.log(indent(indent(`${dim(col(child.kind, 12))} ${dim(child.name)} ${dim(`L${child.range.startLine}`)}`)));
149
+ }
150
+ }
151
+ if (opts.html) {
152
+ const outDir = opts.output ? path.resolve(ROOT, opts.output) : path.join(ROOT, ".ast-map");
153
+ const htmlPath = path.join(outDir, `${rel}-skeleton.html`);
154
+ fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
155
+ fs.writeFileSync(htmlPath, renderHtml(skel), "utf8");
156
+ console.log(`\n${green("✓")} HTML → ${htmlPath}`);
157
+ }
158
+ console.log();
159
+ }
160
+ }
161
+ catch (e) {
162
+ die(e instanceof Error ? e.message : String(e));
163
+ }
164
+ });
165
+ // ─── Command: symbol ──────────────────────────────────────────────────────────
166
+ program
167
+ .command("symbol <file> <name>")
168
+ .description("Extract exact source lines of a named symbol")
169
+ .option("-k, --kind <kind>", "Narrow by symbol kind (function/class/etc)")
170
+ .option("--related", "Also show related types referenced in the signature")
171
+ .option("--json", "Output as JSON")
172
+ .action(async (inputPath, name, opts) => {
173
+ const { abs, rel } = resolveArg(inputPath);
174
+ try {
175
+ const source = fs.readFileSync(abs, "utf8");
176
+ const sourceLines = source.split("\n");
177
+ const skOpts = resolveOptions({ detail: "full", emitHtml: false });
178
+ const skel = await buildSkeleton(abs, rel, skOpts);
179
+ const found = findSymbol(skel.symbols, name, opts.kind);
180
+ if (!found)
181
+ die(`Symbol "${name}" not found in ${rel}`);
182
+ const code = sourceLines.slice(found.range.startLine - 1, found.range.endLine).join("\n");
183
+ const related = opts.related ? findRelatedSymbols(skel.symbols, found, sourceLines) : [];
184
+ if (opts.json)
185
+ return jsonOut({ file: rel, symbol: found.name, kind: found.kind, range: found.range, code, related });
186
+ header(`${found.kind} ${bold(found.name)} ${dim(rel)}`);
187
+ console.log(dim(` Lines ${found.range.startLine}–${found.range.endLine}\n`));
188
+ console.log(code);
189
+ if (related.length > 0) {
190
+ console.log(`\n${bold("Related types:")}`);
191
+ for (const r of related) {
192
+ console.log(`\n${dim(`── ${r.name} (${r.kind})`)} ${dim(`L${r.range.startLine}`)}`);
193
+ console.log(r.code);
194
+ }
195
+ }
196
+ console.log();
197
+ }
198
+ catch (e) {
199
+ die(e instanceof Error ? e.message : String(e));
200
+ }
201
+ });
202
+ // ─── Command: imports ─────────────────────────────────────────────────────────
203
+ program
204
+ .command("imports <file>")
205
+ .description("Resolve all import statements to their source definitions")
206
+ .option("--json", "Output as JSON")
207
+ .action(async (inputPath, opts) => {
208
+ const { abs, rel } = resolveArg(inputPath);
209
+ try {
210
+ const skOpts = resolveOptions({ detail: "full", emitHtml: false });
211
+ const skel = await buildSkeleton(abs, rel, skOpts);
212
+ const resolved = await resolveFileImports(skel, abs, ROOT);
213
+ if (opts.json)
214
+ return jsonOut({ file: rel, importCount: resolved.length, resolved });
215
+ header(`Imports — ${rel} (${resolved.length})`);
216
+ for (const r of resolved) {
217
+ const status = r.found ? green("✓") : r.importKind === "external" ? blue("pkg") : red("✗");
218
+ const alias = r.alias ? dim(` as ${r.alias}`) : "";
219
+ const target = r.resolvedRel ?? r.from;
220
+ const kind = r.kind ? dim(` [${r.kind}]`) : "";
221
+ console.log(indent(`${status} ${col(r.symbol, 28)}${alias}${kind} ${dim(target)}`));
222
+ }
223
+ console.log();
224
+ }
225
+ catch (e) {
226
+ die(e instanceof Error ? e.message : String(e));
227
+ }
228
+ });
229
+ // ─── Command: graph ───────────────────────────────────────────────────────────
230
+ program
231
+ .command("graph <dir>")
232
+ .description("Build and inspect the symbol-level dependency graph")
233
+ .option("-d, --detail <level>", "outline or full", "outline")
234
+ .option("-o, --out <file>", "Write graph JSON to a file")
235
+ .option("--json", "Output graph as JSON (stdout)")
236
+ .action(async (inputPath, opts) => {
237
+ const { abs, rel } = resolveArg(inputPath);
238
+ if (!fs.statSync(abs).isDirectory())
239
+ die(`"${rel}" is not a directory`);
240
+ const skeletons = await gatherSkeletons(abs, (opts.detail ?? "outline"));
241
+ const graph = buildSymbolGraph(skeletons, ROOT);
242
+ if (opts.out) {
243
+ const outAbs = path.resolve(ROOT, opts.out);
244
+ fs.mkdirSync(path.dirname(outAbs), { recursive: true });
245
+ fs.writeFileSync(outAbs, JSON.stringify(graph, null, 2), "utf8");
246
+ console.log(green("✓") + ` Graph written → ${opts.out}`);
247
+ return;
248
+ }
249
+ if (opts.json)
250
+ return jsonOut(graph);
251
+ const importEdges = graph.edges.filter(e => e.edgeType === "imports").length;
252
+ header(`Symbol Graph — ${rel}/`);
253
+ console.log(indent(`${bold("Files:")} ${graph.stats.fileCount}`));
254
+ console.log(indent(`${bold("Symbols:")} ${graph.stats.symbolNodeCount}`));
255
+ console.log(indent(`${bold("Edges:")} ${graph.stats.edgeCount} ${dim(`(${importEdges} cross-file imports)`)}`));
256
+ console.log();
257
+ });
258
+ // ─── Command: validate ────────────────────────────────────────────────────────
259
+ program
260
+ .command("validate <path>")
261
+ .description("Scan for architecture violations (boundary rules + general structural rules)")
262
+ .option("--max-lines <n>", `Flag files over N lines (default: ${GENERAL_RULE_DEFAULTS.largeFileLines})`)
263
+ .option("--max-imports <n>", `Flag files with over N imports (default: ${GENERAL_RULE_DEFAULTS.tooManyImports})`)
264
+ .option("--max-exports <n>", `Flag files with over N exports (default: ${GENERAL_RULE_DEFAULTS.godExportCount})`)
265
+ .option("--json", "Output as JSON")
266
+ .action(async (inputPath, opts) => {
267
+ const { abs } = resolveArg(inputPath);
268
+ const projectConfig = loadProjectConfig(ROOT);
269
+ const skOpts = resolveOptions({ detail: "full", emitHtml: false }, projectConfig);
270
+ const stat = fs.statSync(abs);
271
+ const filesToCheck = stat.isDirectory() ? collectSourceFiles(abs, skOpts) : [abs];
272
+ const thresholds = {
273
+ largeFileLines: opts.maxLines
274
+ ? parseInt(opts.maxLines, 10)
275
+ : (projectConfig.rules?.["large-file"]?.maxLines ?? GENERAL_RULE_DEFAULTS.largeFileLines),
276
+ tooManyImports: opts.maxImports
277
+ ? parseInt(opts.maxImports, 10)
278
+ : (projectConfig.rules?.["too-many-imports"]?.maxImports ?? GENERAL_RULE_DEFAULTS.tooManyImports),
279
+ godExportCount: opts.maxExports
280
+ ? parseInt(opts.maxExports, 10)
281
+ : (projectConfig.rules?.["god-export"]?.maxExports ?? GENERAL_RULE_DEFAULTS.godExportCount),
282
+ };
283
+ const violations = [];
284
+ for (const file of filesToCheck) {
285
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
286
+ let source;
287
+ try {
288
+ source = fs.readFileSync(file, "utf8");
289
+ }
290
+ catch {
291
+ continue;
292
+ }
293
+ let skel;
294
+ try {
295
+ skel = await buildSkeleton(file, fileRel, skOpts);
296
+ }
297
+ catch {
298
+ continue;
299
+ }
300
+ if (skel.directives?.includes("use client")) {
301
+ for (const imp of findServerImports(source)) {
302
+ violations.push({ file: fileRel, rule: "client-server-boundary", severity: "error",
303
+ message: `"use client" imports server-only module "${imp.label}" (${imp.module})`, line: imp.line });
304
+ }
305
+ }
306
+ if (isApiRoute(fileRel)) {
307
+ const sourceLines = source.split("\n");
308
+ for (const sym of findMissingTryCatch(skel.symbols, sourceLines)) {
309
+ violations.push({ file: fileRel, rule: "api-missing-try-catch", severity: "warning",
310
+ message: `API handler "${sym.name}" has no try/catch`, line: sym.range.startLine });
311
+ }
312
+ }
313
+ const importCount = skel.imports?.length ?? 0;
314
+ for (const v of checkGeneralRules(fileRel, source, skel.symbols, importCount, thresholds)) {
315
+ violations.push(v);
316
+ }
317
+ }
318
+ if (opts.json)
319
+ return jsonOut({ scanned: filesToCheck.length, violations });
320
+ const errors = violations.filter(v => v.severity === "error");
321
+ const warnings = violations.filter(v => v.severity === "warning");
322
+ header(`Validate — ${filesToCheck.length} files scanned`);
323
+ if (violations.length === 0) {
324
+ console.log(indent(green("✓ No architecture violations found.")));
325
+ }
326
+ else {
327
+ for (const v of violations) {
328
+ const icon = v.severity === "error" ? red("✗") : yellow("⚠");
329
+ const line = v.line ? dim(`:${v.line}`) : "";
330
+ console.log(indent(`${icon} ${dim(v.file + line)} ${v.message}`));
331
+ }
332
+ console.log(`\n ${red(`${errors.length} error(s)`)}, ${yellow(`${warnings.length} warning(s)`)}`);
333
+ }
334
+ console.log();
335
+ });
336
+ // ─── Command: dead ────────────────────────────────────────────────────────────
337
+ program
338
+ .command("dead <dir>")
339
+ .description("Find exported symbols that are never imported within the directory")
340
+ .option("--json", "Output as JSON")
341
+ .action(async (inputPath, opts) => {
342
+ const { abs, rel } = resolveArg(inputPath);
343
+ if (!fs.statSync(abs).isDirectory())
344
+ die(`"${rel}" is not a directory`);
345
+ const skeletons = await gatherSkeletons(abs);
346
+ const graph = buildSymbolGraph(skeletons, ROOT);
347
+ const dead = findDeadExports(graph);
348
+ if (opts.json)
349
+ return jsonOut({ directory: rel, scanned: skeletons.length, deadExportCount: dead.length, deadExports: dead });
350
+ const highConf = dead.filter(d => d.confidence === "high");
351
+ const lowConf = dead.filter(d => d.confidence === "low");
352
+ header(`Dead Code — ${rel}/ ${dim(`(${skeletons.length} files scanned)`)}`);
353
+ if (dead.length === 0) {
354
+ console.log(indent(green("✓ No dead exports found.")));
355
+ }
356
+ else {
357
+ if (highConf.length > 0) {
358
+ console.log(indent(`${bold("High confidence")} ${dim("— functions / classes / consts")}`));
359
+ table(highConf.map(d => [d.file, d.symbol, d.kind]), [["File", 44], ["Symbol", 28], ["Kind", 10]]);
360
+ }
361
+ if (lowConf.length > 0) {
362
+ console.log(`\n${indent(`${bold("Low confidence")} ${dim("— types / interfaces / enums (may be used as type annotations)")}`)}`);
363
+ table(lowConf.map(d => [d.file, d.symbol, d.kind]), [["File", 44], ["Symbol", 28], ["Kind", 10]]);
364
+ }
365
+ console.log(`\n ${yellow(`${highConf.length} high`)} · ${dim(`${lowConf.length} low`)} confidence dead export(s)`);
366
+ }
367
+ console.log();
368
+ });
369
+ // ─── Command: cycles ──────────────────────────────────────────────────────────
370
+ program
371
+ .command("cycles <dir>")
372
+ .description("Detect circular import dependencies")
373
+ .option("--json", "Output as JSON")
374
+ .action(async (inputPath, opts) => {
375
+ const { abs, rel } = resolveArg(inputPath);
376
+ if (!fs.statSync(abs).isDirectory())
377
+ die(`"${rel}" is not a directory`);
378
+ const skeletons = await gatherSkeletons(abs);
379
+ const graph = buildSymbolGraph(skeletons, ROOT);
380
+ const cycles = findCircularDeps(graph);
381
+ if (opts.json)
382
+ return jsonOut({ directory: rel, scanned: skeletons.length, cycleCount: cycles.length, cycles });
383
+ header(`Circular Dependencies — ${rel}/ ${dim(`(${skeletons.length} files scanned)`)}`);
384
+ if (cycles.length === 0) {
385
+ console.log(indent(green("✓ No circular dependencies found.")));
386
+ }
387
+ else {
388
+ for (const { cycle, length } of cycles) {
389
+ const arrow = dim(" → ");
390
+ console.log(indent(`${yellow("↻")} ${dim(`(${length}-cycle)`)} ${cycle.join(arrow)}`));
391
+ }
392
+ console.log(`\n ${yellow(`${cycles.length} cycle(s) found`)}`);
393
+ }
394
+ console.log();
395
+ });
396
+ // ─── Command: impact ──────────────────────────────────────────────────────────
397
+ program
398
+ .command("impact <file> <symbol>")
399
+ .description("Show the blast radius of changing a symbol (all dependents)")
400
+ .option("--scan <dir>", "Directory to build the graph from (default: file's directory)")
401
+ .option("--json", "Output as JSON")
402
+ .action(async (inputPath, symbol, opts) => {
403
+ const { abs, rel } = resolveArg(inputPath);
404
+ if (fs.statSync(abs).isDirectory())
405
+ die(`Provide a single file path, not a directory`);
406
+ const scanRoot = opts.scan ? resolveArg(opts.scan).abs : path.dirname(abs);
407
+ const skeletons = await gatherSkeletons(scanRoot);
408
+ const graph = buildSymbolGraph(skeletons, ROOT);
409
+ const targetId = `${rel}::${symbol}`;
410
+ const impact = getChangeImpact(graph, targetId);
411
+ if (!impact)
412
+ die(`Symbol "${symbol}" not found in graph for "${rel}". Check that the symbol is exported and the scan dir includes this file.`);
413
+ if (opts.json)
414
+ return jsonOut(impact);
415
+ header(`Change Impact — ${bold(symbol)} ${dim(rel)}`);
416
+ console.log(indent(`${bold("Direct")} ${dim(`(${impact.direct.length})`)}`));
417
+ if (impact.direct.length === 0) {
418
+ console.log(indent(dim(" (none)"), 2));
419
+ }
420
+ else {
421
+ for (const d of impact.direct) {
422
+ console.log(indent(`${cyan("→")} ${d.file}${d.symbol ? dim("::" + d.symbol) : ""}`, 4));
423
+ }
424
+ }
425
+ console.log(`\n${indent(`${bold("Transitive")} ${dim(`(${impact.transitive.length})`)}`)}`);
426
+ if (impact.transitive.length === 0) {
427
+ console.log(indent(dim(" (none)"), 2));
428
+ }
429
+ else {
430
+ for (const t of impact.transitive) {
431
+ console.log(indent(`${gray("↝")} ${t.file}${t.symbol ? dim("::" + t.symbol) : ""}`, 4));
432
+ }
433
+ }
434
+ console.log(`\n ${bold("Total affected files:")} ${impact.totalFiles}`);
435
+ console.log();
436
+ });
437
+ // ─── Command: calls ───────────────────────────────────────────────────────────
438
+ program
439
+ .command("calls <file> <function>")
440
+ .description("Show the call graph for a function (what it calls + who calls it)")
441
+ .option("--scan <dir>", "Directory to scan for reverse lookup (calledBy)")
442
+ .option("--json", "Output as JSON")
443
+ .action(async (inputPath, funcName, opts) => {
444
+ const { abs, rel } = resolveArg(inputPath);
445
+ if (fs.statSync(abs).isDirectory())
446
+ die(`Provide a single file path, not a directory`);
447
+ const scanRoot = opts.scan ? resolveArg(opts.scan).abs : path.dirname(abs);
448
+ const skeletons = await gatherSkeletons(scanRoot);
449
+ const result = await buildCallGraph(abs, funcName, ROOT, skeletons);
450
+ if (!result)
451
+ die(`Function "${funcName}" not found in "${rel}". Check the name and ensure the language is supported.`);
452
+ if (opts.json)
453
+ return jsonOut(result);
454
+ header(`Call Graph — ${bold(funcName + "()")} ${dim(rel)}`);
455
+ console.log(dim(` Lines ${result.functionRange.startLine}–${result.functionRange.endLine}`));
456
+ console.log(`\n${indent(`${bold("Calls")} ${dim(`(${result.calls.length})`)}`)}`);
457
+ if (result.calls.length === 0) {
458
+ console.log(indent(dim(" (no calls detected)"), 2));
459
+ }
460
+ else {
461
+ for (const call of result.calls) {
462
+ const loc = dim(`L${call.line}`);
463
+ let origin;
464
+ if (call.isLocal)
465
+ origin = dim("local");
466
+ else if (call.isExternal)
467
+ origin = blue(call.calleeFileRel ?? "external");
468
+ else if (call.calleeFileRel)
469
+ origin = cyan(call.calleeFileRel);
470
+ else
471
+ origin = dim("?");
472
+ console.log(indent(`${green("→")} ${col(call.callee, 32)} ${loc} ${origin}`, 4));
473
+ }
474
+ }
475
+ console.log(`\n${indent(`${bold("Called By")} ${dim(`(${result.calledBy.length})`)}`)}`);
476
+ if (result.calledBy.length === 0) {
477
+ console.log(indent(dim(" (no importers found in scan dir)"), 2));
478
+ }
479
+ else {
480
+ for (const cb of result.calledBy) {
481
+ console.log(indent(`${gray("←")} ${cb.file}`, 4));
482
+ }
483
+ }
484
+ console.log();
485
+ });
486
+ // ─── Command: search ─────────────────────────────────────────────────────────
487
+ program
488
+ .command("search <pattern> [dir]")
489
+ .description("Find symbols by name across all files in a directory")
490
+ .option("-m, --match <type>", "contains (default) | exact | regex", "contains")
491
+ .option("-k, --kind <kind>", "Filter by kind: function, class, interface, type, method, const…")
492
+ .option("-e, --exported", "Only show exported symbols")
493
+ .option("--json", "Output as JSON")
494
+ .action(async (pattern, dir, opts) => {
495
+ const searchDir = dir ?? ".";
496
+ const { abs, rel } = resolveArg(searchDir);
497
+ if (!fs.statSync(abs).isDirectory())
498
+ die(`"${rel}" is not a directory`);
499
+ const matchType = (opts.match ?? "contains");
500
+ const matches = await searchSymbols(abs, pattern, ROOT, {
501
+ matchType,
502
+ kind: opts.kind,
503
+ exportedOnly: opts.exported,
504
+ });
505
+ if (opts.json)
506
+ return jsonOut({ directory: rel, pattern, matchCount: matches.length, matches });
507
+ header(`Symbol Search — ${bold(`"${pattern}"`)} in ${rel}/`);
508
+ if (matches.length === 0) {
509
+ console.log(indent(dim("No matches found.")));
510
+ }
511
+ else {
512
+ table(matches.map(m => [m.file, m.symbol, m.kind, m.exported ? green("✓") : dim("–")]), [["File", 40], ["Symbol", 30], ["Kind", 12], ["Exported", 8]]);
513
+ console.log(`\n ${matches.length} match(es)`);
514
+ }
515
+ console.log();
516
+ });
517
+ // ─── Command: deps ────────────────────────────────────────────────────────────
518
+ program
519
+ .command("deps <file>")
520
+ .description("Show what a file imports and what imports it")
521
+ .option("--scan <dir>", "Directory to build the graph from (default: file's directory)")
522
+ .option("--json", "Output as JSON")
523
+ .action(async (inputPath, opts) => {
524
+ const { abs, rel } = resolveArg(inputPath);
525
+ if (fs.statSync(abs).isDirectory())
526
+ die(`Provide a single file path, not a directory`);
527
+ const scanRoot = opts.scan ? resolveArg(opts.scan).abs : path.dirname(abs);
528
+ const skeletons = await gatherSkeletons(scanRoot);
529
+ const graph = buildSymbolGraph(skeletons, ROOT);
530
+ const fileId = rel;
531
+ const result = getFileDeps(graph, fileId);
532
+ if (!result)
533
+ die(`"${rel}" not found in graph — check it's inside the scan directory and is a supported source file`);
534
+ if (opts.json)
535
+ return jsonOut(result);
536
+ header(`File Dependencies — ${bold(rel)}`);
537
+ console.log(`\n${indent(`${bold("Imports from")} ${dim(`(${result.imports.length} files)`)}`)}`);
538
+ if (result.imports.length === 0) {
539
+ console.log(indent(dim(" (no local imports)"), 2));
540
+ }
541
+ else {
542
+ for (const dep of result.imports) {
543
+ const syms = dep.symbols.length > 0 ? dim(` [${dep.symbols.slice(0, 5).join(", ")}${dep.symbols.length > 5 ? ` +${dep.symbols.length - 5}` : ""}]`) : "";
544
+ console.log(indent(`${green("→")} ${dep.file}${syms}`, 4));
545
+ }
546
+ }
547
+ console.log(`\n${indent(`${bold("Imported by")} ${dim(`(${result.importedBy.length} files)`)}`)}`);
548
+ if (result.importedBy.length === 0) {
549
+ console.log(indent(dim(" (no files import this)"), 2));
550
+ }
551
+ else {
552
+ for (const dep of result.importedBy) {
553
+ const syms = dep.symbols.length > 0 ? dim(` [${dep.symbols.slice(0, 5).join(", ")}${dep.symbols.length > 5 ? ` +${dep.symbols.length - 5}` : ""}]`) : "";
554
+ console.log(indent(`${gray("←")} ${dep.file}${syms}`, 4));
555
+ }
556
+ }
557
+ console.log();
558
+ });
559
+ // ─── Command: top ─────────────────────────────────────────────────────────────
560
+ program
561
+ .command("top <dir>")
562
+ .description("Show the most-imported symbols — find God Nodes before they hurt you")
563
+ .option("-n, --limit <n>", "Number of results to show", "10")
564
+ .option("--json", "Output as JSON")
565
+ .action(async (inputPath, opts) => {
566
+ const { abs, rel } = resolveArg(inputPath);
567
+ if (!fs.statSync(abs).isDirectory())
568
+ die(`"${rel}" is not a directory`);
569
+ const skeletons = await gatherSkeletons(abs);
570
+ const graph = buildSymbolGraph(skeletons, ROOT);
571
+ const limit = Math.max(1, parseInt(opts.limit ?? "10", 10) || 10);
572
+ const top = getTopSymbols(graph, limit);
573
+ if (opts.json)
574
+ return jsonOut({ directory: rel, scanned: skeletons.length, topSymbols: top });
575
+ header(`Top Imported Symbols — ${rel}/ ${dim(`(${skeletons.length} files)`)}`);
576
+ if (top.length === 0) {
577
+ console.log(indent(dim("No import edges found.")));
578
+ }
579
+ else {
580
+ table(top.map((s, i) => [
581
+ String(i + 1).padStart(2),
582
+ s.symbol,
583
+ s.file,
584
+ s.kind,
585
+ yellow(String(s.importCount)),
586
+ ]), [["#", 3], ["Symbol", 28], ["File", 38], ["Kind", 10], ["Used by", 7]]);
587
+ }
588
+ console.log();
589
+ });
590
+ // ─── Root metadata ────────────────────────────────────────────────────────────
591
+ program
592
+ .name("ast-map")
593
+ .description("CLI for universal-ast-mapper — structural code analysis tools")
594
+ .version("0.5.2")
595
+ .addHelpText("after", `
596
+ ${bold("Examples:")}
597
+ ast-map langs
598
+ ast-map skeleton src/
599
+ ast-map symbol src/utils.ts sanitize --related
600
+ ast-map imports src/pages/login.tsx
601
+ ast-map graph src/ -o graph.json
602
+ ast-map validate src/
603
+ ast-map dead src/
604
+ ast-map cycles src/
605
+ ast-map search validateSession src/ --exported
606
+ ast-map deps src/lib/auth.ts --scan src/
607
+ ast-map top src/ -n 15
608
+ ast-map impact src/utils.ts sanitize --scan src/
609
+ ast-map calls src/utils.ts buildCallGraph --scan src/
610
+
611
+ ${bold("Root:")}
612
+ Defaults to cwd. Override with AST_MAP_ROOT=<path> or run from your project root.
613
+ `);
614
+ program.parseAsync(process.argv).catch(err => {
615
+ console.error(red("Fatal: ") + (err instanceof Error ? err.message : String(err)));
616
+ process.exit(1);
617
+ });
package/dist/config.js ADDED
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export const DEFAULT_IGNORE = [
4
+ "node_modules",
5
+ "vendor",
6
+ ".git",
7
+ "dist",
8
+ "build",
9
+ ".next",
10
+ "out",
11
+ "__pycache__",
12
+ ".venv",
13
+ "venv",
14
+ ".ast-map",
15
+ ];
16
+ let _configCache = null;
17
+ /**
18
+ * Load .ast-map.config.json from the project root.
19
+ * Cached per root path + file mtime so live edits are picked up without restarting.
20
+ * Returns an empty config if no file is found.
21
+ */
22
+ export function loadProjectConfig(root) {
23
+ const configPath = path.join(root, ".ast-map.config.json");
24
+ let mtime = 0;
25
+ try {
26
+ mtime = fs.statSync(configPath).mtimeMs;
27
+ }
28
+ catch { /* file absent */ }
29
+ if (_configCache?.root === root && _configCache.mtime === mtime)
30
+ return _configCache.config;
31
+ let config = {};
32
+ try {
33
+ const raw = fs.readFileSync(configPath, "utf8");
34
+ config = JSON.parse(raw);
35
+ }
36
+ catch {
37
+ // No config file or parse error — use defaults
38
+ }
39
+ _configCache = { root, mtime, config };
40
+ return config;
41
+ }
42
+ export function resolveOptions(opts = {}, projectConfig = {}) {
43
+ const extraIgnore = projectConfig.ignore ?? [];
44
+ const mergedIgnore = [...new Set([...DEFAULT_IGNORE, ...extraIgnore])];
45
+ return {
46
+ detail: opts.detail ?? "outline",
47
+ emitHtml: opts.emitHtml ?? true,
48
+ combineHtml: opts.combineHtml ?? false,
49
+ outputDir: opts.outputDir ?? projectConfig.outputDir,
50
+ ignore: opts.ignore ?? mergedIgnore,
51
+ maxFileBytes: opts.maxFileBytes ?? projectConfig.maxFileBytes ?? 2_000_000,
52
+ };
53
+ }