universal-ast-mapper 1.19.0 → 1.22.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 +34 -0
- package/README.md +5 -2
- package/dist/check.js +112 -0
- package/dist/cli.js +97 -9
- package/dist/diskcache.js +97 -0
- package/dist/extractors/php.js +208 -0
- package/dist/extractors/ruby.js +146 -0
- package/dist/index.js +50 -7
- package/dist/pool.js +114 -0
- package/dist/registry.js +5 -0
- package/dist/report.js +21 -18
- package/dist/skeleton.js +15 -1
- package/dist/worker.js +27 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,40 @@ 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.0] — 2026-06-10 · PHP & Ruby support
|
|
10
|
+
- **PHP** (`.php`): classes/interfaces/traits/enums, methods with visibility modifiers,
|
|
11
|
+
class consts + properties, namespaces; imports from `use` (incl. grouped `use A\{B, C}`
|
|
12
|
+
and aliases) and `require`/`include` (side-effect).
|
|
13
|
+
- **Ruby** (`.rb`, `.rake`): classes, modules (→ namespace), methods, `self.` singleton
|
|
14
|
+
methods, constants; **section-style visibility** (`private`/`protected`/`public`);
|
|
15
|
+
imports from `require` / `require_relative`.
|
|
16
|
+
- **web-tree-sitter 0.20.8 → 0.21.0** — unblocks the Ruby grammar (external-scanner
|
|
17
|
+
crash on the old runtime); no API change, all grammars + suites re-verified.
|
|
18
|
+
- Tests: `Sample.php` + `sample.rb` fixtures, 30 new smoke assertions. **16 languages.**
|
|
19
|
+
|
|
20
|
+
## [1.21.0] — 2026-06-10 · Quality gate (`ast-map check`)
|
|
21
|
+
- **`ast-map check [dir]`** — CI quality gate with two mechanisms: a **baseline ratchet**
|
|
22
|
+
(vs a committed `.ast-map.baseline.json`; fails when cycles, dead exports, SDP violations,
|
|
23
|
+
very-high-complexity functions rise or the health score drops; `--update-baseline`
|
|
24
|
+
re-anchors) and **absolute thresholds** (CLI flags or `.ast-map.config.json` → `"check"`).
|
|
25
|
+
Non-zero exit on failure; `--json` for tooling.
|
|
26
|
+
- New MCP tool **`check_quality_gate`** (28 tools) — same gate for agents.
|
|
27
|
+
- **GitHub Action**: `mode: validate | check | both` + `check-args` inputs.
|
|
28
|
+
- New module `check` (`runQualityGate`, `metricsFromReport`); `AstMapConfig.check`.
|
|
29
|
+
- Tests: new `test/check-smoke.mjs` (13 checks), wired into `npm test`.
|
|
30
|
+
|
|
31
|
+
## [1.20.0] — 2026-06-10 · Incremental cache + parallel parsing
|
|
32
|
+
- **Persistent parse cache**: skeletons are cached on disk under `<root>/.ast-map/cache`,
|
|
33
|
+
keyed by content hash + detail + schema/grammar versions — never stale by construction,
|
|
34
|
+
survives across processes (warm hits on large files ~60× faster than a re-parse).
|
|
35
|
+
On by default; disable with `AST_MAP_NO_CACHE=1` or `"cache": false` in config.
|
|
36
|
+
- **Parallel parsing**: bulk scans distribute work over a worker-thread pool
|
|
37
|
+
(auto-sized, engages at ≥ 64 files, `AST_MAP_WORKERS` override, sequential fallback
|
|
38
|
+
on any worker failure). `report` computes per-file complexity in the workers too.
|
|
39
|
+
- New CLI command `ast-map cache [stats|clear]`.
|
|
40
|
+
- New modules `diskcache` and `pool` (`buildSkeletonsBulk`); `AstMapConfig.cache`.
|
|
41
|
+
- Tests: new `test/cache-smoke.mjs` (18 checks), wired into `npm test`.
|
|
42
|
+
|
|
9
43
|
## [1.19.0] — 2026-06-09 · Dashboard: coupling + SDP
|
|
10
44
|
- The health dashboard (`ast-map report` / `get_codebase_report`) now surfaces the
|
|
11
45
|
v1.14–1.16 architecture metrics: a **Module coupling** card (per-directory instability
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Built on [tree-sitter](https://tree-sitter.github.io/) WASM grammars. Zero regex
|
|
|
6
6
|
|
|
7
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`).
|
|
8
8
|
|
|
9
|
-
**Supported languages:** TypeScript · TSX · JavaScript (ESM/CJS) · Python · Go · Rust · Java · C# · C · C++ · Kotlin · Swift ·
|
|
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
11
|
| Capability | TS/JS | Python | Go | Rust | Java | C# | C | C++ | Kt | Swift |
|
|
12
12
|
|--------------------------|:-----:|:------:|:---:|:----:|:----:|:---:|:---:|:---:|:---:|:-----:|
|
|
@@ -17,7 +17,7 @@ Built on [tree-sitter](https://tree-sitter.github.io/) WASM grammars. Zero regex
|
|
|
17
17
|
| Call graph callee origin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — |
|
|
18
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
|
|
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 was unblocked in v1.22.0 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.
|
|
@@ -740,6 +740,9 @@ Not part of the public API: the internal `src/` module layout and the generated
|
|
|
740
740
|
|
|
741
741
|
| Version | What changed |
|
|
742
742
|
|---------|--------------|
|
|
743
|
+
| **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**. |
|
|
744
|
+
| **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`. |
|
|
745
|
+
| **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
746
|
| **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
747
|
| **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
748
|
| **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
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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,
|
|
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
|
-
|
|
153
|
-
results.push({ file:
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.22.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",
|
|
@@ -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.
|
|
40
|
+
"web-tree-sitter": "0.21.0",
|
|
41
41
|
"zod": "^3.23.8"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|