universal-ast-mapper 1.10.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ since 1.0.0, guarantees a stable MCP tool / CLI surface across the 1.x line.
6
6
 
7
7
  ---
8
8
 
9
+ ## [1.13.0] — 2026-06-08 · Context-pack
10
+ - **`pack_context`** + **`ast-map pack <file> [symbol]`**: the minimal context to
11
+ work on a symbol — its source, the signatures it depends on, and its dependents
12
+ — with a token estimate, instead of reading whole files.
13
+
14
+ ## [1.12.0] — 2026-06-08 · Git-aware analysis
15
+ - **`ast-map diff [base]`** + **`get_diff`**: changed symbols since a git ref,
16
+ breaking changes (removed / signature-changed exports), and blast radius.
17
+ - **`ast-map risk`** + **`get_risk_map`**: rank files by churn × complexity.
18
+
19
+ ## [1.11.0] — 2026-06-01 · Code-health dashboard
20
+ - **`ast-map report`** writes a premium self-contained HTML dashboard: health
21
+ grade (A–F), stats, language breakdown, complexity hotspots, god nodes, dead
22
+ code, and cycles. **`get_codebase_report`** MCP tool returns the same as JSON.
23
+
9
24
  ## [1.10.0] — 2026-06-01 · Source maps
10
25
  - **`read_source_map`** MCP tool + **`ast-map sourcemap <file>`** CLI: trace a
11
26
  compiled JS/CSS file (inline `data:` or external `.map`) back to its original
@@ -112,6 +127,9 @@ since 1.0.0, guarantees a stable MCP tool / CLI surface across the 1.x line.
112
127
  - **0.2.0** — import extraction; `resolve_imports`; `build_symbol_graph`.
113
128
  - **0.1.0** — `get_skeleton_json`, `generate_skeleton`, `get_symbol_context`, `validate_architecture`.
114
129
 
130
+ [1.13.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.13.0
131
+ [1.12.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.12.0
132
+ [1.11.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.11.0
115
133
  [1.10.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.10.0
116
134
  [1.9.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.9.0
117
135
  [1.8.0]: https://github.com/6ixthxense/AST-MCP/releases/tag/v1.8.0
package/README.md CHANGED
@@ -4,6 +4,8 @@ An **MCP server + CLI tool** that turns source code into structured, machine-rea
4
4
 
5
5
  Built on [tree-sitter](https://tree-sitter.github.io/) WASM grammars. Zero regex guessing — real AST parsing.
6
6
 
7
+ **24 MCP tools / 25 CLI commands** spanning skeletons, dependency graphs, and deep analysis — dead code, cycles, change-impact, complexity, duplicates, unused params, type-flow, decorators — plus monorepo support, an interactive **graph explorer** (`ast-map explore`), **watch mode**, and a one-page **health dashboard** (`ast-map report`).
8
+
7
9
  **Supported languages:** TypeScript · TSX · JavaScript (ESM/CJS) · Python · Go · Rust · Java · C# · C · C++ · Kotlin · Swift
8
10
 
9
11
  | Capability | TS/JS | Python | Go | Rust | Java | C# | C | C++ | Kt | Swift |
@@ -101,6 +103,10 @@ ast-map workspace [dir] [alias: ws]
101
103
  ast-map explore [dir] [-o out.html]
102
104
  ast-map watch [dir] [-o out.html]
103
105
  ast-map sourcemap <file>
106
+ ast-map report [dir] [-o report.html]
107
+ ast-map diff [base] [--dir <d>] # git-aware changed symbols + impact
108
+ ast-map risk [dir] [-n N] # churn × complexity
109
+ ast-map pack <file> [symbol] [--scan <d>] # minimal context pack
104
110
  ast-map search <pattern> [dir] [-m contains|exact|regex] [-k kind] [-e]
105
111
  ast-map deps <file> [--scan <dir>]
106
112
  ast-map top <dir> [-n 10]
@@ -347,6 +353,67 @@ Scan a file or directory for **named functions/methods with parameters that are
347
353
 
348
354
  ---
349
355
 
356
+ ### `read_source_map`
357
+ Given a compiled JS/CSS file with an inline (`data:`) or external `sourceMappingURL`, return the **original source files** it maps back to (honors `sourceRoot`; reports embedded `sourcesContent`).
358
+
359
+ ```json
360
+ { "file": "dist/bundle.js", "mapKind": "inline", "sources": ["../src/app.ts", "../src/util.ts"], "hasContent": true }
361
+ ```
362
+
363
+ **Params:** `path`
364
+
365
+ ---
366
+
367
+ ### `get_codebase_report`
368
+ A one-shot **codebase health summary**: file/symbol counts, language breakdown, a health **grade (A–F)** + score, complexity hotspots, god nodes, dead exports, and circular dependencies. Rendered as a premium HTML dashboard by `ast-map report`.
369
+
370
+ ```json
371
+ { "grade": "B", "score": 82, "fileCount": 120, "symbolCount": 1400,
372
+ "complexity": { "average": 4.1, "max": 22, "hotspots": [ … ] },
373
+ "godNodes": [ … ], "dead": { "count": 3, "items": [ … ] }, "cycles": { "count": 0, "items": [] } }
374
+ ```
375
+
376
+ **Params:** `path` (optional, defaults to root)
377
+
378
+ ---
379
+
380
+ ### `get_diff`
381
+ **Git-aware.** Compare the working tree to a git ref (default `HEAD`) and return which symbols were added/removed/modified per file, which changes are potentially **breaking** (removed or signature-changed exports), and the **blast radius** — files that depend on those breaking changes. Untracked new files count as additions.
382
+
383
+ ```json
384
+ { "summary": { "filesChanged": 2, "added": 1, "removed": 1, "modified": 1, "breaking": 2, "impactedFiles": 1 },
385
+ "breaking": [ { "file": "src/a.ts", "symbol": "foo", "reason": "signature changed" } ],
386
+ "impactedFiles": ["src/b.ts"] }
387
+ ```
388
+
389
+ **Params:** `base` (optional), `path` (optional)
390
+
391
+ ---
392
+
393
+ ### `pack_context`
394
+ **Token-efficient.** Assemble the *minimal* context to understand or change a symbol — its own source, the **signatures** of what it depends on (resolved imports), and the files that depend on it — instead of reading whole files. Returns a token estimate so you can see the savings.
395
+
396
+ ```json
397
+ { "primary": { "symbol": "login", "startLine": 8, "endLine": 12, "source": "…" },
398
+ "dependencies": [ { "file": "utils.ts", "symbols": [ { "name": "hashPassword", "signature": "…" } ] } ],
399
+ "dependents": [ { "file": "router.ts" } ], "tokenEstimate": 56 }
400
+ ```
401
+
402
+ **Params:** `path`, `symbol` (optional), `scan` (optional)
403
+
404
+ ---
405
+
406
+ ### `get_risk_map`
407
+ Rank files by **refactor risk = git churn × max complexity** — the files that are both frequently changed and complex (the best refactor / test targets).
408
+
409
+ ```json
410
+ { "files": [ { "file": "src/callgraph.ts", "churn": 7, "maxComplexity": 69, "risk": 483 } ] }
411
+ ```
412
+
413
+ **Params:** `path` (optional)
414
+
415
+ ---
416
+
350
417
  ### `get_change_impact`
351
418
  Given a file + symbol, reverse-traverse the import graph to compute **blast radius**.
352
419
 
@@ -620,6 +687,9 @@ Not part of the public API: the internal `src/` module layout and the generated
620
687
 
621
688
  | Version | What changed |
622
689
  |---------|--------------|
690
+ | **1.13.0** | **Context-pack** — new `pack_context` MCP tool + `ast-map pack <file> [symbol]` CLI: the minimal context to work on a symbol (its source + the signatures it depends on + its dependents) with a token estimate, instead of reading whole files. **24 MCP tools**. |
691
+ | **1.12.0** | **Git-aware analysis** — `ast-map diff [base]` + `get_diff` tool: changed symbols since a ref, **breaking changes** (removed / signature-changed exports), and blast radius. `ast-map risk` + `get_risk_map` tool: rank files by churn × complexity. Brings a time/history dimension. **23 MCP tools**. |
692
+ | **1.11.0** | **Code-health dashboard** — new `ast-map report` CLI writes a premium self-contained HTML overview (grade A–F, stats, language breakdown, complexity hotspots, god nodes, dead code, cycles) + `get_codebase_report` MCP tool for the same as JSON. |
623
693
  | **1.10.0** | **Source-map support** — new `read_source_map` MCP tool + `ast-map sourcemap <file>` CLI: given a compiled JS/CSS file with an inline (`data:`) or external `sourceMappingURL`, returns the original source files it maps back to (honors `sourceRoot`). Traces `dist/` output back to source. |
624
694
  | **1.9.0** | **Watch mode** — `ast-map watch [dir]` recomputes the dependency analysis (file count · dead exports · cycles) on every source-file change, debounced; `-o file.html` also regenerates the live explorer each time. Plus: the explorer debug readout is now hidden (toggle with `d`). |
625
695
  | **1.8.2** | **Explorer stability fix** — clamp the force layout (distance floor + velocity cap) so nodes that initialize close together can't be flung to huge coordinates, which was blowing up the bounding box and shrinking the whole graph into a corner. Now reliably centers and fills. |
package/dist/cli.js CHANGED
@@ -16,6 +16,9 @@ import { traceTypeInFile } from "./typeflow.js";
16
16
  import { discoverWorkspace, findPackageCycles } from "./workspace.js";
17
17
  import { buildExplorerHtml } from "./explorer.js";
18
18
  import { readSourceMap } from "./sourcemap.js";
19
+ import { buildReport, buildReportHtml } from "./report.js";
20
+ import { computeDiff, computeRisk, isGitRepo } from "./gitdiff.js";
21
+ import { packContext } from "./contextpack.js";
19
22
  import { buildCallGraph } from "./callgraph.js";
20
23
  import { searchSymbols } from "./search.js";
21
24
  const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
@@ -446,6 +449,122 @@ program
446
449
  console.log(`\n ${info.sources.length} original source(s)` + (info.hasContent ? dim(" · embeds sourcesContent") : ""));
447
450
  console.log();
448
451
  });
452
+ // ─── Command: pack ────────────────────────────────────────────────────────────
453
+ program
454
+ .command("pack <file> [symbol]")
455
+ .description("Minimal context pack for a symbol (source + dep signatures + dependents)")
456
+ .option("--scan <dir>", "Directory to scan for dependents", ".")
457
+ .option("--json", "Output as JSON")
458
+ .action(async (file, symbol, opts) => {
459
+ const { abs, rel } = resolveArg(file);
460
+ if (fs.statSync(abs).isDirectory())
461
+ die(`"${rel}" is a directory; pass a file`);
462
+ const scanAbs = resolveArg(opts.scan).abs;
463
+ const pack = await packContext(abs, rel, ROOT, symbol, scanAbs);
464
+ if (opts.json)
465
+ return jsonOut(pack);
466
+ header(`Context Pack \u2014 ${rel}${symbol ? "::" + symbol : ""} ${dim("(~" + pack.tokenEstimate + " tokens)")}`);
467
+ console.log(indent(bold("Primary") + dim(` lines ${pack.primary.startLine}-${pack.primary.endLine}`)));
468
+ console.log();
469
+ console.log(indent(bold("Depends on:")));
470
+ if (pack.dependencies.length === 0)
471
+ console.log(indent(dim("(none in-project)"), 4));
472
+ for (const d of pack.dependencies) {
473
+ console.log(indent(green(d.file), 4));
474
+ for (const sym of d.symbols)
475
+ console.log(indent(dim((sym.signature || sym.name)), 6));
476
+ }
477
+ console.log();
478
+ console.log(indent(bold("Depended on by:")));
479
+ if (pack.dependents.length === 0)
480
+ console.log(indent(dim("(none found in scan)"), 4));
481
+ for (const dep of pack.dependents)
482
+ console.log(indent(yellow(dep.file), 4));
483
+ console.log();
484
+ });
485
+ // ─── Command: diff ────────────────────────────────────────────────────────────
486
+ program
487
+ .command("diff [base]")
488
+ .description("Symbols changed since a git ref + breaking changes + blast radius")
489
+ .option("--dir <dir>", "Limit to a subdirectory", ".")
490
+ .option("--json", "Output as JSON")
491
+ .action(async (base, opts) => {
492
+ if (!isGitRepo(ROOT))
493
+ die("not a git repository (or git is unavailable)");
494
+ const { abs, rel } = resolveArg(opts.dir);
495
+ const ref = base ?? "HEAD";
496
+ const d = await computeDiff(abs, ROOT, ref);
497
+ if (opts.json)
498
+ return jsonOut(d);
499
+ header(`Diff since ${bold(ref)} ${dim(`(${d.summary.filesChanged} file(s) · +${d.summary.added} ~${d.summary.modified} -${d.summary.removed})`)}`);
500
+ if (d.files.length === 0) {
501
+ console.log(indent(dim("No source-symbol changes.")));
502
+ console.log();
503
+ return;
504
+ }
505
+ for (const f of d.files) {
506
+ console.log(indent(`${bold(f.file)} ${dim("[" + f.status + "]")}`));
507
+ for (const a of f.added)
508
+ console.log(indent(green("+ ") + a.symbol + dim(a.exported ? " (exported)" : ""), 4));
509
+ for (const m of f.modified)
510
+ console.log(indent(yellow("~ ") + m.symbol + dim(m.exported ? " (exported)" : ""), 4));
511
+ for (const r of f.removed)
512
+ console.log(indent(red("- ") + r.symbol + dim(r.exported ? " (exported)" : ""), 4));
513
+ }
514
+ if (d.breaking.length > 0) {
515
+ console.log(`\n${indent(bold(red("\u26a0 Breaking changes (" + d.breaking.length + ")")))}`);
516
+ for (const b of d.breaking)
517
+ console.log(indent(`${red(b.symbol)} ${dim(b.reason)} ${dim(b.file)}`, 4));
518
+ console.log(`\n${indent(yellow(d.impactedFiles.length + " file(s) impacted") + dim(" by breaking changes"))}`);
519
+ for (const f of d.impactedFiles.slice(0, 20))
520
+ console.log(indent(dim(f), 4));
521
+ }
522
+ console.log();
523
+ });
524
+ // ─── Command: risk ────────────────────────────────────────────────────────────
525
+ program
526
+ .command("risk [dir]")
527
+ .description("Rank files by refactor risk (git churn × complexity)")
528
+ .option("--json", "Output as JSON")
529
+ .option("-n, --top <n>", "Show top N", (v) => parseInt(v, 10), 15)
530
+ .action(async (dir, opts) => {
531
+ if (!isGitRepo(ROOT))
532
+ die("not a git repository (or git is unavailable)");
533
+ const { abs, rel } = resolveArg(dir ?? ".");
534
+ const files = await computeRisk(abs, ROOT);
535
+ if (opts.json)
536
+ return jsonOut({ count: files.length, files });
537
+ header(`Refactor Risk \u2014 ${rel}/ ${dim("(churn × max complexity)")}`);
538
+ if (files.length === 0) {
539
+ console.log(indent(green("✓ nothing risky (no churn × complexity)")));
540
+ console.log();
541
+ return;
542
+ }
543
+ table(files.slice(0, opts.top).map((f) => [String(f.risk), `${f.churn} × ${f.maxComplexity}`, f.file]), [["Risk", 7], ["churn×cx", 12], ["File", 44]]);
544
+ console.log();
545
+ });
546
+ // ─── Command: report ──────────────────────────────────────────────────────────
547
+ program
548
+ .command("report [dir]")
549
+ .description("Generate a code-health dashboard (HTML)")
550
+ .option("-o, --out <file>", "Output HTML path", "ast-report.html")
551
+ .option("--json", "Print the report data as JSON")
552
+ .action(async (dir, opts) => {
553
+ const { abs, rel } = resolveArg(dir ?? ".");
554
+ if (!fs.statSync(abs).isDirectory())
555
+ die(`"${rel}" is not a directory`);
556
+ const data = await buildReport(abs, ROOT);
557
+ if (opts.json)
558
+ return jsonOut(data);
559
+ const out = path.resolve(process.cwd(), opts.out);
560
+ fs.mkdirSync(path.dirname(out), { recursive: true });
561
+ fs.writeFileSync(out, buildReportHtml(data), "utf8");
562
+ header(`Code Health \u2014 ${rel}/ ${dim(`(${data.fileCount} files)`)}`);
563
+ const gcolor = data.grade === "A" || data.grade === "B" ? green : data.grade === "C" || data.grade === "D" ? yellow : (x) => x;
564
+ 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}`));
565
+ console.log(indent(green("✓ wrote " + path.relative(process.cwd(), out))));
566
+ console.log();
567
+ });
449
568
  // ─── Command: explore ─────────────────────────────────────────────────────────
450
569
  program
451
570
  .command("explore [dir]")
@@ -0,0 +1,79 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { buildSkeleton, collectSourceFiles } from "./skeleton.js";
4
+ import { resolveOptions } from "./config.js";
5
+ import { resolveFileImports } from "./resolver.js";
6
+ import { buildCallGraph } from "./callgraph.js";
7
+ function findSym(syms, name) {
8
+ for (const s of syms) {
9
+ if (s.name === name)
10
+ return s;
11
+ const n = findSym(s.children, name);
12
+ if (n)
13
+ return n;
14
+ }
15
+ return null;
16
+ }
17
+ const tok = (s) => Math.round(s.length / 4);
18
+ /**
19
+ * Assemble the minimal context an agent needs to understand or change a symbol:
20
+ * the symbol's own source, the signatures of what it depends on (resolved
21
+ * imports), and the files that depend on it — instead of reading whole files.
22
+ */
23
+ export async function packContext(absFile, relFile, root, symbolName, scanDir) {
24
+ const opts = resolveOptions({ detail: "full", emitHtml: false });
25
+ const skel = await buildSkeleton(absFile, relFile, opts);
26
+ const lines = fs.readFileSync(absFile, "utf8").split(/\r?\n/);
27
+ let startLine = 1, endLine = lines.length;
28
+ if (symbolName) {
29
+ const sym = findSym(skel.symbols, symbolName);
30
+ if (sym) {
31
+ startLine = sym.range.startLine;
32
+ endLine = sym.range.endLine;
33
+ }
34
+ }
35
+ const source = lines.slice(startLine - 1, endLine).join("\n");
36
+ // Dependencies: resolved in-project imports + the target symbol signatures.
37
+ const refs = await resolveFileImports(skel, absFile, root);
38
+ const byFile = new Map();
39
+ for (const r of refs) {
40
+ if (!r.found || !r.resolvedRel)
41
+ continue;
42
+ const arr = byFile.get(r.resolvedRel) ?? [];
43
+ if (!arr.some((x) => x.name === r.symbol))
44
+ arr.push({ name: r.symbol, signature: r.signature ?? null });
45
+ byFile.set(r.resolvedRel, arr);
46
+ }
47
+ const dependencies = [...byFile.entries()].map(([file, symbols]) => ({ file, symbols }));
48
+ // Dependents: who calls the seed symbol (needs a directory scan).
49
+ let dependents = [];
50
+ if (symbolName && scanDir) {
51
+ const sopts = resolveOptions({ detail: "outline", emitHtml: false });
52
+ const skels = [];
53
+ for (const f of collectSourceFiles(scanDir, sopts)) {
54
+ const rr = path.relative(root, f).split(path.sep).join("/");
55
+ try {
56
+ skels.push(await buildSkeleton(f, rr, sopts));
57
+ }
58
+ catch { /* skip */ }
59
+ }
60
+ const cg = await buildCallGraph(absFile, symbolName, root, skels);
61
+ if (cg) {
62
+ const seen = new Set();
63
+ for (const c of cg.calledBy)
64
+ if (!seen.has(c.file)) {
65
+ seen.add(c.file);
66
+ dependents.push({ file: c.file });
67
+ }
68
+ }
69
+ }
70
+ const depTok = dependencies.reduce((a, d) => a + d.symbols.reduce((b, s) => b + tok(s.signature || s.name), 0), 0);
71
+ return {
72
+ seed: { file: relFile, ...(symbolName ? { symbol: symbolName } : {}) },
73
+ primary: { file: relFile, ...(symbolName ? { symbol: symbolName } : {}), startLine, endLine, source },
74
+ dependencies,
75
+ dependents,
76
+ tokenEstimate: tok(source) + depTok,
77
+ note: "Read primary.source in full; for dependencies you usually only need the listed signatures, not the whole files.",
78
+ };
79
+ }
@@ -0,0 +1,178 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { buildSkeleton, collectSourceFiles } from "./skeleton.js";
6
+ import { resolveOptions } from "./config.js";
7
+ import { buildSymbolGraph } from "./graph.js";
8
+ import { getChangeImpact } from "./graph-analysis.js";
9
+ import { detectLanguage } from "./registry.js";
10
+ import { computeFileComplexity } from "./complexity.js";
11
+ function git(args, cwd) {
12
+ return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
13
+ }
14
+ export function isGitRepo(root) {
15
+ try {
16
+ git(["rev-parse", "--is-inside-work-tree"], root);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ function changedFiles(root, base) {
24
+ let out;
25
+ try {
26
+ out = git(["diff", "--name-status", base, "--"], root);
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ const res = [];
32
+ for (const line of out.split(/\r?\n/)) {
33
+ if (!line.trim())
34
+ continue;
35
+ const m = line.match(/^([AMD])\t(.+)$/);
36
+ if (m) {
37
+ res.push({ status: m[1], file: m[2] });
38
+ continue;
39
+ }
40
+ const r = line.match(/^R\d+\t\S+\t(.+)$/); // rename → treat new path as modified
41
+ if (r)
42
+ res.push({ status: "M", file: r[1] });
43
+ }
44
+ // Untracked files are new since any ref — treat them as added.
45
+ try {
46
+ const untracked = git(["ls-files", "--others", "--exclude-standard"], root);
47
+ for (const f of untracked.split(/\r?\n/)) {
48
+ if (f.trim() && !res.some((x) => x.file === f))
49
+ res.push({ status: "A", file: f });
50
+ }
51
+ }
52
+ catch { /* ignore */ }
53
+ return res.filter((f) => detectLanguage(f.file));
54
+ }
55
+ function oldContent(root, base, rel) {
56
+ try {
57
+ return git(["show", `${base}:${rel}`], root);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ async function skeletonFromSource(source, rel) {
64
+ const ext = path.extname(rel);
65
+ const tmp = path.join(os.tmpdir(), `astdiff-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
66
+ try {
67
+ fs.writeFileSync(tmp, source);
68
+ return await buildSkeleton(tmp, rel, resolveOptions({ detail: "full", emitHtml: false }));
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ finally {
74
+ try {
75
+ fs.unlinkSync(tmp);
76
+ }
77
+ catch { /* ignore */ }
78
+ }
79
+ }
80
+ function flatten(syms, prefix, acc) {
81
+ for (const s of syms) {
82
+ const q = prefix ? prefix + "." + s.name : s.name;
83
+ acc.set(q, s);
84
+ flatten(s.children, q, acc);
85
+ }
86
+ return acc;
87
+ }
88
+ const sc = (s) => ({ symbol: s.name, kind: s.kind, exported: s.exported ?? false });
89
+ export async function computeDiff(absDir, root, base) {
90
+ const absDirNorm = path.resolve(absDir);
91
+ const changed = changedFiles(root, base).filter((f) => path.resolve(root, f.file).startsWith(absDirNorm));
92
+ const files = [];
93
+ const breaking = [];
94
+ for (const cf of changed) {
95
+ const rel = cf.file;
96
+ const newSkel = cf.status === "D" ? null : await safeBuildFromDisk(path.resolve(root, rel), rel);
97
+ const oldSrc = cf.status === "A" ? null : oldContent(root, base, rel);
98
+ const oldSkel = oldSrc != null ? await skeletonFromSource(oldSrc, rel) : null;
99
+ const oldMap = oldSkel ? flatten(oldSkel.symbols, "", new Map()) : new Map();
100
+ const newMap = newSkel ? flatten(newSkel.symbols, "", new Map()) : new Map();
101
+ const added = [], removed = [], modified = [];
102
+ for (const [q, s] of newMap)
103
+ if (!oldMap.has(q))
104
+ added.push(sc(s));
105
+ for (const [q, s] of oldMap)
106
+ if (!newMap.has(q))
107
+ removed.push(sc(s));
108
+ for (const [q, s] of newMap) {
109
+ const o = oldMap.get(q);
110
+ if (o && (o.signature ?? "") !== (s.signature ?? ""))
111
+ modified.push(sc(s));
112
+ }
113
+ const status = cf.status === "A" ? "added" : cf.status === "D" ? "deleted" : "modified";
114
+ files.push({ file: rel, status, added, removed, modified });
115
+ for (const r of removed)
116
+ if (r.exported && !r.symbol.includes(" ")) {
117
+ breaking.push({ file: rel, symbol: r.symbol, reason: cf.status === "D" ? "file deleted" : "export removed" });
118
+ }
119
+ for (const m of modified)
120
+ if (m.exported)
121
+ breaking.push({ file: rel, symbol: m.symbol, reason: "signature changed" });
122
+ }
123
+ // Blast radius of breaking changes (top-level symbols only).
124
+ const impacted = new Set();
125
+ if (breaking.length > 0) {
126
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
127
+ const skels = [];
128
+ for (const f of collectSourceFiles(absDirNorm, opts)) {
129
+ const r = path.relative(root, f).split(path.sep).join("/");
130
+ try {
131
+ skels.push(await buildSkeleton(f, r, opts));
132
+ }
133
+ catch { /* skip */ }
134
+ }
135
+ const graph = buildSymbolGraph(skels, root);
136
+ for (const b of breaking) {
137
+ const imp = getChangeImpact(graph, `${b.file}::${b.symbol}`);
138
+ if (imp)
139
+ for (const d of [...imp.direct, ...imp.transitive])
140
+ if (d.file !== b.file)
141
+ impacted.add(d.file);
142
+ }
143
+ }
144
+ const sum = files.reduce((a, f) => ({ added: a.added + f.added.length, removed: a.removed + f.removed.length, modified: a.modified + f.modified.length }), { added: 0, removed: 0, modified: 0 });
145
+ return {
146
+ base,
147
+ files,
148
+ breaking,
149
+ impactedFiles: [...impacted].sort(),
150
+ summary: { filesChanged: files.length, ...sum, breaking: breaking.length, impactedFiles: impacted.size },
151
+ };
152
+ }
153
+ async function safeBuildFromDisk(abs, rel) {
154
+ try {
155
+ return await buildSkeleton(abs, rel, resolveOptions({ detail: "full", emitHtml: false }));
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ export async function computeRisk(absDir, root) {
162
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
163
+ const out = [];
164
+ for (const f of collectSourceFiles(absDir, opts)) {
165
+ const rel = path.relative(root, f).split(path.sep).join("/");
166
+ let churn = 0;
167
+ try {
168
+ churn = parseInt(git(["rev-list", "--count", "HEAD", "--", rel], root).trim(), 10) || 0;
169
+ }
170
+ catch {
171
+ churn = 0;
172
+ }
173
+ const fc = await computeFileComplexity(f, rel);
174
+ const maxC = fc ? fc.maxComplexity : 0;
175
+ out.push({ file: rel, churn, maxComplexity: maxC, risk: churn * maxC });
176
+ }
177
+ return out.filter((r) => r.risk > 0).sort((a, b) => b.risk - a.risk);
178
+ }
package/dist/index.js CHANGED
@@ -20,6 +20,9 @@ import { findUnusedParams } from "./unused-params.js";
20
20
  import { traceTypeInFile } from "./typeflow.js";
21
21
  import { discoverWorkspace, findPackageCycles } from "./workspace.js";
22
22
  import { readSourceMap } from "./sourcemap.js";
23
+ import { buildReport } from "./report.js";
24
+ import { computeDiff, computeRisk, isGitRepo } from "./gitdiff.js";
25
+ import { packContext } from "./contextpack.js";
23
26
  /** Files may only be read inside this root (override with AST_MAP_ROOT). */
24
27
  const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
25
28
  function resolveInRoot(input) {
@@ -767,6 +770,95 @@ server.registerTool("read_source_map", {
767
770
  return errorText(describeError(err));
768
771
  }
769
772
  });
773
+ /* ─────────────────── tool: get_codebase_report ─────────────────────────── */
774
+ server.registerTool("get_codebase_report", {
775
+ title: "Codebase health report",
776
+ description: "Scan a directory and return a one-shot health summary: file/symbol counts, language " +
777
+ "breakdown, a health grade (A\u2013F) and score, complexity hotspots, god nodes (most-imported " +
778
+ "symbols), dead exports, and circular dependencies. The `ast-map report` CLI renders this as HTML.",
779
+ inputSchema: {
780
+ path: z.string().optional().describe("Directory to scan. Defaults to the project root."),
781
+ },
782
+ }, async ({ path: input }) => {
783
+ try {
784
+ const { abs, rel } = resolveInRoot(input ?? ".");
785
+ if (!fs.statSync(abs).isDirectory()) {
786
+ return errorText(`"${input}" is not a directory. get_codebase_report requires a directory.`);
787
+ }
788
+ const data = await buildReport(abs, ROOT);
789
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...data });
790
+ }
791
+ catch (err) {
792
+ return errorText(describeError(err));
793
+ }
794
+ });
795
+ /* ─────────────────── tool: get_diff ────────────────────────────────────── */
796
+ server.registerTool("get_diff", {
797
+ title: "Git-aware change diff + blast radius",
798
+ description: "Compare the working tree against a git ref (default HEAD) and return which symbols were " +
799
+ "added/removed/modified per file, which changes are potentially **breaking** (removed or " +
800
+ "signature-changed exports), and the **blast radius** \u2014 files that depend on those breaking changes.",
801
+ inputSchema: {
802
+ base: z.string().optional().describe("Git ref to compare against. Default HEAD."),
803
+ path: z.string().optional().describe("Limit to a subdirectory. Default project root."),
804
+ },
805
+ }, async ({ base, path: input }) => {
806
+ try {
807
+ if (!isGitRepo(ROOT))
808
+ return errorText("Not a git repository (or git is unavailable).");
809
+ const { abs, rel } = resolveInRoot(input ?? ".");
810
+ const data = await computeDiff(abs, ROOT, base ?? "HEAD");
811
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...data });
812
+ }
813
+ catch (err) {
814
+ return errorText(describeError(err));
815
+ }
816
+ });
817
+ /* ─────────────────── tool: get_risk_map ────────────────────────────────── */
818
+ server.registerTool("get_risk_map", {
819
+ title: "Refactor risk map (churn \u00d7 complexity)",
820
+ description: "Rank files by refactor risk = git churn (number of commits touching the file) \u00d7 the file's " +
821
+ "max function complexity. Surfaces the files that are both frequently changed and complex \u2014 " +
822
+ "the most valuable refactor / test targets.",
823
+ inputSchema: {
824
+ path: z.string().optional().describe("Directory to scan. Default project root."),
825
+ },
826
+ }, async ({ path: input }) => {
827
+ try {
828
+ if (!isGitRepo(ROOT))
829
+ return errorText("Not a git repository (or git is unavailable).");
830
+ const { abs, rel } = resolveInRoot(input ?? ".");
831
+ const files = await computeRisk(abs, ROOT);
832
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", count: files.length, files: files.slice(0, 50) });
833
+ }
834
+ catch (err) {
835
+ return errorText(describeError(err));
836
+ }
837
+ });
838
+ /* ─────────────────── tool: pack_context ────────────────────────────────── */
839
+ server.registerTool("pack_context", {
840
+ title: "Minimal context pack for a symbol",
841
+ description: "Assemble the *minimal* context needed to understand or change a symbol \u2014 the symbol's own " +
842
+ "source, the signatures of what it depends on (resolved imports), and the files that depend on " +
843
+ "it \u2014 instead of reading whole files. Returns a token estimate so you can see the savings.",
844
+ inputSchema: {
845
+ path: z.string().describe("File containing the symbol (relative to root or absolute within it)."),
846
+ symbol: z.string().optional().describe("Symbol name to centre the pack on. Omit for the whole file."),
847
+ scan: z.string().optional().describe("Directory to scan for dependents. Default: project root."),
848
+ },
849
+ }, async ({ path: input, symbol, scan }) => {
850
+ try {
851
+ const { abs, rel } = resolveInRoot(input);
852
+ if (fs.statSync(abs).isDirectory())
853
+ return errorText(`"${input}" is a directory; pass a file.`);
854
+ const scanAbs = scan ? resolveInRoot(scan).abs : ROOT;
855
+ const pack = await packContext(abs, rel.split(path.sep).join("/"), ROOT, symbol, scanAbs);
856
+ return jsonText(pack);
857
+ }
858
+ catch (err) {
859
+ return errorText(describeError(err));
860
+ }
861
+ });
770
862
  /* ─────────────────── tool: get_change_impact ───────────────────────────── */
771
863
  server.registerTool("get_change_impact", {
772
864
  title: "Get change impact (blast radius)",
package/dist/report.js ADDED
@@ -0,0 +1,162 @@
1
+ import path from "node:path";
2
+ import { collectSourceFiles, buildSkeleton } from "./skeleton.js";
3
+ import { resolveOptions } from "./config.js";
4
+ import { buildSymbolGraph } from "./graph.js";
5
+ import { findDeadExports, findCircularDeps, getTopSymbols } from "./graph-analysis.js";
6
+ import { computeFileComplexity } from "./complexity.js";
7
+ function gradeFor(score) {
8
+ if (score >= 90)
9
+ return "A";
10
+ if (score >= 80)
11
+ return "B";
12
+ if (score >= 70)
13
+ return "C";
14
+ if (score >= 60)
15
+ return "D";
16
+ return "F";
17
+ }
18
+ export async function buildReport(absDir, root) {
19
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
20
+ const files = collectSourceFiles(absDir, opts);
21
+ const skeletons = [];
22
+ const langCount = new Map();
23
+ let symbolCount = 0;
24
+ const hotspots = [];
25
+ let cxSum = 0, cxN = 0, cxMax = 0;
26
+ for (const file of files) {
27
+ const rel = path.relative(root, file).split(path.sep).join("/");
28
+ try {
29
+ const skel = await buildSkeleton(file, rel, opts);
30
+ skeletons.push(skel);
31
+ symbolCount += skel.symbolCount;
32
+ langCount.set(skel.language, (langCount.get(skel.language) ?? 0) + 1);
33
+ const fc = await computeFileComplexity(file, rel);
34
+ if (fc) {
35
+ for (const f of fc.functions) {
36
+ hotspots.push({ ...f, file: rel });
37
+ cxSum += f.complexity;
38
+ cxN++;
39
+ cxMax = Math.max(cxMax, f.complexity);
40
+ }
41
+ }
42
+ }
43
+ catch { /* skip unparsable */ }
44
+ }
45
+ const graph = buildSymbolGraph(skeletons, root);
46
+ const dead = findDeadExports(graph).filter((d) => d.confidence === "high");
47
+ const cycles = findCircularDeps(graph);
48
+ const god = getTopSymbols(graph, 8);
49
+ hotspots.sort((a, b) => b.complexity - a.complexity);
50
+ const veryHigh = hotspots.filter((f) => f.complexity > 20).length;
51
+ const high = hotspots.filter((f) => f.complexity > 10 && f.complexity <= 20).length;
52
+ // Health score: start at 100, subtract weighted penalties.
53
+ let score = 100;
54
+ score -= Math.min(20, dead.length * 1.5);
55
+ score -= Math.min(22, cycles.length * 6);
56
+ score -= Math.min(28, veryHigh * 4 + high * 1);
57
+ score -= Math.min(12, god.filter((g) => g.importCount >= 8).length * 4);
58
+ score = Math.max(0, Math.round(score));
59
+ const languages = [...langCount.entries()]
60
+ .map(([lang, f]) => ({ lang, files: f }))
61
+ .sort((a, b) => b.files - a.files);
62
+ return {
63
+ project: absDir.split(/[\\/]/).filter(Boolean).pop() || "project",
64
+ generatedAt: new Date().toISOString(),
65
+ fileCount: skeletons.length,
66
+ symbolCount,
67
+ edgeCount: graph.edges.filter((e) => e.edgeType === "imports").length,
68
+ languages,
69
+ score,
70
+ grade: gradeFor(score),
71
+ dead: { count: dead.length, items: dead.slice(0, 25).map((d) => ({ file: d.file, symbol: d.symbol, kind: d.kind })) },
72
+ cycles: { count: cycles.length, items: cycles.slice(0, 12).map((c) => c.cycle) },
73
+ godNodes: god.map((g) => ({ symbol: g.symbol, file: g.file, importCount: g.importCount })),
74
+ complexity: { average: cxN ? Math.round((cxSum / cxN) * 10) / 10 : 0, max: cxMax, hotspots: hotspots.slice(0, 12) },
75
+ };
76
+ }
77
+ /* ─── Premium HTML dashboard ───────────────────────────────────────────────── */
78
+ const GRADE_COLOR = {
79
+ A: "#1d9e75", B: "#1d9e75", C: "#ba7517", D: "#d85a30", F: "#e24b4a",
80
+ };
81
+ function esc(s) {
82
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
83
+ }
84
+ function ratingColor(r) {
85
+ return r === "very-high" ? "#e24b4a" : r === "high" ? "#d85a30" : r === "moderate" ? "#ba7517" : "#1d9e75";
86
+ }
87
+ function statCard(label, value, accent) {
88
+ return `<div class="stat"><div class="sv"${accent ? ` style="color:${accent}"` : ""}>${value}</div><div class="sl">${label}</div></div>`;
89
+ }
90
+ function bar(label, value, max, color, right) {
91
+ const pct = max > 0 ? Math.round((value / max) * 100) : 0;
92
+ return `<div class="row"><div class="rl">${esc(label)}</div><div class="track"><div class="fill" style="width:${pct}%;background:${color}"></div></div><div class="rr">${right}</div></div>`;
93
+ }
94
+ export function buildReportHtml(d) {
95
+ const gc = GRADE_COLOR[d.grade] ?? "#888";
96
+ const maxLang = d.languages[0]?.files ?? 1;
97
+ const langs = d.languages.map((l) => bar(l.lang, l.files, maxLang, "#534ab7", `${l.files}`)).join("");
98
+ const maxCx = d.complexity.hotspots[0]?.complexity ?? 1;
99
+ const hotspots = d.complexity.hotspots.length
100
+ ? d.complexity.hotspots.map((h) => bar(`${h.name} · ${h.file}`, h.complexity, maxCx, ratingColor(h.rating), `<b>${h.complexity}</b>`)).join("")
101
+ : `<div class="empty">No functions found.</div>`;
102
+ const god = d.godNodes.length
103
+ ? d.godNodes.map((g) => `<div class="li"><span class="mono">${esc(g.symbol)}</span><span class="dim">${esc(g.file)}</span><span class="pill">${g.importCount} importers</span></div>`).join("")
104
+ : `<div class="empty">None.</div>`;
105
+ const dead = d.dead.count
106
+ ? d.dead.items.map((x) => `<div class="li"><span class="kbadge">${esc(x.kind)}</span><span class="mono">${esc(x.symbol)}</span><span class="dim">${esc(x.file)}</span></div>`).join("")
107
+ + (d.dead.count > d.dead.items.length ? `<div class="more">+${d.dead.count - d.dead.items.length} more…</div>` : "")
108
+ : `<div class="ok">✓ No high-confidence dead exports</div>`;
109
+ const cycles = d.cycles.count
110
+ ? d.cycles.items.map((c) => `<div class="li"><span class="mono">${esc(c.join(" → "))}</span></div>`).join("")
111
+ : `<div class="ok">✓ No circular dependencies</div>`;
112
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
113
+ <title>${esc(d.project)} — code health</title><style>
114
+ :root{--bg:#fafaf8;--card:#fff;--bd:#e7e5df;--tx:#2b2b28;--dim:#8a8880;--soft:#f1efe9}
115
+ @media(prefers-color-scheme:dark){:root{--bg:#161613;--card:#1e1e1b;--bd:#33332e;--tx:#e6e4dd;--dim:#9a988f;--soft:#26261f}}
116
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--tx);font-family:system-ui,-apple-system,sans-serif;line-height:1.5}
117
+ .wrap{max-width:980px;margin:0 auto;padding:32px 24px 60px}
118
+ .hero{display:flex;align-items:center;gap:24px;margin-bottom:28px}
119
+ .badge{width:104px;height:104px;border-radius:24px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#fff;flex:0 0 auto}
120
+ .badge .g{font-size:46px;font-weight:700;line-height:1}.badge .s{font-size:12px;opacity:.9}
121
+ .h1{font-size:26px;font-weight:650;margin:0}.sub{color:var(--dim);font-size:13px;margin-top:4px}
122
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:12px;margin-bottom:30px}
123
+ .stat{background:var(--card);border:1px solid var(--bd);border-radius:14px;padding:14px 16px}
124
+ .sv{font-size:24px;font-weight:650}.sl{font-size:12px;color:var(--dim);margin-top:2px}
125
+ .card{background:var(--card);border:1px solid var(--bd);border-radius:16px;padding:18px 20px;margin-bottom:18px}
126
+ .card h2{font-size:14px;font-weight:600;margin:0 0 14px;letter-spacing:.02em;text-transform:uppercase;color:var(--dim)}
127
+ .row{display:flex;align-items:center;gap:12px;margin:7px 0;font-size:13px}
128
+ .rl{flex:0 0 46%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
129
+ .track{flex:1;height:8px;background:var(--soft);border-radius:5px;overflow:hidden}.fill{height:100%;border-radius:5px}
130
+ .rr{flex:0 0 auto;color:var(--dim);min-width:32px;text-align:right}
131
+ .li{display:flex;align-items:center;gap:10px;padding:5px 0;font-size:13px;border-top:1px solid var(--bd)}.li:first-child{border-top:none}
132
+ .mono{font-family:ui-monospace,monospace;font-weight:550}.dim{color:var(--dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
133
+ .pill{margin-left:auto;background:var(--soft);border-radius:20px;padding:2px 10px;font-size:11px;color:var(--dim);flex:0 0 auto}
134
+ .kbadge{font-size:11px;color:var(--dim);background:var(--soft);border-radius:5px;padding:1px 7px;flex:0 0 auto}
135
+ .ok{color:#1d9e75;font-size:13px}.empty{color:var(--dim);font-size:13px}.more{color:var(--dim);font-size:12px;padding-top:6px}
136
+ .two{display:grid;grid-template-columns:1fr 1fr;gap:18px}@media(max-width:720px){.two{grid-template-columns:1fr}.rl{flex-basis:42%}}
137
+ .foot{color:var(--dim);font-size:11px;text-align:center;margin-top:24px}
138
+ </style></head><body><div class="wrap">
139
+ <div class="hero">
140
+ <div class="badge" style="background:${gc}"><div class="g">${d.grade}</div><div class="s">${d.score}/100</div></div>
141
+ <div><h1 class="h1">${esc(d.project)} — code health</h1>
142
+ <div class="sub">${d.fileCount} files · ${d.symbolCount} symbols · ${d.languages.length} language(s) · ${esc(d.generatedAt.slice(0, 10))}</div></div>
143
+ </div>
144
+ <div class="grid">
145
+ ${statCard("Files", d.fileCount)}
146
+ ${statCard("Symbols", d.symbolCount)}
147
+ ${statCard("Import edges", d.edgeCount)}
148
+ ${statCard("Avg complexity", d.complexity.average)}
149
+ ${statCard("Max complexity", d.complexity.max, ratingColor(d.complexity.max > 20 ? "very-high" : d.complexity.max > 10 ? "high" : "low"))}
150
+ ${statCard("Dead exports", d.dead.count, d.dead.count ? "#d85a30" : "#1d9e75")}
151
+ ${statCard("Cycles", d.cycles.count, d.cycles.count ? "#e24b4a" : "#1d9e75")}
152
+ </div>
153
+ <div class="card"><h2>Language breakdown</h2>${langs}</div>
154
+ <div class="card"><h2>Complexity hotspots</h2>${hotspots}</div>
155
+ <div class="two">
156
+ <div class="card"><h2>God nodes (most imported)</h2>${god}</div>
157
+ <div class="card"><h2>Circular dependencies</h2>${cycles}</div>
158
+ </div>
159
+ <div class="card"><h2>Dead exports (high confidence)</h2>${dead}</div>
160
+ <div class="foot">Generated by AST-MCP · universal-ast-mapper</div>
161
+ </div></body></html>`;
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-ast-mapper",
3
- "version": "1.10.0",
3
+ "version": "1.13.0",
4
4
  "description": "MCP server that maps source files into a normalized code skeleton (JSON + HTML) using tree-sitter.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",