universal-ast-mapper 1.19.0 → 1.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,45 @@ since 1.0.0, guarantees a stable MCP tool / CLI surface across the 1.x line.
6
6
 
7
7
  ---
8
8
 
9
+ ## [1.22.1] — 2026-06-10 · Docs
10
+ - README refreshed to match v1.20–1.22: 28 tools / 30 commands, PHP+Ruby capability
11
+ columns, `cache`/`check` CLI + config + env-var docs, `check_quality_gate` reference,
12
+ Action `mode: check` example, new Performance section. No code changes.
13
+
14
+ ## [1.22.0] — 2026-06-10 · PHP & Ruby support
15
+ - **PHP** (`.php`): classes/interfaces/traits/enums, methods with visibility modifiers,
16
+ class consts + properties, namespaces; imports from `use` (incl. grouped `use A\{B, C}`
17
+ and aliases) and `require`/`include` (side-effect).
18
+ - **Ruby** (`.rb`, `.rake`): classes, modules (→ namespace), methods, `self.` singleton
19
+ methods, constants; **section-style visibility** (`private`/`protected`/`public`);
20
+ imports from `require` / `require_relative`.
21
+ - **web-tree-sitter 0.20.8 → 0.21.0** — unblocks the Ruby grammar (external-scanner
22
+ crash on the old runtime); no API change, all grammars + suites re-verified.
23
+ - Tests: `Sample.php` + `sample.rb` fixtures, 30 new smoke assertions. **16 languages.**
24
+
25
+ ## [1.21.0] — 2026-06-10 · Quality gate (`ast-map check`)
26
+ - **`ast-map check [dir]`** — CI quality gate with two mechanisms: a **baseline ratchet**
27
+ (vs a committed `.ast-map.baseline.json`; fails when cycles, dead exports, SDP violations,
28
+ very-high-complexity functions rise or the health score drops; `--update-baseline`
29
+ re-anchors) and **absolute thresholds** (CLI flags or `.ast-map.config.json` → `"check"`).
30
+ Non-zero exit on failure; `--json` for tooling.
31
+ - New MCP tool **`check_quality_gate`** (28 tools) — same gate for agents.
32
+ - **GitHub Action**: `mode: validate | check | both` + `check-args` inputs.
33
+ - New module `check` (`runQualityGate`, `metricsFromReport`); `AstMapConfig.check`.
34
+ - Tests: new `test/check-smoke.mjs` (13 checks), wired into `npm test`.
35
+
36
+ ## [1.20.0] — 2026-06-10 · Incremental cache + parallel parsing
37
+ - **Persistent parse cache**: skeletons are cached on disk under `<root>/.ast-map/cache`,
38
+ keyed by content hash + detail + schema/grammar versions — never stale by construction,
39
+ survives across processes (warm hits on large files ~60× faster than a re-parse).
40
+ On by default; disable with `AST_MAP_NO_CACHE=1` or `"cache": false` in config.
41
+ - **Parallel parsing**: bulk scans distribute work over a worker-thread pool
42
+ (auto-sized, engages at ≥ 64 files, `AST_MAP_WORKERS` override, sequential fallback
43
+ on any worker failure). `report` computes per-file complexity in the workers too.
44
+ - New CLI command `ast-map cache [stats|clear]`.
45
+ - New modules `diskcache` and `pool` (`buildSkeletonsBulk`); `AstMapConfig.cache`.
46
+ - Tests: new `test/cache-smoke.mjs` (18 checks), wired into `npm test`.
47
+
9
48
  ## [1.19.0] — 2026-06-09 · Dashboard: coupling + SDP
10
49
  - The health dashboard (`ast-map report` / `get_codebase_report`) now surfaces the
11
50
  v1.14–1.16 architecture metrics: a **Module coupling** card (per-directory instability
package/README.md CHANGED
@@ -4,20 +4,20 @@ 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
- **27 MCP tools / 28 CLI commands / 5 MCP prompts** 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`).
7
+ **28 MCP tools / 30 CLI commands / 5 MCP prompts** 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**, a one-page **health dashboard** (`ast-map report`), a **persistent parse cache + parallel parsing** (warm re-scans skip parsing entirely), and a **CI quality gate** (`ast-map check`, baseline ratchet).
8
8
 
9
- **Supported languages:** TypeScript · TSX · JavaScript (ESM/CJS) · Python · Go · Rust · Java · C# · C · C++ · Kotlin · Swift · **Vue** · **Svelte** (SFC `<script>`)
9
+ **Supported languages:** TypeScript · TSX · JavaScript (ESM/CJS) · Python · Go · Rust · Java · C# · C · C++ · Kotlin · Swift · Vue · Svelte (SFC `<script>`) · **PHP** · **Ruby**
10
10
 
11
- | Capability | TS/JS | Python | Go | Rust | Java | C# | C | C++ | Kt | Swift |
12
- |--------------------------|:-----:|:------:|:---:|:----:|:----:|:---:|:---:|:---:|:---:|:-----:|
13
- | Symbol extraction | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
14
- | Imports parsing | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
15
- | Graph `imports` edges | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
16
- | `resolve_imports` enrich | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
17
- | Call graph callee origin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — |
18
- | Reverse `calledBy` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — |
11
+ | Capability | TS/JS | Python | Go | Rust | Java | C# | C | C++ | Kt | Swift | PHP | Ruby |
12
+ |--------------------------|:-----:|:------:|:---:|:----:|:----:|:---:|:---:|:---:|:---:|:-----:|:---:|:----:|
13
+ | Symbol extraction | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
14
+ | Imports parsing | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
15
+ | Graph `imports` edges | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — |
16
+ | `resolve_imports` enrich | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — |
17
+ | Call graph callee origin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — | — | — |
18
+ | Reverse `calledBy` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — | — | — |
19
19
 
20
- > As of v0.8.2, all four v0.8.0 languages have **cross-file graph + resolver** wiring: Kotlin (FQCN/package index), C/C++ (`#include` with header↔impl pairing), and Swift (module = directory under `Sources/`). Call-graph callee origin is resolved for Kotlin; for C/C++/Swift it stays limited because their imports don't name individual symbols. (Ruby grammar in `tree-sitter-wasms@0.1.13` is unstable and was skipped.)
20
+ > As of v0.8.2, all four v0.8.0 languages have **cross-file graph + resolver** wiring: Kotlin (FQCN/package index), C/C++ (`#include` with header↔impl pairing), and Swift (module = directory under `Sources/`). Call-graph callee origin is resolved for Kotlin; for C/C++/Swift it stays limited because their imports don't name individual symbols. (PHP & Ruby landed in v1.22.0 — symbol extraction + imports; cross-file graph wiring for them is the next step. Ruby was unblocked by upgrading `web-tree-sitter` to 0.21.0.)
21
21
 
22
22
  Each language uses the resolution strategy that fits it:
23
23
  - **TS/JS/Python** — relative paths (`./foo`, `..mod`) resolved against the importing file's directory, with TS-ESM `.js` → `.ts` rewriting.
@@ -110,6 +110,8 @@ ast-map pack <file> [symbol] [--scan <d>] # minimal context pack
110
110
  ast-map coupling [dir] [-n N] # Ca / Ce / instability per file
111
111
  ast-map layers [dir] [-g gap] # SDP: stable→volatile violations
112
112
  ast-map modules [dir] # directory-level coupling + edges
113
+ ast-map cache [stats|clear] # persistent parse cache (.ast-map/cache)
114
+ ast-map check [dir] [--update-baseline] [--min-score N] [--max-cycles N] ...
113
115
  ast-map search <pattern> [dir] [-m contains|exact|regex] [-k kind] [-e]
114
116
  ast-map deps <file> [--scan <dir>]
115
117
  ast-map top <dir> [-n 10]
@@ -524,6 +526,13 @@ Thresholds can be set per-call or in `.ast-map.config.json`.
524
526
 
525
527
  ---
526
528
 
529
+ ### `check_quality_gate`
530
+ Run the CI quality gate: **absolute thresholds** (from `.ast-map.config.json` → `"check"`) plus a **baseline ratchet** against `.ast-map.baseline.json` — fails when cycles, dead exports, SDP violations, very-high-complexity functions rise, or the health score drops. Set `updateBaseline` to re-anchor at the current metrics. Same engine as `ast-map check`.
531
+
532
+ **Params:** `path`, `baseline`, `updateBaseline`
533
+
534
+ ---
535
+
527
536
  ### `get_top_symbols`
528
537
  Return the N most-imported symbols — your codebase's "God Nodes" where a breaking change has maximum blast radius.
529
538
 
@@ -540,18 +549,44 @@ Place in your project root. All fields optional.
540
549
  "ignore": ["dist", "coverage", ".turbo"],
541
550
  "maxFileBytes": 500000,
542
551
  "outputDir": ".ast-map",
552
+ "cache": true,
543
553
  "rules": {
544
554
  "large-file": { "maxLines": 400 },
545
555
  "too-many-imports": { "maxImports": 20 },
546
556
  "god-export": { "maxExports": 15 }
557
+ },
558
+ "check": {
559
+ "maxCycles": 0,
560
+ "maxSdpViolations": 10,
561
+ "minScore": 70
547
562
  }
548
563
  }
549
564
  ```
550
565
 
566
+ - `cache` — persistent parse cache in `<root>/.ast-map/cache` (default `true`; also disabled by `AST_MAP_NO_CACHE=1`). Inspect/clear with `ast-map cache [stats|clear]`.
567
+ - `check` — default thresholds for `ast-map check` / `check_quality_gate`; CLI flags override per run.
568
+
551
569
  The config is read live — changes take effect on the next call without restarting the MCP server.
552
570
 
553
571
  ---
554
572
 
573
+ ## Performance — cache & parallel parsing
574
+
575
+ Since **v1.20.0**, bulk scans are fast twice over:
576
+
577
+ - **Persistent parse cache** — every parsed file's skeleton is stored under `<root>/.ast-map/cache`, keyed by a SHA-1 of its content + detail + schema/grammar versions. A changed file hashes to a new key, so entries are **never stale by construction**, and the cache survives across processes — a re-run on an unchanged repo skips parsing entirely (warm hits on large files ≈ 60× faster).
578
+ - **Worker-thread parallel parsing** — batches of ≥ 64 files are distributed over a pool sized from your CPU count (max 8); smaller batches stay sequential so there's no startup-cost penalty. Any worker failure falls back to sequential parsing.
579
+
580
+ | Env var | Effect |
581
+ |---------|--------|
582
+ | `AST_MAP_NO_CACHE=1` | disable the disk cache for this run |
583
+ | `AST_MAP_WORKERS=0` | force sequential parsing |
584
+ | `AST_MAP_WORKERS=N` | force a pool of N workers (bypasses the batch-size gate) |
585
+
586
+ `ast-map cache` shows entry count + size; `ast-map cache clear` wipes it. `.ast-map/` is already in the default ignore list — add it to `.gitignore` if it isn't.
587
+
588
+ ---
589
+
555
590
  ## Power Prompts
556
591
 
557
592
  ### Full Architecture Audit
@@ -712,7 +747,7 @@ jobs:
712
747
  - uses: actions/checkout@v4
713
748
  - uses: actions/setup-node@v4
714
749
  with: { node-version: "20" }
715
- - uses: 6ixthxense/AST-MCP@v1.0.0
750
+ - uses: 6ixthxense/AST-MCP@v1
716
751
  with:
717
752
  path: src
718
753
  max-lines: "400"
@@ -720,7 +755,19 @@ jobs:
720
755
  max-exports: "15"
721
756
  ```
722
757
 
723
- The action runs `ast-map validate` and fails the job on threshold violations. You can also call any CLI command directly with `npx -p universal-ast-mapper ast-map <command>`.
758
+ The action runs `ast-map validate` and fails the job on threshold violations.
759
+
760
+ Since **v1.21.0** the action can also run the **quality gate** (baseline ratchet + thresholds). Commit a baseline once (`ast-map check src --update-baseline`), then:
761
+
762
+ ```yaml
763
+ - uses: 6ixthxense/AST-MCP@v1
764
+ with:
765
+ path: src
766
+ mode: check # validate | check | both
767
+ check-args: "--min-score 70 --max-cycles 0"
768
+ ```
769
+
770
+ The job fails whenever cycles, dead exports, SDP violations, or complexity regress past the committed `.ast-map.baseline.json`. You can also call any CLI command directly with `npx -p universal-ast-mapper ast-map <command>`.
724
771
 
725
772
  ---
726
773
 
@@ -740,6 +787,9 @@ Not part of the public API: the internal `src/` module layout and the generated
740
787
 
741
788
  | Version | What changed |
742
789
  |---------|--------------|
790
+ | **1.22.0** | **PHP & Ruby support** — `.php` (classes, interfaces, traits, enums, methods with visibility, `use` imports incl. grouped, require/include) and `.rb`/`.rake` (classes, modules, methods, `self.` singleton methods, `private` section tracking, require/require_relative). Unblocked by upgrading `web-tree-sitter` 0.20.8 → 0.21.0 (all existing grammars re-verified). **16 languages**. |
791
+ | **1.21.0** | **Quality gate** — `ast-map check` fails CI when quality regresses: **baseline ratchet** vs `.ast-map.baseline.json` (cycles · dead exports · SDP · very-high complexity · score; `--update-baseline` re-anchors) + absolute thresholds (flags or config `"check"`). New MCP tool `check_quality_gate` (**28 tools**); GitHub Action gains `mode: check`. |
792
+ | **1.20.0** | **Incremental cache + parallel parsing** — persistent content-hash parse cache in `.ast-map/cache` (on by default, never stale, warm hits ~60× faster on large files; `ast-map cache stats|clear`, `AST_MAP_NO_CACHE`, `"cache": false`) + worker-thread **parallel parsing** for bulk scans (auto-sized, `AST_MAP_WORKERS` override, sequential fallback). |
743
793
  | **1.19.0** | **Dashboard: coupling + SDP** — `ast-map report` / `get_codebase_report` now include **module coupling** (per-directory instability bars) and **layer violations** (stable→volatile, SDP) cards, plus an SDP stat; SDP inversions also factor into the health score. The v1.14–1.16 metrics are now visual. |
744
794
  | **1.18.0** | **Vue & Svelte SFC support** — `.vue` and `.svelte` single-file components are now parsed: the `<script>` / `<script setup>` block is lifted out (TS or JS) and its symbols + imports extracted, with cross-file graph edges into plain modules. Blank-padding keeps every symbol's line numbers pointing at the original SFC. **14 languages**. |
745
795
  | **1.17.0** | **MCP prompts** — the server now registers 5 parameterized **prompts** (`architecture_audit`, `safe_refactor`, `dead_code_cleanup`, `health_check`, `onboard_codebase`): named workflows a client can invoke from its prompt menu, each chaining the right tools. The Cookbook recipes, one call away. |
package/dist/check.js ADDED
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { buildReport } from "./report.js";
4
+ export const BASELINE_FILENAME = ".ast-map.baseline.json";
5
+ export function metricsFromReport(r) {
6
+ return {
7
+ fileCount: r.fileCount,
8
+ symbolCount: r.symbolCount,
9
+ cycles: r.cycles.count,
10
+ deadExports: r.dead.count,
11
+ sdpViolations: r.layerViolations.count,
12
+ veryHighComplexity: r.complexity.hotspots.filter((h) => h.complexity > 20).length,
13
+ maxComplexity: r.complexity.max,
14
+ score: r.score,
15
+ grade: r.grade,
16
+ };
17
+ }
18
+ function readBaseline(file) {
19
+ try {
20
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
21
+ return raw.metrics ?? null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ function checkThresholds(m, t, out) {
28
+ const rules = [
29
+ ["maxCycles", "cycles", "max"],
30
+ ["maxDeadExports", "deadExports", "max"],
31
+ ["maxSdpViolations", "sdpViolations", "max"],
32
+ ["maxVeryHighComplexity", "veryHighComplexity", "max"],
33
+ ["maxComplexity", "maxComplexity", "max"],
34
+ ["minScore", "score", "min"],
35
+ ];
36
+ for (const [tKey, mKey, dir] of rules) {
37
+ const limit = t[tKey];
38
+ if (limit === undefined)
39
+ continue;
40
+ const actual = m[mKey];
41
+ const bad = dir === "max" ? actual > limit : actual < limit;
42
+ if (bad) {
43
+ out.push({
44
+ kind: "threshold",
45
+ metric: mKey,
46
+ limit,
47
+ actual,
48
+ message: `${mKey} is ${actual}, ${dir === "max" ? "exceeds max" : "below min"} ${limit}`,
49
+ });
50
+ }
51
+ }
52
+ }
53
+ /** Metrics where an increase vs the baseline is a regression. */
54
+ const RATCHET_UP = [
55
+ "cycles",
56
+ "deadExports",
57
+ "sdpViolations",
58
+ "veryHighComplexity",
59
+ ];
60
+ function checkBaseline(m, base, out) {
61
+ for (const key of RATCHET_UP) {
62
+ const was = base[key];
63
+ const now = m[key];
64
+ if (now > was) {
65
+ out.push({
66
+ kind: "regression",
67
+ metric: key,
68
+ limit: was,
69
+ actual: now,
70
+ message: `${key} regressed: ${was} → ${now} (baseline ratchet)`,
71
+ });
72
+ }
73
+ }
74
+ if (m.score < base.score) {
75
+ out.push({
76
+ kind: "regression",
77
+ metric: "score",
78
+ limit: base.score,
79
+ actual: m.score,
80
+ message: `health score regressed: ${base.score} → ${m.score}`,
81
+ });
82
+ }
83
+ }
84
+ export async function runQualityGate(absDir, root, opts = {}) {
85
+ const report = await buildReport(absDir, root);
86
+ const metrics = metricsFromReport(report);
87
+ const baselinePath = path.resolve(root, opts.baselinePath ?? BASELINE_FILENAME);
88
+ const baseline = readBaseline(baselinePath);
89
+ const failures = [];
90
+ if (opts.thresholds)
91
+ checkThresholds(metrics, opts.thresholds, failures);
92
+ if (baseline)
93
+ checkBaseline(metrics, baseline, failures);
94
+ let baselineUpdated = false;
95
+ if (opts.updateBaseline) {
96
+ const doc = {
97
+ tool: "universal-ast-mapper",
98
+ updatedAt: new Date().toISOString(),
99
+ metrics,
100
+ };
101
+ fs.writeFileSync(baselinePath, JSON.stringify(doc, null, 2) + "\n", "utf8");
102
+ baselineUpdated = true;
103
+ }
104
+ return {
105
+ passed: failures.length === 0,
106
+ metrics,
107
+ baseline,
108
+ baselinePath,
109
+ baselineUpdated,
110
+ failures,
111
+ };
112
+ }
package/dist/cli.js CHANGED
@@ -5,6 +5,8 @@ import fs from "node:fs";
5
5
  import { buildSkeleton, collectSourceFiles } from "./skeleton.js";
6
6
  import { renderHtml, renderCombinedHtml } from "./html.js";
7
7
  import { resolveOptions, loadProjectConfig } from "./config.js";
8
+ import { initDiskCache, defaultCacheDir, diskCacheStats, clearDiskCache } from "./diskcache.js";
9
+ import { buildSkeletonsBulk } from "./pool.js";
8
10
  import { supportedLanguages } from "./registry.js";
9
11
  import { findSymbol, findRelatedSymbols, findServerImports, isApiRoute, findMissingTryCatch, checkGeneralRules, GENERAL_RULE_DEFAULTS } from "./analysis.js";
10
12
  import { resolveFileImports } from "./resolver.js";
@@ -17,6 +19,7 @@ import { discoverWorkspace, findPackageCycles } from "./workspace.js";
17
19
  import { buildExplorerHtml } from "./explorer.js";
18
20
  import { readSourceMap } from "./sourcemap.js";
19
21
  import { buildReport, buildReportHtml } from "./report.js";
22
+ import { runQualityGate, BASELINE_FILENAME } from "./check.js";
20
23
  import { computeDiff, computeRisk, isGitRepo } from "./gitdiff.js";
21
24
  import { packContext } from "./contextpack.js";
22
25
  import { computeCoupling } from "./coupling.js";
@@ -25,6 +28,10 @@ import { computeModuleCoupling } from "./modulecoupling.js";
25
28
  import { buildCallGraph } from "./callgraph.js";
26
29
  import { searchSymbols } from "./search.js";
27
30
  const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
31
+ // Persistent parse cache (disable with AST_MAP_NO_CACHE=1 or "cache": false in config).
32
+ if (process.env.AST_MAP_NO_CACHE !== "1" && loadProjectConfig(ROOT).cache !== false) {
33
+ initDiskCache(defaultCacheDir(ROOT));
34
+ }
28
35
  // ─── ANSI colours (disabled when not a TTY) ───────────────────────────────────
29
36
  const tty = process.stdout.isTTY ?? false;
30
37
  const esc = (code) => (s) => tty ? `\x1b[${code}m${s}\x1b[0m` : s;
@@ -65,20 +72,38 @@ function resolveArg(p) {
65
72
  async function gatherSkeletons(dirAbs, detail = "outline") {
66
73
  const opts = resolveOptions({ detail, emitHtml: false });
67
74
  const files = collectSourceFiles(dirAbs, opts);
68
- const skeletons = [];
69
- for (const file of files) {
70
- const fr = path.relative(ROOT, file).split(path.sep).join("/");
71
- try {
72
- skeletons.push(await buildSkeleton(file, fr, opts));
73
- }
74
- catch { /* skip parse errors */ }
75
- }
76
- return skeletons;
75
+ const items = files.map((f) => ({ abs: f, rel: path.relative(ROOT, f).split(path.sep).join("/") }));
76
+ const built = await buildSkeletonsBulk(items, opts);
77
+ return built.filter((r) => r !== null).map((r) => r.skel);
77
78
  }
78
79
  function die(msg) {
79
80
  console.error(red("✗") + " " + msg);
80
81
  process.exit(1);
81
82
  }
83
+ // ─── Command: cache ───────────────────────────────────────────────────────────
84
+ program
85
+ .command("cache [action]")
86
+ .description("Inspect or clear the persistent parse cache (actions: stats, clear)")
87
+ .option("--json", "Output as JSON")
88
+ .action((action, opts) => {
89
+ const dir = defaultCacheDir(ROOT);
90
+ if (action === "clear") {
91
+ const removed = clearDiskCache(dir);
92
+ if (opts.json)
93
+ jsonOut({ dir, removed });
94
+ else
95
+ console.log(green("\u2713") + ` cleared ${removed} cached ${removed === 1 ? "entry" : "entries"} (${dir})`);
96
+ return;
97
+ }
98
+ const stats = diskCacheStats(dir);
99
+ if (opts.json)
100
+ jsonOut(stats);
101
+ else {
102
+ console.log(bold("Parse cache") + " " + dim(stats.dir));
103
+ console.log(` entries: ${stats.entries}`);
104
+ console.log(` size: ${(stats.bytes / 1024).toFixed(1)} KB`);
105
+ }
106
+ });
82
107
  // ─── Command: langs ───────────────────────────────────────────────────────────
83
108
  program
84
109
  .command("langs")
@@ -651,6 +676,69 @@ program
651
676
  console.log(indent(green("✓ wrote " + path.relative(process.cwd(), out))));
652
677
  console.log();
653
678
  });
679
+ // ─── Command: check ───────────────────────────────────────────────────────────
680
+ const num = (v) => Number.parseFloat(v);
681
+ program
682
+ .command("check [dir]")
683
+ .description("CI quality gate: absolute thresholds + baseline ratchet (cycles, dead exports, SDP, complexity, score)")
684
+ .option("--baseline <file>", `Baseline file (default ${BASELINE_FILENAME})`)
685
+ .option("--update-baseline", "Write current metrics as the new baseline")
686
+ .option("--max-cycles <n>", "Fail when circular dependencies exceed n", num)
687
+ .option("--max-dead-exports <n>", "Fail when dead exports exceed n", num)
688
+ .option("--max-sdp-violations <n>", "Fail when SDP/layer violations exceed n", num)
689
+ .option("--max-very-high-complexity <n>", "Fail when functions with complexity > 20 exceed n", num)
690
+ .option("--max-complexity <n>", "Fail when any function's complexity exceeds n", num)
691
+ .option("--min-score <n>", "Fail when the health score drops below n", num)
692
+ .option("--json", "Output the gate result as JSON")
693
+ .action(async (dir, o) => {
694
+ const { abs, rel } = resolveArg(dir ?? ".");
695
+ if (!fs.statSync(abs).isDirectory())
696
+ die(`"${rel}" is not a directory`);
697
+ const fromConfig = loadProjectConfig(ROOT).check ?? {};
698
+ const thresholds = {
699
+ maxCycles: o.maxCycles ?? fromConfig.maxCycles,
700
+ maxDeadExports: o.maxDeadExports ?? fromConfig.maxDeadExports,
701
+ maxSdpViolations: o.maxSdpViolations ?? fromConfig.maxSdpViolations,
702
+ maxVeryHighComplexity: o.maxVeryHighComplexity ?? fromConfig.maxVeryHighComplexity,
703
+ maxComplexity: o.maxComplexity ?? fromConfig.maxComplexity,
704
+ minScore: o.minScore ?? fromConfig.minScore,
705
+ };
706
+ const result = await runQualityGate(abs, ROOT, {
707
+ baselinePath: o.baseline,
708
+ thresholds,
709
+ updateBaseline: o.updateBaseline,
710
+ });
711
+ if (o.json) {
712
+ jsonOut(result);
713
+ if (!result.passed)
714
+ process.exit(1);
715
+ return;
716
+ }
717
+ header(`Quality gate \u2014 ${rel}/`);
718
+ const m = result.metrics;
719
+ const b = result.baseline;
720
+ const delta = (key) => b ? dim(` (baseline ${String(b[key])})`) : "";
721
+ console.log(indent(`score ${bold(String(m.score))}/100 (${m.grade})${delta("score")}`));
722
+ console.log(indent(`cycles ${m.cycles}${delta("cycles")} · dead exports ${m.deadExports}${delta("deadExports")} · SDP ${m.sdpViolations}${delta("sdpViolations")}`));
723
+ console.log(indent(`complexity: max ${m.maxComplexity} · very-high (>20) ${m.veryHighComplexity}${delta("veryHighComplexity")}`));
724
+ if (result.baselineUpdated) {
725
+ console.log(indent(green("\u2713") + " baseline updated: " + path.relative(process.cwd(), result.baselinePath)));
726
+ }
727
+ else if (!b) {
728
+ console.log(indent(dim(`no baseline (${path.relative(process.cwd(), result.baselinePath)}) \u2014 run with --update-baseline to create one`)));
729
+ }
730
+ if (result.failures.length > 0) {
731
+ console.log();
732
+ for (const f of result.failures) {
733
+ console.log(indent(red("\u2717") + ` [${f.kind}] ${f.message}`));
734
+ }
735
+ console.log();
736
+ console.log(indent(red(`gate FAILED \u2014 ${result.failures.length} violation(s)`)));
737
+ process.exit(1);
738
+ }
739
+ console.log(indent(green("\u2713 gate passed")));
740
+ console.log();
741
+ });
654
742
  // ─── Command: explore ─────────────────────────────────────────────────────────
655
743
  program
656
744
  .command("explore [dir]")
@@ -0,0 +1,97 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ // ─── Persistent (on-disk) parse cache ─────────────────────────────────────────
5
+ // Content-hash keyed: the key embeds schemaVersion + grammar source + detail +
6
+ // the file's raw bytes, so entries never go stale — a changed file simply maps
7
+ // to a new key. Stored as sharded JSON files under <root>/.ast-map/cache.
8
+ // Enabled by calling initDiskCache() once at startup (CLI / MCP server / worker).
9
+ let cacheDir = null;
10
+ /** Enable (or disable with null) the disk cache for this process. */
11
+ export function initDiskCache(dir) {
12
+ cacheDir = dir;
13
+ }
14
+ /** The currently active cache directory, or null when disabled. */
15
+ export function diskCacheDir() {
16
+ return cacheDir;
17
+ }
18
+ /** Conventional cache location for a project root. */
19
+ export function defaultCacheDir(root) {
20
+ return path.join(root, ".ast-map", "cache");
21
+ }
22
+ /** Stable cache key for a (source, detail, schema, grammar) tuple. */
23
+ export function diskCacheKey(source, detail, schemaVersion, grammarSource) {
24
+ return crypto
25
+ .createHash("sha1")
26
+ .update(`${schemaVersion}\0${grammarSource}\0${detail}\0`)
27
+ .update(source)
28
+ .digest("hex");
29
+ }
30
+ function shardPath(dir, key) {
31
+ return path.join(dir, key.slice(0, 2), key.slice(2) + ".json");
32
+ }
33
+ /** Read a cached skeleton, or null on miss / disabled / corrupt entry. */
34
+ export function diskCacheGet(key) {
35
+ if (!cacheDir)
36
+ return null;
37
+ try {
38
+ const raw = fs.readFileSync(shardPath(cacheDir, key), "utf8");
39
+ const parsed = JSON.parse(raw);
40
+ return parsed.skel ?? null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** Persist a skeleton under the given key (best-effort, never throws). */
47
+ export function diskCachePut(key, skel) {
48
+ if (!cacheDir)
49
+ return;
50
+ try {
51
+ const file = shardPath(cacheDir, key);
52
+ fs.mkdirSync(path.dirname(file), { recursive: true });
53
+ const tmp = file + "." + process.pid + ".tmp";
54
+ fs.writeFileSync(tmp, JSON.stringify({ skel }));
55
+ fs.renameSync(tmp, file);
56
+ }
57
+ catch {
58
+ /* cache write failures are non-fatal */
59
+ }
60
+ }
61
+ /** Count entries + total size of a cache directory. */
62
+ export function diskCacheStats(dir) {
63
+ let entries = 0;
64
+ let bytes = 0;
65
+ const walk = (d) => {
66
+ let names = [];
67
+ try {
68
+ names = fs.readdirSync(d, { withFileTypes: true });
69
+ }
70
+ catch {
71
+ return;
72
+ }
73
+ for (const e of names) {
74
+ const p = path.join(d, e.name);
75
+ if (e.isDirectory())
76
+ walk(p);
77
+ else if (e.name.endsWith(".json")) {
78
+ entries++;
79
+ try {
80
+ bytes += fs.statSync(p).size;
81
+ }
82
+ catch { /* skip */ }
83
+ }
84
+ }
85
+ };
86
+ walk(dir);
87
+ return { dir, entries, bytes };
88
+ }
89
+ /** Remove every entry in a cache directory. Returns how many were removed. */
90
+ export function clearDiskCache(dir) {
91
+ const { entries } = diskCacheStats(dir);
92
+ try {
93
+ fs.rmSync(dir, { recursive: true, force: true });
94
+ }
95
+ catch { /* best-effort */ }
96
+ return entries;
97
+ }
@@ -0,0 +1,208 @@
1
+ import { namedChildren, nameOf, headerSignature, leadingComment } from "../parser.js";
2
+ import { makeSymbol } from "./common.js";
3
+ // ─── PHP extractor (tree-sitter-php) ──────────────────────────────────────────
4
+ export function extractPhp(root, _source) {
5
+ return collect(namedChildren(root));
6
+ }
7
+ function collect(nodes) {
8
+ const out = [];
9
+ for (const n of nodes) {
10
+ const sym = handle(n);
11
+ if (sym)
12
+ out.push(sym);
13
+ else if (n.type === "namespace_definition") {
14
+ // `namespace Foo;` (no braces) — siblings follow; emit the namespace marker.
15
+ const name = nameOf(n)?.replace(/\s+/g, "") ?? namespaceName(n);
16
+ if (name) {
17
+ out.push(makeSymbol({ name, kind: "namespace", node: n, rawKind: n.type }));
18
+ }
19
+ const body = n.childForFieldName("body");
20
+ if (body)
21
+ out[out.length - 1].children = collect(namedChildren(body));
22
+ }
23
+ else if (n.type === "expression_statement" || n.type === "if_statement") {
24
+ // skip — top-level statements
25
+ }
26
+ }
27
+ return out;
28
+ }
29
+ function namespaceName(n) {
30
+ for (const c of namedChildren(n)) {
31
+ if (c.type === "namespace_name")
32
+ return c.text.replace(/\s+/g, "");
33
+ }
34
+ return null;
35
+ }
36
+ function phpVisibility(node) {
37
+ for (const c of namedChildren(node)) {
38
+ if (c.type === "visibility_modifier") {
39
+ const t = c.text;
40
+ if (t === "private" || t === "protected")
41
+ return "private";
42
+ return "public";
43
+ }
44
+ }
45
+ return "public";
46
+ }
47
+ function classLike(node, kind) {
48
+ const name = nameOf(node) ?? "(class)";
49
+ const body = node.childForFieldName("body") ?? findChild(node, "declaration_list");
50
+ return makeSymbol({
51
+ name,
52
+ kind,
53
+ node,
54
+ rawKind: node.type,
55
+ signature: headerSignature(node, body),
56
+ doc: leadingComment(node),
57
+ children: body ? collect(namedChildren(body)) : [],
58
+ });
59
+ }
60
+ function findChild(node, type) {
61
+ for (const c of namedChildren(node))
62
+ if (c.type === type)
63
+ return c;
64
+ return null;
65
+ }
66
+ function handle(node) {
67
+ switch (node.type) {
68
+ case "class_declaration":
69
+ case "trait_declaration":
70
+ return classLike(node, "class");
71
+ case "interface_declaration":
72
+ return classLike(node, "interface");
73
+ case "enum_declaration": {
74
+ const body = findChild(node, "enum_declaration_list");
75
+ return makeSymbol({
76
+ name: nameOf(node) ?? "(enum)",
77
+ kind: "enum",
78
+ node,
79
+ rawKind: node.type,
80
+ signature: headerSignature(node, body),
81
+ doc: leadingComment(node),
82
+ children: body
83
+ ? namedChildren(body)
84
+ .filter((c) => c.type === "enum_case")
85
+ .map((c) => makeSymbol({ name: nameOf(c) ?? c.text, kind: "const", node: c, rawKind: c.type }))
86
+ : [],
87
+ });
88
+ }
89
+ case "function_definition": {
90
+ const body = node.childForFieldName("body") ?? findChild(node, "compound_statement");
91
+ return makeSymbol({
92
+ name: nameOf(node) ?? "(function)",
93
+ kind: "function",
94
+ node,
95
+ rawKind: node.type,
96
+ signature: headerSignature(node, body),
97
+ doc: leadingComment(node),
98
+ });
99
+ }
100
+ case "method_declaration": {
101
+ const body = node.childForFieldName("body") ?? findChild(node, "compound_statement");
102
+ const vis = phpVisibility(node);
103
+ return makeSymbol({
104
+ name: nameOf(node) ?? "(method)",
105
+ kind: "method",
106
+ node,
107
+ rawKind: node.type,
108
+ signature: headerSignature(node, body),
109
+ visibility: vis,
110
+ exported: vis === "public",
111
+ doc: leadingComment(node),
112
+ });
113
+ }
114
+ case "const_declaration": {
115
+ const el = findChild(node, "const_element");
116
+ const name = el ? namedChildren(el)[0]?.text : null;
117
+ if (!name)
118
+ return null;
119
+ const vis = phpVisibility(node);
120
+ return makeSymbol({
121
+ name,
122
+ kind: "const",
123
+ node,
124
+ rawKind: node.type,
125
+ visibility: vis,
126
+ exported: vis === "public",
127
+ });
128
+ }
129
+ case "property_declaration": {
130
+ const decl = findChild(node, "property_element");
131
+ const name = decl?.text.replace(/\s*=.*$/, "").trim();
132
+ if (!name)
133
+ return null;
134
+ const vis = phpVisibility(node);
135
+ return makeSymbol({
136
+ name,
137
+ kind: "field",
138
+ node,
139
+ rawKind: node.type,
140
+ visibility: vis,
141
+ exported: vis === "public",
142
+ });
143
+ }
144
+ default:
145
+ return null;
146
+ }
147
+ }
148
+ // ─── Import extraction ────────────────────────────────────────────────────────
149
+ // `use App\Models\User;`, grouped `use App\{A, B};`, and require/include calls.
150
+ export function extractImportsPhp(root, _source) {
151
+ const imports = [];
152
+ walk(root, imports, 0);
153
+ return imports;
154
+ }
155
+ function walk(node, out, depth) {
156
+ if (depth > 4)
157
+ return;
158
+ for (const c of namedChildren(node)) {
159
+ if (c.type === "namespace_use_declaration")
160
+ parseUse(c, out);
161
+ else if (c.type === "require_expression" ||
162
+ c.type === "require_once_expression" ||
163
+ c.type === "include_expression" ||
164
+ c.type === "include_once_expression") {
165
+ const str = findString(c);
166
+ if (str)
167
+ out.push({ symbol: "*", from: str, isSideEffect: true });
168
+ }
169
+ else {
170
+ walk(c, out, depth + 1);
171
+ }
172
+ }
173
+ }
174
+ function findString(node) {
175
+ for (const c of namedChildren(node)) {
176
+ if (c.type === "string")
177
+ return c.text.replace(/^['"]|['"]$/g, "");
178
+ const deep = findString(c);
179
+ if (deep)
180
+ return deep;
181
+ }
182
+ return null;
183
+ }
184
+ function parseUse(node, out) {
185
+ let groupBase = null;
186
+ for (const c of namedChildren(node)) {
187
+ if (c.type === "namespace_name")
188
+ groupBase = c.text.replace(/\s+/g, "");
189
+ else if (c.type === "namespace_use_clause") {
190
+ const qn = c.text.replace(/\s+as\s+.*$/, "").replace(/\s+/g, "");
191
+ const alias = /\s+as\s+(\w+)/.exec(c.text)?.[1];
192
+ const leaf = qn.split("\\").pop() ?? qn;
193
+ const imp = { symbol: leaf, from: qn };
194
+ if (alias)
195
+ imp.alias = alias;
196
+ out.push(imp);
197
+ }
198
+ else if (c.type === "namespace_use_group") {
199
+ for (const g of namedChildren(c)) {
200
+ if (g.type !== "namespace_use_group_clause")
201
+ continue;
202
+ const txt = g.text.replace(/\s+/g, "");
203
+ const leaf = txt.split("\\").pop() ?? txt;
204
+ out.push({ symbol: leaf, from: groupBase ? `${groupBase}\\${txt}` : txt });
205
+ }
206
+ }
207
+ }
208
+ }
@@ -0,0 +1,146 @@
1
+ import { namedChildren, nameOf, headerSignature, leadingComment } from "../parser.js";
2
+ import { makeSymbol } from "./common.js";
3
+ // ─── Ruby extractor (tree-sitter-ruby) ────────────────────────────────────────
4
+ // Tracks `private` / `protected` visibility sections inside class bodies.
5
+ export function extractRuby(root, _source) {
6
+ return collect(namedChildren(root), false);
7
+ }
8
+ function collect(nodes, insideClass) {
9
+ const out = [];
10
+ let visibility = "public";
11
+ for (const n of nodes) {
12
+ // Bare `private` / `protected` / `public` switches the section.
13
+ if (n.type === "identifier") {
14
+ if (n.text === "private" || n.text === "protected")
15
+ visibility = "private";
16
+ else if (n.text === "public")
17
+ visibility = "public";
18
+ continue;
19
+ }
20
+ const sym = handle(n, insideClass, visibility);
21
+ if (sym)
22
+ out.push(sym);
23
+ }
24
+ return out;
25
+ }
26
+ function bodyOf(node) {
27
+ const byField = node.childForFieldName("body");
28
+ if (byField)
29
+ return byField;
30
+ for (const c of namedChildren(node)) {
31
+ if (c.type === "body_statement")
32
+ return c;
33
+ }
34
+ return null;
35
+ }
36
+ function handle(node, insideClass, visibility) {
37
+ switch (node.type) {
38
+ case "class": {
39
+ const body = bodyOf(node);
40
+ return makeSymbol({
41
+ name: nameOf(node) ?? "(class)",
42
+ kind: "class",
43
+ node,
44
+ rawKind: node.type,
45
+ signature: headerSignature(node, body),
46
+ doc: leadingComment(node),
47
+ children: body ? collect(namedChildren(body), true) : [],
48
+ });
49
+ }
50
+ case "module": {
51
+ const body = bodyOf(node);
52
+ return makeSymbol({
53
+ name: nameOf(node) ?? "(module)",
54
+ kind: "namespace",
55
+ node,
56
+ rawKind: node.type,
57
+ signature: headerSignature(node, body),
58
+ doc: leadingComment(node),
59
+ children: body ? collect(namedChildren(body), true) : [],
60
+ });
61
+ }
62
+ case "method": {
63
+ const body = bodyOf(node);
64
+ const name = nameOf(node) ?? "(method)";
65
+ return makeSymbol({
66
+ name,
67
+ kind: insideClass ? "method" : "function",
68
+ node,
69
+ rawKind: node.type,
70
+ signature: headerSignature(node, body),
71
+ visibility,
72
+ exported: visibility === "public",
73
+ doc: leadingComment(node),
74
+ });
75
+ }
76
+ case "singleton_method": {
77
+ const body = bodyOf(node);
78
+ const name = nameOf(node) ?? "(method)";
79
+ return makeSymbol({
80
+ name: `self.${name}`,
81
+ kind: insideClass ? "method" : "function",
82
+ node,
83
+ rawKind: node.type,
84
+ signature: headerSignature(node, body),
85
+ visibility,
86
+ exported: visibility === "public",
87
+ doc: leadingComment(node),
88
+ });
89
+ }
90
+ case "assignment": {
91
+ // Top-level / class-level CONSTANT = ...
92
+ const left = namedChildren(node)[0];
93
+ if (left?.type === "constant") {
94
+ return makeSymbol({
95
+ name: left.text,
96
+ kind: "const",
97
+ node,
98
+ rawKind: node.type,
99
+ });
100
+ }
101
+ return null;
102
+ }
103
+ default:
104
+ return null;
105
+ }
106
+ }
107
+ // ─── Import extraction ────────────────────────────────────────────────────────
108
+ // `require 'x'` (external) and `require_relative './x'` (relative).
109
+ export function extractImportsRuby(root, _source) {
110
+ const imports = [];
111
+ for (const n of namedChildren(root))
112
+ collectRequire(n, imports, 0);
113
+ return imports;
114
+ }
115
+ function collectRequire(node, out, depth) {
116
+ if (depth > 3)
117
+ return;
118
+ if (node.type === "call") {
119
+ const fn = namedChildren(node)[0];
120
+ if (fn?.type === "identifier" && (fn.text === "require" || fn.text === "require_relative")) {
121
+ const args = node.childForFieldName("arguments") ?? findArgs(node);
122
+ const str = args ? firstString(args) : null;
123
+ if (str) {
124
+ const from = fn.text === "require_relative" && !str.startsWith(".") ? `./${str}` : str;
125
+ out.push({ symbol: "*", from, isSideEffect: true });
126
+ }
127
+ return;
128
+ }
129
+ }
130
+ for (const c of namedChildren(node))
131
+ collectRequire(c, out, depth + 1);
132
+ }
133
+ function findArgs(node) {
134
+ for (const c of namedChildren(node))
135
+ if (c.type === "argument_list")
136
+ return c;
137
+ return null;
138
+ }
139
+ function firstString(args) {
140
+ for (const c of namedChildren(args)) {
141
+ if (c.type === "string") {
142
+ return c.text.replace(/^['"]|['"]$/g, "");
143
+ }
144
+ }
145
+ return null;
146
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  import { z } from "zod";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { resolveOptions, loadProjectConfig } from "./config.js";
9
+ import { initDiskCache, defaultCacheDir } from "./diskcache.js";
10
+ import { buildSkeletonsBulk } from "./pool.js";
9
11
  import { buildSkeleton, collectSourceFiles, UnsupportedLanguageError, } from "./skeleton.js";
10
12
  import { renderHtml, renderCombinedHtml } from "./html.js";
11
13
  import { supportedLanguages } from "./registry.js";
@@ -21,6 +23,7 @@ import { traceTypeInFile } from "./typeflow.js";
21
23
  import { discoverWorkspace, findPackageCycles } from "./workspace.js";
22
24
  import { readSourceMap } from "./sourcemap.js";
23
25
  import { buildReport } from "./report.js";
26
+ import { runQualityGate } from "./check.js";
24
27
  import { computeDiff, computeRisk, isGitRepo } from "./gitdiff.js";
25
28
  import { packContext } from "./contextpack.js";
26
29
  import { computeCoupling } from "./coupling.js";
@@ -29,6 +32,10 @@ import { computeModuleCoupling } from "./modulecoupling.js";
29
32
  import { registerPrompts } from "./prompts.js";
30
33
  /** Files may only be read inside this root (override with AST_MAP_ROOT). */
31
34
  const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
35
+ // Persistent parse cache (disable with AST_MAP_NO_CACHE=1 or "cache": false in config).
36
+ if (process.env.AST_MAP_NO_CACHE !== "1" && loadProjectConfig(ROOT).cache !== false) {
37
+ initDiskCache(defaultCacheDir(ROOT));
38
+ }
32
39
  function resolveInRoot(input) {
33
40
  const abs = path.resolve(ROOT, input);
34
41
  const rel = path.relative(ROOT, abs);
@@ -135,12 +142,17 @@ server.registerTool("generate_skeleton", {
135
142
  const results = [];
136
143
  const successSkeletons = [];
137
144
  let totalSymbols = 0;
138
- for (const file of files) {
139
- const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
140
- try {
141
- const skel = await buildSkeleton(file, fileRel, opts);
145
+ const items = files.map((file) => ({
146
+ abs: file,
147
+ rel: path.relative(ROOT, file).split(path.sep).join("/"),
148
+ }));
149
+ const built = await buildSkeletonsBulk(items, opts);
150
+ for (let i = 0; i < built.length; i++) {
151
+ const r = built[i];
152
+ if (r) {
153
+ const skel = r.skel;
142
154
  totalSymbols += skel.symbolCount;
143
- const htmlPath = opts.emitHtml ? writeHtml(skel, fileRel, opts) : null;
155
+ const htmlPath = opts.emitHtml ? writeHtml(skel, items[i].rel, opts) : null;
144
156
  successSkeletons.push(skel);
145
157
  results.push({
146
158
  file: skel.file,
@@ -149,8 +161,8 @@ server.registerTool("generate_skeleton", {
149
161
  htmlPath,
150
162
  });
151
163
  }
152
- catch (err) {
153
- results.push({ file: fileRel.split(path.sep).join("/"), error: describeError(err) });
164
+ else {
165
+ results.push({ file: items[i].rel, error: "parse failed or unsupported file type" });
154
166
  }
155
167
  }
156
168
  let combinedHtmlPath = null;
@@ -797,6 +809,37 @@ server.registerTool("get_codebase_report", {
797
809
  return errorText(describeError(err));
798
810
  }
799
811
  });
812
+ /* ─────────────────── tool: check_quality_gate ──────────────────────────── */
813
+ server.registerTool("check_quality_gate", {
814
+ title: "Quality gate (thresholds + baseline ratchet)",
815
+ description: "Run the CI quality gate over a directory: evaluates absolute thresholds (from " +
816
+ "`.ast-map.config.json` \u2192 `check`) and a **baseline ratchet** against " +
817
+ "`.ast-map.baseline.json` \u2014 fails when cycles, dead exports, SDP violations, " +
818
+ "very-high-complexity functions, or the health score regress. " +
819
+ "Set updateBaseline to re-anchor the baseline at the current metrics.",
820
+ inputSchema: {
821
+ path: z.string().optional().describe("Directory to gate. Defaults to the project root."),
822
+ baseline: z.string().optional().describe("Baseline file path. Default .ast-map.baseline.json."),
823
+ updateBaseline: z.boolean().optional().describe("Write current metrics as the new baseline."),
824
+ },
825
+ }, async ({ path: input, baseline, updateBaseline }) => {
826
+ try {
827
+ const { abs, rel } = resolveInRoot(input ?? ".");
828
+ if (!fs.statSync(abs).isDirectory()) {
829
+ return errorText(`"${input}" is not a directory. check_quality_gate requires a directory.`);
830
+ }
831
+ const thresholds = loadProjectConfig(ROOT).check;
832
+ const result = await runQualityGate(abs, ROOT, {
833
+ baselinePath: baseline,
834
+ thresholds,
835
+ updateBaseline,
836
+ });
837
+ return jsonText({ directory: rel.split(path.sep).join("/") || ".", ...result });
838
+ }
839
+ catch (err) {
840
+ return errorText(describeError(err));
841
+ }
842
+ });
800
843
  /* ─────────────────── tool: get_diff ────────────────────────────────────── */
801
844
  server.registerTool("get_diff", {
802
845
  title: "Git-aware change diff + blast radius",
package/dist/pool.js ADDED
@@ -0,0 +1,114 @@
1
+ import os from "node:os";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Worker } from "node:worker_threads";
6
+ import { buildSkeleton } from "./skeleton.js";
7
+ import { computeFileComplexity } from "./complexity.js";
8
+ import { diskCacheDir } from "./diskcache.js";
9
+ /** Batches smaller than this are parsed sequentially (worker startup costs more). */
10
+ const MIN_BATCH = 64;
11
+ const MAX_WORKERS = 8;
12
+ function envWorkers() {
13
+ const env = process.env.AST_MAP_WORKERS;
14
+ if (env === undefined || env === "")
15
+ return null;
16
+ const v = Number.parseInt(env, 10);
17
+ return Number.isNaN(v) ? null : Math.max(0, Math.min(v, MAX_WORKERS));
18
+ }
19
+ function plannedWorkers(n) {
20
+ const forced = envWorkers();
21
+ if (forced !== null)
22
+ return forced;
23
+ const cpus = os.cpus().length;
24
+ return Math.min(Math.max(cpus - 1, 0), MAX_WORKERS, Math.ceil(n / MIN_BATCH));
25
+ }
26
+ async function buildSequential(items, opts, withComplexity, out) {
27
+ for (let i = 0; i < items.length; i++) {
28
+ if (out[i] !== undefined)
29
+ continue; // already produced by a worker
30
+ try {
31
+ const skel = await buildSkeleton(items[i].abs, items[i].rel, opts);
32
+ const complexity = withComplexity
33
+ ? await computeFileComplexity(items[i].abs, items[i].rel)
34
+ : undefined;
35
+ out[i] = { skel, complexity };
36
+ }
37
+ catch {
38
+ out[i] = null; // unparsable / unsupported — callers skip nulls
39
+ }
40
+ }
41
+ }
42
+ /**
43
+ * Build skeletons for many files, in parallel when it pays off.
44
+ * Returns one entry per input item (null = failed/unsupported file).
45
+ */
46
+ export async function buildSkeletonsBulk(items, opts, withComplexity = false) {
47
+ const out = new Array(items.length);
48
+ const workers = plannedWorkers(items.length);
49
+ const workerFile = path.join(path.dirname(fileURLToPath(import.meta.url)), "worker.js");
50
+ // An explicit AST_MAP_WORKERS >= 2 bypasses the batch-size gate.
51
+ const smallBatch = items.length < MIN_BATCH && envWorkers() === null;
52
+ if (smallBatch || workers <= 1 || !fs.existsSync(workerFile)) {
53
+ await buildSequential(items, opts, withComplexity, out);
54
+ return out;
55
+ }
56
+ let failed = false;
57
+ await new Promise((resolve) => {
58
+ let next = 0;
59
+ let done = 0;
60
+ let open = 0;
61
+ const pool = [];
62
+ const finish = () => {
63
+ for (const w of pool)
64
+ void w.terminate();
65
+ resolve();
66
+ };
67
+ const dispatch = (w) => {
68
+ if (failed || next >= items.length) {
69
+ return;
70
+ }
71
+ const id = next++;
72
+ w.postMessage({ id, abs: items[id].abs, rel: items[id].rel, opts, withComplexity });
73
+ };
74
+ for (let i = 0; i < workers; i++) {
75
+ let w;
76
+ try {
77
+ w = new Worker(workerFile, { workerData: { cacheDir: diskCacheDir() } });
78
+ }
79
+ catch {
80
+ failed = true;
81
+ break;
82
+ }
83
+ open++;
84
+ pool.push(w);
85
+ w.on("message", (msg) => {
86
+ out[msg.id] = msg.ok && msg.skel ? { skel: msg.skel, complexity: msg.complexity } : null;
87
+ done++;
88
+ if (done >= items.length || failed)
89
+ finish();
90
+ else
91
+ dispatch(w);
92
+ });
93
+ w.on("error", () => {
94
+ failed = true;
95
+ finish();
96
+ });
97
+ dispatch(w);
98
+ // prime a second task per worker to hide round-trip latency
99
+ dispatch(w);
100
+ }
101
+ if (pool.length === 0)
102
+ resolve();
103
+ else if (open === 0)
104
+ resolve();
105
+ });
106
+ // Fill any gaps (worker failure, early termination) sequentially.
107
+ for (let i = 0; i < items.length; i++) {
108
+ if (out[i] === undefined) {
109
+ await buildSequential(items, opts, withComplexity, out);
110
+ break;
111
+ }
112
+ }
113
+ return out;
114
+ }
package/dist/registry.js CHANGED
@@ -9,6 +9,8 @@ import { extractC, extractImportsC } from "./extractors/c.js";
9
9
  import { extractCpp, extractImportsCpp } from "./extractors/cpp.js";
10
10
  import { extractKotlin, extractDirectivesKotlin, extractImportsKotlin } from "./extractors/kotlin.js";
11
11
  import { extractSwift, extractImportsSwift } from "./extractors/swift.js";
12
+ import { extractPhp, extractImportsPhp } from "./extractors/php.js";
13
+ import { extractRuby, extractImportsRuby } from "./extractors/ruby.js";
12
14
  const TS_ENTRY = (language, grammar) => ({
13
15
  language,
14
16
  grammar,
@@ -62,6 +64,9 @@ const BY_EXT = {
62
64
  extract: extractKotlin, extractDirectives: extractDirectivesKotlin, extractImports: extractImportsKotlin,
63
65
  },
64
66
  ".swift": { language: "swift", grammar: "swift", extract: extractSwift, extractImports: extractImportsSwift },
67
+ ".php": { language: "php", grammar: "php", extract: extractPhp, extractImports: extractImportsPhp },
68
+ ".rb": { language: "ruby", grammar: "ruby", extract: extractRuby, extractImports: extractImportsRuby },
69
+ ".rake": { language: "ruby", grammar: "ruby", extract: extractRuby, extractImports: extractImportsRuby },
65
70
  ".vue": SFC_ENTRY("vue"),
66
71
  ".svelte": SFC_ENTRY("svelte"),
67
72
  };
package/dist/report.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import path from "node:path";
2
- import { collectSourceFiles, buildSkeleton } from "./skeleton.js";
2
+ import { collectSourceFiles } from "./skeleton.js";
3
+ import { buildSkeletonsBulk } from "./pool.js";
3
4
  import { resolveOptions } from "./config.js";
4
5
  import { buildSymbolGraph } from "./graph.js";
5
6
  import { findDeadExports, findCircularDeps, getTopSymbols } from "./graph-analysis.js";
6
- import { computeFileComplexity } from "./complexity.js";
7
7
  import { findLayerViolations } from "./layers.js";
8
8
  import { computeModuleCoupling } from "./modulecoupling.js";
9
9
  function gradeFor(score) {
@@ -25,24 +25,27 @@ export async function buildReport(absDir, root) {
25
25
  let symbolCount = 0;
26
26
  const hotspots = [];
27
27
  let cxSum = 0, cxN = 0, cxMax = 0;
28
- for (const file of files) {
29
- const rel = path.relative(root, file).split(path.sep).join("/");
30
- try {
31
- const skel = await buildSkeleton(file, rel, opts);
32
- skeletons.push(skel);
33
- symbolCount += skel.symbolCount;
34
- langCount.set(skel.language, (langCount.get(skel.language) ?? 0) + 1);
35
- const fc = await computeFileComplexity(file, rel);
36
- if (fc) {
37
- for (const f of fc.functions) {
38
- hotspots.push({ ...f, file: rel });
39
- cxSum += f.complexity;
40
- cxN++;
41
- cxMax = Math.max(cxMax, f.complexity);
42
- }
28
+ const items = files.map((file) => ({
29
+ abs: file,
30
+ rel: path.relative(root, file).split(path.sep).join("/"),
31
+ }));
32
+ const built = await buildSkeletonsBulk(items, opts, true);
33
+ for (let i = 0; i < built.length; i++) {
34
+ const r = built[i];
35
+ if (!r)
36
+ continue; // skip unparsable
37
+ const rel = items[i].rel;
38
+ skeletons.push(r.skel);
39
+ symbolCount += r.skel.symbolCount;
40
+ langCount.set(r.skel.language, (langCount.get(r.skel.language) ?? 0) + 1);
41
+ if (r.complexity) {
42
+ for (const f of r.complexity.functions) {
43
+ hotspots.push({ ...f, file: rel });
44
+ cxSum += f.complexity;
45
+ cxN++;
46
+ cxMax = Math.max(cxMax, f.complexity);
43
47
  }
44
48
  }
45
- catch { /* skip unparsable */ }
46
49
  }
47
50
  const graph = buildSymbolGraph(skeletons, root);
48
51
  const dead = findDeadExports(graph).filter((d) => d.confidence === "high");
package/dist/skeleton.js CHANGED
@@ -4,6 +4,7 @@ import { detectLanguage, supportedExtensions } from "./registry.js";
4
4
  import { parseSource } from "./parser.js";
5
5
  import { countSymbols, toOutline } from "./extractors/common.js";
6
6
  import { extractScript } from "./sfc.js";
7
+ import { diskCacheKey, diskCacheGet, diskCachePut } from "./diskcache.js";
7
8
  export const SCHEMA_VERSION = "1.1";
8
9
  export const GRAMMAR_SOURCE = "tree-sitter-wasms@0.1.13";
9
10
  const parseCache = new Map();
@@ -58,7 +59,19 @@ export async function buildSkeleton(absPath, relPath, opts) {
58
59
  const wantFile = relPath.split(path.sep).join("/");
59
60
  return cached.file === wantFile ? cached : { ...cached, file: wantFile };
60
61
  }
61
- let source = fs.readFileSync(absPath, "utf8");
62
+ const rawSource = fs.readFileSync(absPath, "utf8");
63
+ // Persistent cache (content-hash keyed, see diskcache.ts). A hit skips
64
+ // parsing entirely; entries can never be stale because the key embeds the
65
+ // file's bytes + detail + schema/grammar versions.
66
+ const dKey = diskCacheKey(rawSource, opts.detail, SCHEMA_VERSION, GRAMMAR_SOURCE);
67
+ const fromDisk = diskCacheGet(dKey);
68
+ if (fromDisk) {
69
+ const wantFile = relPath.split(path.sep).join("/");
70
+ const hit = fromDisk.file === wantFile ? fromDisk : { ...fromDisk, file: wantFile };
71
+ setCached(absPath, opts.detail, hit);
72
+ return hit;
73
+ }
74
+ let source = rawSource;
62
75
  let grammar = entry.grammar;
63
76
  if (entry.sfc) {
64
77
  const script = extractScript(source);
@@ -83,6 +96,7 @@ export async function buildSkeleton(absPath, relPath, opts) {
83
96
  symbols,
84
97
  };
85
98
  setCached(absPath, opts.detail, result);
99
+ diskCachePut(dKey, result);
86
100
  return result;
87
101
  }
88
102
  /** Recursively collect supported source files under a directory. */
package/dist/worker.js ADDED
@@ -0,0 +1,27 @@
1
+ // Worker-thread entry: builds skeletons (and optionally per-file complexity)
2
+ // off the main thread. Spawned by pool.ts with { cacheDir } as workerData.
3
+ import { parentPort, workerData } from "node:worker_threads";
4
+ import { buildSkeleton } from "./skeleton.js";
5
+ import { computeFileComplexity } from "./complexity.js";
6
+ import { initDiskCache } from "./diskcache.js";
7
+ const data = (workerData ?? {});
8
+ if (data.cacheDir)
9
+ initDiskCache(data.cacheDir);
10
+ parentPort.on("message", (msg) => {
11
+ void (async () => {
12
+ try {
13
+ const skel = await buildSkeleton(msg.abs, msg.rel, msg.opts);
14
+ const complexity = msg.withComplexity
15
+ ? await computeFileComplexity(msg.abs, msg.rel)
16
+ : undefined;
17
+ parentPort.postMessage({ id: msg.id, ok: true, skel, complexity });
18
+ }
19
+ catch (e) {
20
+ parentPort.postMessage({
21
+ id: msg.id,
22
+ ok: false,
23
+ error: e instanceof Error ? e.message : String(e),
24
+ });
25
+ }
26
+ })();
27
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-ast-mapper",
3
- "version": "1.19.0",
3
+ "version": "1.22.1",
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",
@@ -19,7 +19,7 @@
19
19
  "build": "tsc",
20
20
  "start": "node dist/index.js",
21
21
  "smoke": "node test/smoke.mjs",
22
- "test": "node test/smoke.mjs && node test/analysis.mjs",
22
+ "test": "node test/smoke.mjs && node test/analysis.mjs && node test/cache-smoke.mjs && node test/check-smoke.mjs",
23
23
  "postinstall": "node scripts/install-skill.mjs"
24
24
  },
25
25
  "engines": {
@@ -37,7 +37,7 @@
37
37
  "@modelcontextprotocol/sdk": "^1.29.0",
38
38
  "commander": "^14.0.3",
39
39
  "tree-sitter-wasms": "0.1.13",
40
- "web-tree-sitter": "0.20.8",
40
+ "web-tree-sitter": "0.21.0",
41
41
  "zod": "^3.23.8"
42
42
  },
43
43
  "devDependencies": {