universal-ast-mapper 1.25.0 → 1.27.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 +33 -0
- package/README.md +18 -1
- package/dist/cli.js +42 -0
- package/dist/explorer.js +25 -8
- package/dist/index.js +40 -0
- package/dist/testmap.js +167 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,39 @@ since 1.0.0, guarantees a stable MCP tool / CLI surface across the 1.x line.
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [1.27.0] — 2026-06-11 · Test-coverage mapping
|
|
10
|
+
- **New MCP tool `get_test_coverage`** + **CLI `ast-map tests [dir]`** (alias
|
|
11
|
+
`coverage`) — structural test coverage with zero instrumentation: which source
|
|
12
|
+
files have tests at all, and which have **none**.
|
|
13
|
+
- Two pairing signals:
|
|
14
|
+
- **import** — a test file imports the source file (graph edge; definitive).
|
|
15
|
+
- **name** — conventions: `auth.test.ts` → `auth.ts`, `auth_test.go`,
|
|
16
|
+
`test_utils.py` → `utils.py`, `AuthTest.java` → `Auth.java`,
|
|
17
|
+
`foo-smoke.mjs` → `foo.*`, and bare `test/<name>.*` → `<name>.*`;
|
|
18
|
+
ambiguity resolved by longest shared path prefix.
|
|
19
|
+
- Test files detected by directory (`test/`, `tests/`, `__tests__/`, `spec/`, `e2e/`)
|
|
20
|
+
or basename pattern; **fixtures/mocks/testdata dirs excluded from both sides**.
|
|
21
|
+
- Output: coverage ratio, test→source `links` (with `via`), `tested`,
|
|
22
|
+
**`untested` ranked by risk** (fan-in Ca, then symbol count — load-bearing
|
|
23
|
+
files with no tests first), and `orphanTests` (no source matched; usually
|
|
24
|
+
integration/e2e).
|
|
25
|
+
- CLI: `-u/--untested`, `--links`, `-n/--top`, `--json`.
|
|
26
|
+
- New module `testmap` (`mapTestCoverage`, `isTestFile`, `testNameTarget`,
|
|
27
|
+
`isFixtureFile`) + `test/fixtures/testmap/` fixture tree. Tests: +9 checks
|
|
28
|
+
in `test/analysis.mjs` (153 total). **30 MCP tools / 32 CLI commands.**
|
|
29
|
+
|
|
30
|
+
## [1.26.0] — 2026-06-11 · Coupling overlay in the explorer
|
|
31
|
+
- **`ast-map explore` color modes** — new toolbar dropdown: `color: folder`
|
|
32
|
+
(existing per-directory hues) or **`color: coupling`** — nodes shaded by
|
|
33
|
+
**instability** I = Ce/(Ca+Ce) on a green (0, stable) → yellow → red
|
|
34
|
+
(1, volatile) scale; orphan files stay gray.
|
|
35
|
+
- **Legend** (bottom-left, shown in coupling mode) explains the scale; the hover
|
|
36
|
+
tooltip and the detail sidebar now show **Ca / Ce / I** per file.
|
|
37
|
+
- Explorer nodes carry `ca` / `ce` / `inst` computed from the deduped file-level
|
|
38
|
+
import edges — same definition as `get_coupling` (Robert C. Martin metrics).
|
|
39
|
+
- Still a single self-contained HTML file, dark-mode aware, zero dependencies.
|
|
40
|
+
- Tests: +5 checks in `test/analysis.mjs` (144 total).
|
|
41
|
+
|
|
9
42
|
## [1.25.0] — 2026-06-11 · Semantic symbol search
|
|
10
43
|
- **New MCP tool `semantic_search`** + **CLI `ast-map find <query> [dir]`** — find
|
|
11
44
|
symbols by *meaning*, not exact name: "remove expired cache entries" →
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ 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
|
-
**
|
|
7
|
+
**30 MCP tools / 32 CLI commands / 5 MCP prompts** spanning skeletons, dependency graphs, and deep analysis — dead code, cycles, change-impact, complexity, duplicates, unused params, type-flow, decorators, test-coverage mapping — plus monorepo support, an interactive **graph explorer** with a **coupling overlay** (`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
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
|
|
|
@@ -128,6 +128,7 @@ ast-map cache [stats|clear] # persistent parse cache (.ast-
|
|
|
128
128
|
ast-map check [dir] [--update-baseline] [--min-score N] [--max-cycles N] ...
|
|
129
129
|
ast-map search <pattern> [dir] [-m contains|exact|regex] [-k kind] [-e]
|
|
130
130
|
ast-map find <query> [dir] [-l N] [-k kind] [-e] # semantic: by meaning
|
|
131
|
+
ast-map tests [dir] [alias: coverage] [-u] [--links] [-n N]
|
|
131
132
|
ast-map deps <file> [--scan <dir>]
|
|
132
133
|
ast-map top <dir> [-n 10]
|
|
133
134
|
ast-map impact <file> <symbol> [--scan <dir>]
|
|
@@ -159,6 +160,9 @@ ast-map search handler src/ --exported
|
|
|
159
160
|
# Don't know the name? Search by meaning
|
|
160
161
|
ast-map find "remove expired cache entries" src/
|
|
161
162
|
|
|
163
|
+
# Which source files have no tests at all?
|
|
164
|
+
ast-map tests . --untested
|
|
165
|
+
|
|
162
166
|
# What does this file import / what imports it?
|
|
163
167
|
ast-map deps src/lib/auth.ts --scan src/
|
|
164
168
|
|
|
@@ -533,6 +537,17 @@ semantic_search("find unused exported code") →
|
|
|
533
537
|
|
|
534
538
|
---
|
|
535
539
|
|
|
540
|
+
### `get_test_coverage`
|
|
541
|
+
Structural test coverage: pair test files with the source files they exercise, and list source files **no test touches** — no instrumentation or test runner needed, works on a cold checkout.
|
|
542
|
+
|
|
543
|
+
Two signals: a test file *importing* a source file (definitive), and naming conventions (`auth.test.ts` → `auth.ts`, `test_utils.py` → `utils.py`, `FooTest.java` → `Foo.java`, `test/<name>.*` → `<name>.*`). Fixture/mock directories are excluded from both sides. Untested files are ranked by risk (fan-in, then symbol count); unmatched test files are reported as `orphanTests` (usually integration/e2e).
|
|
544
|
+
|
|
545
|
+
> File-level coverage ("does anything test this file?"), not line coverage.
|
|
546
|
+
|
|
547
|
+
**Params:** `path`, `untestedOnly`
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
536
551
|
### `get_file_deps`
|
|
537
552
|
For a single file, show what it imports and what imports it (with symbol names).
|
|
538
553
|
More focused than `build_symbol_graph` — use for quick dependency lookup.
|
|
@@ -820,6 +835,8 @@ Not part of the public API: the internal `src/` module layout and the generated
|
|
|
820
835
|
|
|
821
836
|
| Version | What changed |
|
|
822
837
|
|---------|--------------|
|
|
838
|
+
| **1.27.0** | **Test-coverage mapping** — new MCP tool `get_test_coverage` + CLI `ast-map tests` (alias `coverage`): pairs test files with the sources they exercise (import edges + naming conventions) and lists **untested sources ranked by risk** (fan-in, then symbols). Fixture dirs excluded; orphan tests reported. File-level, zero instrumentation. (**30 tools / 32 commands**) |
|
|
839
|
+
| **1.26.0** | **Coupling overlay in the explorer** — `ast-map explore` gains a `color: coupling` mode: nodes shaded by **instability** I = Ce/(Ca+Ce) on a green (stable) → red (volatile) scale, with a legend, and Ca / Ce / I readouts in the hover tooltip and detail sidebar. Spot load-bearing files and volatile hotspots at a glance. |
|
|
823
840
|
| **1.25.0** | **Semantic symbol search** — new MCP tool `semantic_search` + CLI `ast-map find <query>`: find symbols by *meaning* ("remove expired sessions" → `clearDiskCache`). Identifier tokenization + 60-group programming thesaurus + stemming + fuzzy matching + BM25-style IDF ranking over names, docs, signatures and paths. No embeddings, no network. (**29 tools / 31 commands**) |
|
|
824
841
|
| **1.24.0** | **TS path-alias resolution** — bare imports like `@/components/Button` now resolve via the **nearest** `tsconfig.json`/`jsconfig.json` (`compilerOptions.paths` + `baseUrl`, relative `extends` chains, longest-prefix matching, string-aware JSONC parser). Wired into `resolve_imports`, the symbol graph, and the call graph — on a real Next.js app this took the import graph from 31 to **324 edges** and cut false dead-exports by ~30%. |
|
|
825
842
|
| **1.23.0** | **Configurable root boundary** — `AST_MAP_ROOT` accepts **multiple roots** (path-delimiter separated) and `AST_MAP_UNLOCKED=1` allows analyzing **any absolute path** on request (default stays locked). Analysis/graph/report rel-paths now computed against the matched root, so cross-root results are correct. New `roots` module + 13-check test suite. |
|
package/dist/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ import { computeModuleCoupling } from "./modulecoupling.js";
|
|
|
28
28
|
import { buildCallGraph } from "./callgraph.js";
|
|
29
29
|
import { searchSymbols } from "./search.js";
|
|
30
30
|
import { semanticSearch } from "./semantic.js";
|
|
31
|
+
import { mapTestCoverage } from "./testmap.js";
|
|
31
32
|
import { parseRootsFromEnv } from "./roots.js";
|
|
32
33
|
const ROOT = parseRootsFromEnv().roots[0]; // CLI is local — no boundary, primary root only
|
|
33
34
|
// Persistent parse cache (disable with AST_MAP_NO_CACHE=1 or "cache": false in config).
|
|
@@ -1130,6 +1131,47 @@ program
|
|
|
1130
1131
|
}
|
|
1131
1132
|
console.log();
|
|
1132
1133
|
});
|
|
1134
|
+
// ─── Command: tests (coverage map) ───────────────────────────────────────────
|
|
1135
|
+
program
|
|
1136
|
+
.command("tests [dir]")
|
|
1137
|
+
.alias("coverage")
|
|
1138
|
+
.description("Map test files to the sources they cover; list untested sources")
|
|
1139
|
+
.option("-u, --untested", "Only show untested source files")
|
|
1140
|
+
.option("--links", "Show every test→source link")
|
|
1141
|
+
.option("-n, --top <n>", "Max untested files to show", (v) => parseInt(v, 10), 25)
|
|
1142
|
+
.option("--json", "Output as JSON")
|
|
1143
|
+
.action(async (dir, opts) => {
|
|
1144
|
+
const { abs, rel } = resolveArg(dir ?? ".");
|
|
1145
|
+
if (!fs.statSync(abs).isDirectory())
|
|
1146
|
+
die(`"${rel}" is not a directory`);
|
|
1147
|
+
const skeletons = await gatherSkeletons(abs);
|
|
1148
|
+
const map = mapTestCoverage(buildSymbolGraph(skeletons, ROOT));
|
|
1149
|
+
if (opts.json)
|
|
1150
|
+
return jsonOut({ directory: rel, ...map });
|
|
1151
|
+
header(`Test Coverage — ${rel}/ ${dim(`(${map.testFiles} test files · ${map.sourceFiles} sources)`)}`);
|
|
1152
|
+
const pct = Math.round(map.coverageRatio * 100);
|
|
1153
|
+
const pcolor = pct >= 70 ? green : pct >= 40 ? yellow : red;
|
|
1154
|
+
console.log(indent(`${bold("Covered:")} ${pcolor(`${map.testedSources}/${map.sourceFiles} (${pct}%)`)} of source files have at least one test`));
|
|
1155
|
+
if (!opts.untested && opts.links && map.links.length > 0) {
|
|
1156
|
+
console.log(`\n${indent(bold("Links:"))}`);
|
|
1157
|
+
table(map.links.map((l) => [l.via === "import" ? green(l.via) : yellow(l.via), l.test, "→ " + l.source]), [["Via", 7], ["Test", 38], ["Source", 40]]);
|
|
1158
|
+
}
|
|
1159
|
+
if (map.untested.length > 0) {
|
|
1160
|
+
console.log(`\n${indent(`${bold("Untested sources")} ${dim("(by risk: fan-in, then symbols)")}`)}`);
|
|
1161
|
+
table(map.untested.slice(0, opts.top).map((u) => [String(u.afferent), String(u.symbols), u.file]), [["Ca", 4], ["Syms", 5], ["File", 52]]);
|
|
1162
|
+
if (map.untested.length > opts.top)
|
|
1163
|
+
console.log(indent(dim(`… ${map.untested.length - opts.top} more (use -n)`)));
|
|
1164
|
+
}
|
|
1165
|
+
else if (map.sourceFiles > 0) {
|
|
1166
|
+
console.log(indent(green("✓ every source file has at least one test")));
|
|
1167
|
+
}
|
|
1168
|
+
if (!opts.untested && map.orphanTests.length > 0) {
|
|
1169
|
+
console.log(`\n${indent(`${bold("Orphan tests")} ${dim("(no source matched — integration/e2e?)")}`)}`);
|
|
1170
|
+
for (const t of map.orphanTests.slice(0, 10))
|
|
1171
|
+
console.log(indent(dim(t), 4));
|
|
1172
|
+
}
|
|
1173
|
+
console.log();
|
|
1174
|
+
});
|
|
1133
1175
|
// ─── Command: deps ────────────────────────────────────────────────────────────
|
|
1134
1176
|
program
|
|
1135
1177
|
.command("deps <file>")
|
package/dist/explorer.js
CHANGED
|
@@ -20,7 +20,7 @@ function deriveFileGraph(graph) {
|
|
|
20
20
|
continue;
|
|
21
21
|
const f = n;
|
|
22
22
|
const parts = f.id.split("/");
|
|
23
|
-
nodes.push({ id: f.id, symbols: f.symbolCount, group: parts.length > 1 ? parts[0] : "(root)", lang: f.language, syms: fileSyms.get(f.id) ?? [] });
|
|
23
|
+
nodes.push({ id: f.id, symbols: f.symbolCount, group: parts.length > 1 ? parts[0] : "(root)", lang: f.language, syms: fileSyms.get(f.id) ?? [], ca: 0, ce: 0, inst: 0 });
|
|
24
24
|
}
|
|
25
25
|
const seen = new Set();
|
|
26
26
|
const links = [];
|
|
@@ -37,6 +37,18 @@ function deriveFileGraph(graph) {
|
|
|
37
37
|
seen.add(key);
|
|
38
38
|
links.push({ source: e.from, target: toFile });
|
|
39
39
|
}
|
|
40
|
+
// Per-file coupling (Ca = fan-in, Ce = fan-out, I = Ce/(Ca+Ce)) from the deduped links.
|
|
41
|
+
const outSet = new Map();
|
|
42
|
+
const inSet = new Map();
|
|
43
|
+
for (const l of links) {
|
|
44
|
+
(outSet.get(l.source) ?? outSet.set(l.source, new Set()).get(l.source)).add(l.target);
|
|
45
|
+
(inSet.get(l.target) ?? inSet.set(l.target, new Set()).get(l.target)).add(l.source);
|
|
46
|
+
}
|
|
47
|
+
for (const n of nodes) {
|
|
48
|
+
n.ce = outSet.get(n.id)?.size ?? 0;
|
|
49
|
+
n.ca = inSet.get(n.id)?.size ?? 0;
|
|
50
|
+
n.inst = n.ca + n.ce === 0 ? 0 : Math.round((n.ce / (n.ca + n.ce)) * 100) / 100;
|
|
51
|
+
}
|
|
40
52
|
return { nodes, links };
|
|
41
53
|
}
|
|
42
54
|
const STYLE = "body{margin:0;font-family:system-ui,sans-serif;color:#222;background:#fafafa}" +
|
|
@@ -51,7 +63,10 @@ const STYLE = "body{margin:0;font-family:system-ui,sans-serif;color:#222;backgro
|
|
|
51
63
|
"#panel .row{padding:3px 6px;border-radius:5px;cursor:pointer;word-break:break-all;line-height:1.5}#panel .row:hover{background:#f0f0f0}" +
|
|
52
64
|
"#panel .sym{color:#444;padding:2px 6px;word-break:break-all}#panel .k{color:#999;font-size:11px}" +
|
|
53
65
|
"#close{position:absolute;top:10px;right:12px;cursor:pointer;color:#999;font-size:18px;line-height:1;border:none;background:none}" +
|
|
54
|
-
"
|
|
66
|
+
"#mode{padding:5px 8px;border:1px solid #ddd;border-radius:6px;font-size:12px;background:#fff;color:#222}" +
|
|
67
|
+
"#leg{position:fixed;left:14px;bottom:14px;z-index:3;background:#fff;border:1px solid #e5e5e5;border-radius:8px;padding:8px 12px;font-size:11px;color:#555;display:none}" +
|
|
68
|
+
"#leg .bar{width:150px;height:8px;border-radius:4px;background:linear-gradient(90deg,hsl(120,65%,46%),hsl(60,75%,50%),hsl(0,70%,52%));margin:5px 0 3px}" +
|
|
69
|
+
"@media(prefers-color-scheme:dark){body{color:#ddd;background:#161616}#bar,#panel{background:#1e1e1e;border-color:#333}#q{background:#2a2a2a;border-color:#444;color:#ddd}#panel .row:hover{background:#2a2a2a}#panel .sym{color:#bbb}#mode{background:#2a2a2a;border-color:#444;color:#ddd}#leg{background:#1e1e1e;border-color:#333;color:#bbb}}";
|
|
55
70
|
const CLIENT = "var c=document.getElementById('cv'),ctx=c.getContext('2d'),tip=document.getElementById('tip'),panel=document.getElementById('panel');" +
|
|
56
71
|
"var PANELW=300,panelOpen=false;" +
|
|
57
72
|
"var W,H;function resize(){var r=devicePixelRatio||1;W=innerWidth||c.clientWidth||800;H=(innerHeight-48)||c.clientHeight||600;c.width=W*r;c.height=H*r;ctx.setTransform(r,0,0,r,0,0);}addEventListener('resize',function(){resize();});resize();" +
|
|
@@ -62,7 +77,8 @@ const CLIENT = "var c=document.getElementById('cv'),ctx=c.getContext('2d'),tip=d
|
|
|
62
77
|
"sim.forEach(function(n){n.x=W/2+(Math.random()-0.5)*240;n.y=H/2+(Math.random()-0.5)*240;});" +
|
|
63
78
|
"var groups={},gi=0;function color(g){if(groups[g]==null)groups[g]=gi++;return 'hsl('+((groups[g]*67)%360)+',58%,55%)';}" +
|
|
64
79
|
"var adj={};links.forEach(function(l){(adj[l.source]=adj[l.source]||[]).push(l.target);(adj[l.target]=adj[l.target]||[]).push(l.source);});" +
|
|
65
|
-
"var view={x:0,y:0,k:1},sel=null,hover=null,drag=null,pan=null,q='',autofit=true;" +
|
|
80
|
+
"var view={x:0,y:0,k:1},sel=null,hover=null,drag=null,pan=null,q='',autofit=true,mode='group';" +
|
|
81
|
+
"function instColor(i){return 'hsl('+Math.round((1-i)*120)+',65%,'+Math.round(46+i*8)+'%)';}" +
|
|
66
82
|
"function radius(n){return 4+Math.sqrt(n.symbols||0)*1.7;}" +
|
|
67
83
|
"function tick(){if(!sim.length)return;var k=0.0016;for(var i=0;i<sim.length;i++){var a=sim[i];a.vx+=(W/2-a.x)*k;a.vy+=(H/2-a.y)*k;for(var j=i+1;j<sim.length;j++){var b=sim[j];var dx=a.x-b.x,dy=a.y-b.y,d2=dx*dx+dy*dy;if(d2<100)d2=100;var d=Math.sqrt(d2),f=2200/d2,fx=f*dx/d,fy=f*dy/d;a.vx+=fx;a.vy+=fy;b.vx-=fx;b.vy-=fy;}}" +
|
|
68
84
|
"links.forEach(function(l){var a=byId[l.source],b=byId[l.target];if(!a||!b)return;var dx=b.x-a.x,dy=b.y-a.y,d=Math.sqrt(dx*dx+dy*dy)+0.01,f=(d-90)*0.02,fx=f*dx/d,fy=f*dy/d;a.vx+=fx;a.vy+=fy;b.vx-=fx;b.vy-=fy;});" +
|
|
@@ -74,12 +90,12 @@ const CLIENT = "var c=document.getElementById('cv'),ctx=c.getContext('2d'),tip=d
|
|
|
74
90
|
"function esc(t){return String(t).replace(/&/g,'&').replace(/</g,'<');}" +
|
|
75
91
|
"function rowList(ids){if(!ids||!ids.length)return '<div class=\"sym\" style=\"color:#aaa\">none</div>';return ids.slice().sort().map(function(id){return '<div class=\"row\" data-id=\"'+esc(id)+'\">'+esc(id)+'</div>';}).join('');}" +
|
|
76
92
|
"function showPanel(n){sel=n;panelOpen=true;var imp=out[n.id]||[],impBy=inn[n.id]||[];var syms=(n.syms||[]).map(function(s){var i=s.indexOf(' ');return '<div class=\"sym\"><span class=\"k\">'+esc(s.slice(0,i))+'</span> '+esc(s.slice(i+1))+'</div>';}).join('')||'<div class=\"sym\" style=\"color:#aaa\">none</div>';" +
|
|
77
|
-
"panel.innerHTML='<button id=\"close\">×</button>'+'<h2>'+esc(n.id.split('/').pop())+'</h2><div class=\"path\">'+esc(n.id)+'</div>'+'<div class=\"meta\">'+esc(n.lang)+' · '+(n.symbols||0)+' symbols'+(deg[n.id]?'':' · no in-scope deps')+'</div>'+'<h3>Imports ('+imp.length+')</h3>'+rowList(imp)+'<h3>Imported by ('+impBy.length+')</h3>'+rowList(impBy)+'<h3>Symbols</h3>'+syms;" +
|
|
93
|
+
"panel.innerHTML='<button id=\"close\">×</button>'+'<h2>'+esc(n.id.split('/').pop())+'</h2><div class=\"path\">'+esc(n.id)+'</div>'+'<div class=\"meta\">'+esc(n.lang)+' · '+(n.symbols||0)+' symbols'+(deg[n.id]?' · Ca '+(n.ca||0)+' · Ce '+(n.ce||0)+' · I '+(n.inst||0):' · no in-scope deps')+'</div>'+'<h3>Imports ('+imp.length+')</h3>'+rowList(imp)+'<h3>Imported by ('+impBy.length+')</h3>'+rowList(impBy)+'<h3>Symbols</h3>'+syms;" +
|
|
78
94
|
"panel.style.display='block';}" +
|
|
79
95
|
"panel.addEventListener('click',function(e){if(e.target.id==='close'){panelOpen=false;sel=null;panel.style.display='none';autofit=true;return;}var id=e.target.getAttribute('data-id');if(id&&byId[id]){showPanel(byId[id]);center(byId[id]);}});" +
|
|
80
96
|
"function draw(){ctx.clearRect(0,0,W,H);ctx.save();ctx.translate(view.x,view.y);ctx.scale(view.k,view.k);ctx.lineWidth=0.8;" +
|
|
81
97
|
"links.forEach(function(l){var a=byId[l.source],b=byId[l.target];if(!a||!b)return;var on=sel&&(l.source===sel.id||l.target===sel.id);ctx.strokeStyle=on?'rgba(110,110,240,0.9)':'rgba(150,150,150,0.18)';ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke();});" +
|
|
82
|
-
"function dot(n,orphan){var dim=(sel&&n!==sel&&(adj[sel.id]||[]).indexOf(n.id)<0)||(q&&n.id.toLowerCase().indexOf(q)<0);ctx.globalAlpha=dim?0.14:(orphan?0.55:1);ctx.beginPath();ctx.arc(n.x,n.y,orphan?3.2:radius(n),0,6.2832);ctx.fillStyle=color(n.group);ctx.fill();if(n===sel||n===hover){ctx.lineWidth=2;ctx.strokeStyle='#fff';ctx.stroke();ctx.lineWidth=0.8;}}" +
|
|
98
|
+
"function dot(n,orphan){var dim=(sel&&n!==sel&&(adj[sel.id]||[]).indexOf(n.id)<0)||(q&&n.id.toLowerCase().indexOf(q)<0);ctx.globalAlpha=dim?0.14:(orphan?0.55:1);ctx.beginPath();ctx.arc(n.x,n.y,orphan?3.2:radius(n),0,6.2832);ctx.fillStyle=mode==='inst'?(deg[n.id]?instColor(n.inst):'#999'):color(n.group);ctx.fill();if(n===sel||n===hover){ctx.lineWidth=2;ctx.strokeStyle='#fff';ctx.stroke();ctx.lineWidth=0.8;}}" +
|
|
83
99
|
"orphans.forEach(function(n){dot(n,true);});sim.forEach(function(n){dot(n,false);});" +
|
|
84
100
|
"ctx.globalAlpha=1;ctx.fillStyle=getComputedStyle(document.body).color;ctx.font='11px system-ui';sim.forEach(function(n){if(n===sel||n===hover||n.symbols>=14){ctx.fillText(n.id.split('/').pop(),n.x+radius(n)+3,n.y+3);}});ctx.restore();}" +
|
|
85
101
|
"function loop(){var w=innerWidth,hh=innerHeight-48;if(w&&hh&&(w!==W||hh!==H))resize();tick();tick();layoutOrphans();if(autofit)fitView();draw();var bx=bb4(nodes);document.getElementById(\"dbg\").textContent=\"W=\"+W+\" H=\"+H+\" iw=\"+innerWidth+\"x\"+innerHeight+\" dpr=\"+(devicePixelRatio||1)+\" k=\"+view.k.toFixed(2)+\" vx=\"+Math.round(view.x)+\" vy=\"+Math.round(view.y)+\" fit=\"+autofit+\" sim=\"+sim.length+\" orph=\"+orphans.length+\" worldBox=\"+Math.round(bx[0])+\",\"+Math.round(bx[1])+\"..\"+Math.round(bx[2])+\",\"+Math.round(bx[3]);requestAnimationFrame(loop);}" +
|
|
@@ -87,10 +103,11 @@ const CLIENT = "var c=document.getElementById('cv'),ctx=c.getContext('2d'),tip=d
|
|
|
87
103
|
"function pick(p){var all=sim.concat(orphans);for(var i=all.length-1;i>=0;i--){var n=all[i];var r=(deg[n.id]?radius(n):3.2)+5;if((p.x-n.x)*(p.x-n.x)+(p.y-n.y)*(p.y-n.y)<=r*r)return n;}return null;}" +
|
|
88
104
|
"c.addEventListener('mousedown',function(e){autofit=false;var n=pick(world(e));if(n){drag=n;showPanel(n);}else{pan={x:e.clientX-view.x,y:e.clientY-view.y};}});" +
|
|
89
105
|
"c.addEventListener('dblclick',function(){panelOpen=false;sel=null;panel.style.display='none';autofit=true;});" +
|
|
90
|
-
"addEventListener('mousemove',function(e){var p=world(e);if(drag){drag.x=p.x;drag.y=p.y;drag.vx=0;drag.vy=0;}else if(pan){view.x=e.clientX-pan.x;view.y=e.clientY-pan.y;}else{hover=pick(p);if(hover){tip.style.display='block';tip.style.left=(e.clientX+12)+'px';tip.style.top=(e.clientY+12)+'px';tip.textContent=hover.id+' · '+(hover.symbols||0)+' symbols · '+hover.lang;}else tip.style.display='none';}});" +
|
|
106
|
+
"addEventListener('mousemove',function(e){var p=world(e);if(drag){drag.x=p.x;drag.y=p.y;drag.vx=0;drag.vy=0;}else if(pan){view.x=e.clientX-pan.x;view.y=e.clientY-pan.y;}else{hover=pick(p);if(hover){tip.style.display='block';tip.style.left=(e.clientX+12)+'px';tip.style.top=(e.clientY+12)+'px';tip.textContent=hover.id+' · '+(hover.symbols||0)+' symbols · '+hover.lang+(deg[hover.id]?' · Ca '+hover.ca+' Ce '+hover.ce+' I '+hover.inst:'');}else tip.style.display='none';}});" +
|
|
91
107
|
"addEventListener('mouseup',function(){drag=null;pan=null;});" +
|
|
92
108
|
"c.addEventListener('wheel',function(e){e.preventDefault();autofit=false;var s=e.deltaY<0?1.1:0.9;var mx=e.clientX,my=e.clientY-48;view.x=mx-(mx-view.x)*s;view.y=my-(my-view.y)*s;view.k*=s;},{passive:false});" +
|
|
93
109
|
"document.getElementById('q').addEventListener('input',function(e){q=e.target.value.toLowerCase();});" +
|
|
110
|
+
"document.getElementById('mode').addEventListener('change',function(e){mode=e.target.value;document.getElementById('leg').style.display=mode==='inst'?'block':'none';});" +
|
|
94
111
|
"addEventListener('keydown',function(e){if(e.key==='d'&&e.target.tagName!=='INPUT'){var x=document.getElementById('dbg');x.style.display=x.style.display==='none'?'block':'none';}});loop();";
|
|
95
112
|
/** Build a self-contained, dependency-free HTML graph explorer. */
|
|
96
113
|
export function buildExplorerHtml(graph, root) {
|
|
@@ -100,7 +117,7 @@ export function buildExplorerHtml(graph, root) {
|
|
|
100
117
|
return ("<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
|
|
101
118
|
"<title>AST-MCP — " + title + " graph</title><style>" + STYLE + "</style></head><body>" +
|
|
102
119
|
"<div id=\"bar\"><h1>AST-MCP graph</h1><span class=\"muted\">" + data.nodes.length + " files · " + data.links.length + " edges · drag / scroll / click</span>" +
|
|
103
|
-
"<input id=\"q\" placeholder=\"filter files…\"
|
|
104
|
-
"<canvas id=\"cv\"></canvas><div id=\"tip\"></div><div id=\"panel\"></div><div id=\"dbg\" style=\"position:fixed;left:8px;bottom:8px;font:11px monospace;color:#e07;z-index:6;pointer-events:none;white-space:pre;display:none\"></div>" +
|
|
120
|
+
"<input id=\"q\" placeholder=\"filter files…\" /><select id=\"mode\"><option value=\"group\">color: folder</option><option value=\"inst\">color: coupling</option></select></div>" +
|
|
121
|
+
"<canvas id=\"cv\"></canvas><div id=\"tip\"></div><div id=\"panel\"></div><div id=\"leg\"><b>Instability I = Ce/(Ca+Ce)</b><div class=\"bar\"></div><div style=\"display:flex;justify-content:space-between\"><span>0 = stable</span><span>1 = volatile</span></div></div><div id=\"dbg\" style=\"position:fixed;left:8px;bottom:8px;font:11px monospace;color:#e07;z-index:6;pointer-events:none;white-space:pre;display:none\"></div>" +
|
|
105
122
|
"<script>var DATA=" + dataJson + ";</script><script>" + CLIENT + "</script></body></html>");
|
|
106
123
|
}
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTop
|
|
|
18
18
|
import { buildCallGraph } from "./callgraph.js";
|
|
19
19
|
import { searchSymbols } from "./search.js";
|
|
20
20
|
import { semanticSearch } from "./semantic.js";
|
|
21
|
+
import { mapTestCoverage } from "./testmap.js";
|
|
21
22
|
import { computeFileComplexity } from "./complexity.js";
|
|
22
23
|
import { findUnusedParams } from "./unused-params.js";
|
|
23
24
|
import { traceTypeInFile } from "./typeflow.js";
|
|
@@ -1191,6 +1192,45 @@ server.registerTool("semantic_search", {
|
|
|
1191
1192
|
return errorText(describeError(err));
|
|
1192
1193
|
}
|
|
1193
1194
|
});
|
|
1195
|
+
/* ─────────────────── tool: get_test_coverage ───────────────────────────── */
|
|
1196
|
+
server.registerTool("get_test_coverage", {
|
|
1197
|
+
title: "Test-coverage map (tests ↔ sources)",
|
|
1198
|
+
description: "Structural test coverage: pair test files with the source files they exercise and list " +
|
|
1199
|
+
"source files no test touches. Two signals: a test file *importing* a source file " +
|
|
1200
|
+
"(definitive) and naming conventions (auth.test.ts → auth.ts, test_utils.py → utils.py). " +
|
|
1201
|
+
"No instrumentation or test runner needed. Untested files are ranked by risk " +
|
|
1202
|
+
"(fan-in, then symbol count). This is file-level coverage, not line coverage.",
|
|
1203
|
+
inputSchema: {
|
|
1204
|
+
path: z.string().optional().describe("Directory to scan (should include the test files). Default project root."),
|
|
1205
|
+
untestedOnly: z.boolean().optional().describe("Return only the untested-sources list. Default false."),
|
|
1206
|
+
},
|
|
1207
|
+
}, async ({ path: input, untestedOnly }) => {
|
|
1208
|
+
try {
|
|
1209
|
+
const { abs, rel, root } = resolveInRoot(input ?? ".");
|
|
1210
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
1211
|
+
return errorText(`"${input}" is not a directory. get_test_coverage requires a directory.`);
|
|
1212
|
+
}
|
|
1213
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
1214
|
+
const files = collectSourceFiles(abs, opts);
|
|
1215
|
+
const skels = [];
|
|
1216
|
+
for (const f of files) {
|
|
1217
|
+
const r = path.relative(root, f).split(path.sep).join("/");
|
|
1218
|
+
try {
|
|
1219
|
+
skels.push(await buildSkeleton(f, r, opts));
|
|
1220
|
+
}
|
|
1221
|
+
catch { /* skip */ }
|
|
1222
|
+
}
|
|
1223
|
+
const map = mapTestCoverage(buildSymbolGraph(skels, root));
|
|
1224
|
+
const dir = rel.split(path.sep).join("/") || ".";
|
|
1225
|
+
if (untestedOnly) {
|
|
1226
|
+
return jsonText({ directory: dir, untestedSources: map.untestedSources, coverageRatio: map.coverageRatio, untested: map.untested });
|
|
1227
|
+
}
|
|
1228
|
+
return jsonText({ directory: dir, ...map });
|
|
1229
|
+
}
|
|
1230
|
+
catch (err) {
|
|
1231
|
+
return errorText(describeError(err));
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1194
1234
|
/* ─────────────────── tool: get_file_deps ───────────────────────────────── */
|
|
1195
1235
|
server.registerTool("get_file_deps", {
|
|
1196
1236
|
title: "Get file-level import dependencies",
|
package/dist/testmap.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-coverage mapping — pair test files with the source files they exercise,
|
|
3
|
+
* and surface source files no test touches.
|
|
4
|
+
*
|
|
5
|
+
* This is *structural* coverage (which files have tests at all), not line
|
|
6
|
+
* coverage — no instrumentation, no test runner, works on a cold checkout.
|
|
7
|
+
*
|
|
8
|
+
* Two matching signals, strongest first:
|
|
9
|
+
* 1. import — a test file imports the source file (graph edge; definitive).
|
|
10
|
+
* 2. name — naming convention pairs them ("auth.test.ts" → "auth.ts",
|
|
11
|
+
* "test_utils.py" → "utils.py", "FooTest.java" → "Foo.java"),
|
|
12
|
+
* resolved to the candidate sharing the longest path prefix.
|
|
13
|
+
*/
|
|
14
|
+
// ─── Test-file detection ───────────────────────────────────────────────────────
|
|
15
|
+
const TEST_DIRS = new Set(["test", "tests", "__tests__", "spec", "specs", "testing", "e2e", "integration-tests"]);
|
|
16
|
+
/** Support material, not tests and not production source: excluded from both sides. */
|
|
17
|
+
const FIXTURE_DIRS = new Set(["fixtures", "fixture", "__fixtures__", "__mocks__", "mocks", "testdata", "snapshots", "__snapshots__"]);
|
|
18
|
+
/** True when a rel path lives under a fixtures/mocks/testdata directory. */
|
|
19
|
+
export function isFixtureFile(rel) {
|
|
20
|
+
return rel.split("/").slice(0, -1).some((d) => FIXTURE_DIRS.has(d.toLowerCase()));
|
|
21
|
+
}
|
|
22
|
+
const TEST_BASENAME_PATTERNS = [
|
|
23
|
+
/\.(test|spec)\.[^.]+$/i, // auth.test.ts, auth.spec.js
|
|
24
|
+
/[_-](test|tests|spec)\.[^.]+$/i, // auth_test.go, auth-test.js, auth_spec.rb
|
|
25
|
+
/^(test|spec)[_-]/i, // test_auth.py, spec_auth.rb
|
|
26
|
+
/Tests?\.(java|cs|kt|kts|swift|scala)$/, // AuthTest.java, AuthTests.cs
|
|
27
|
+
/Spec\.(java|cs|kt|kts|swift|scala)$/, // AuthSpec.kt
|
|
28
|
+
];
|
|
29
|
+
/** True when a rel path (forward-slashed) looks like a test file. */
|
|
30
|
+
export function isTestFile(rel) {
|
|
31
|
+
const parts = rel.split("/");
|
|
32
|
+
const base = parts[parts.length - 1];
|
|
33
|
+
if (parts.slice(0, -1).some((d) => TEST_DIRS.has(d.toLowerCase())))
|
|
34
|
+
return true;
|
|
35
|
+
return TEST_BASENAME_PATTERNS.some((re) => re.test(base));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Derive the source basename a test file's name points at, or null when the
|
|
39
|
+
* name carries no convention ("smoke.mjs" in a test dir → null).
|
|
40
|
+
* "auth.test.ts" → "auth" · "test_utils.py" → "utils" · "AuthTest.java" → "Auth"
|
|
41
|
+
*/
|
|
42
|
+
export function testNameTarget(rel) {
|
|
43
|
+
const base = rel.split("/").pop();
|
|
44
|
+
const noExt = base.replace(/\.[^.]+$/, "");
|
|
45
|
+
let t = noExt
|
|
46
|
+
.replace(/\.(test|spec)$/i, "")
|
|
47
|
+
.replace(/[_-](test|tests|spec|smoke)$/i, "")
|
|
48
|
+
.replace(/\.(smoke)$/i, "")
|
|
49
|
+
.replace(/^(test|spec)[_-]/i, "")
|
|
50
|
+
.replace(/(Test|Tests|Spec)$/, "");
|
|
51
|
+
if (t === noExt || t.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
return t;
|
|
54
|
+
}
|
|
55
|
+
// ─── Mapping ───────────────────────────────────────────────────────────────────
|
|
56
|
+
function commonPrefixLen(a, b) {
|
|
57
|
+
const pa = a.split("/");
|
|
58
|
+
const pb = b.split("/");
|
|
59
|
+
let i = 0;
|
|
60
|
+
while (i < pa.length - 1 && i < pb.length - 1 && pa[i] === pb[i])
|
|
61
|
+
i++;
|
|
62
|
+
return i;
|
|
63
|
+
}
|
|
64
|
+
function baseNoExt(rel) {
|
|
65
|
+
return rel.split("/").pop().replace(/\.[^.]+$/, "");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Build the test↔source coverage map from a symbol graph
|
|
69
|
+
* (use `buildSymbolGraph` over a directory that includes the test files).
|
|
70
|
+
*/
|
|
71
|
+
export function mapTestCoverage(graph) {
|
|
72
|
+
const nodeMap = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
73
|
+
const allFiles = graph.nodes.filter((n) => n.nodeType === "file");
|
|
74
|
+
const fixtureCount = allFiles.filter((f) => isFixtureFile(f.id)).length;
|
|
75
|
+
const files = allFiles.filter((f) => !isFixtureFile(f.id));
|
|
76
|
+
const testFiles = files.filter((f) => isTestFile(f.id));
|
|
77
|
+
const sourceFiles = files.filter((f) => !isTestFile(f.id));
|
|
78
|
+
const isTest = new Set(testFiles.map((f) => f.id));
|
|
79
|
+
const sourceByBase = new Map();
|
|
80
|
+
for (const s of sourceFiles) {
|
|
81
|
+
const b = baseNoExt(s.id).toLowerCase();
|
|
82
|
+
(sourceByBase.get(b) ?? sourceByBase.set(b, []).get(b)).push(s.id);
|
|
83
|
+
}
|
|
84
|
+
// Signal 1: import edges test → source. Also count source-side fan-in (Ca).
|
|
85
|
+
const links = [];
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
const afferent = new Map(); // source ← importers (non-test)
|
|
88
|
+
for (const e of graph.edges) {
|
|
89
|
+
if (e.edgeType !== "imports")
|
|
90
|
+
continue;
|
|
91
|
+
const to = nodeMap.get(e.to);
|
|
92
|
+
const toFile = to ? (to.nodeType === "file" ? to.id : to.file) : null;
|
|
93
|
+
if (!toFile || e.from === toFile)
|
|
94
|
+
continue;
|
|
95
|
+
if (isTest.has(e.from) && !isTest.has(toFile)) {
|
|
96
|
+
const key = e.from + "|" + toFile;
|
|
97
|
+
if (!seen.has(key)) {
|
|
98
|
+
seen.add(key);
|
|
99
|
+
links.push({ test: e.from, source: toFile, via: "import" });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (!isTest.has(e.from) && !isTest.has(toFile)) {
|
|
103
|
+
(afferent.get(toFile) ?? afferent.set(toFile, new Set()).get(toFile)).add(e.from);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Signal 2: naming convention, for test files with no import link yet.
|
|
107
|
+
const linkedTests = new Set(links.map((l) => l.test));
|
|
108
|
+
for (const t of testFiles) {
|
|
109
|
+
if (linkedTests.has(t.id))
|
|
110
|
+
continue;
|
|
111
|
+
// Explicit marker in the name, else (for files inside test dirs, where the
|
|
112
|
+
// dir itself is the marker) the plain basename: test/analysis.mjs → "analysis".
|
|
113
|
+
const target = testNameTarget(t.id) ?? baseNoExt(t.id);
|
|
114
|
+
const candidates = sourceByBase.get(target.toLowerCase());
|
|
115
|
+
if (!candidates || candidates.length === 0)
|
|
116
|
+
continue;
|
|
117
|
+
// Prefer the candidate sharing the longest directory prefix with the test.
|
|
118
|
+
let best = [];
|
|
119
|
+
let bestScore = -1;
|
|
120
|
+
for (const c of candidates) {
|
|
121
|
+
const s = commonPrefixLen(t.id, c);
|
|
122
|
+
if (s > bestScore) {
|
|
123
|
+
bestScore = s;
|
|
124
|
+
best = [c];
|
|
125
|
+
}
|
|
126
|
+
else if (s === bestScore)
|
|
127
|
+
best.push(c);
|
|
128
|
+
}
|
|
129
|
+
for (const c of best) {
|
|
130
|
+
const key = t.id + "|" + c;
|
|
131
|
+
if (!seen.has(key)) {
|
|
132
|
+
seen.add(key);
|
|
133
|
+
links.push({ test: t.id, source: c, via: "name" });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Aggregate.
|
|
138
|
+
const testsBySource = new Map();
|
|
139
|
+
for (const l of links) {
|
|
140
|
+
(testsBySource.get(l.source) ?? testsBySource.set(l.source, []).get(l.source)).push(l.test);
|
|
141
|
+
}
|
|
142
|
+
const tested = [];
|
|
143
|
+
const untested = [];
|
|
144
|
+
for (const s of sourceFiles) {
|
|
145
|
+
const tests = testsBySource.get(s.id);
|
|
146
|
+
if (tests)
|
|
147
|
+
tested.push({ file: s.id, tests: [...new Set(tests)].sort() });
|
|
148
|
+
else
|
|
149
|
+
untested.push({ file: s.id, symbols: s.symbolCount, afferent: afferent.get(s.id)?.size ?? 0 });
|
|
150
|
+
}
|
|
151
|
+
tested.sort((a, b) => a.file.localeCompare(b.file));
|
|
152
|
+
untested.sort((a, b) => b.afferent - a.afferent || b.symbols - a.symbols || a.file.localeCompare(b.file));
|
|
153
|
+
const covered = new Set(links.map((l) => l.test));
|
|
154
|
+
const orphanTests = testFiles.map((f) => f.id).filter((id) => !covered.has(id)).sort();
|
|
155
|
+
return {
|
|
156
|
+
sourceFiles: sourceFiles.length,
|
|
157
|
+
testFiles: testFiles.length,
|
|
158
|
+
fixtureFiles: fixtureCount,
|
|
159
|
+
testedSources: tested.length,
|
|
160
|
+
untestedSources: untested.length,
|
|
161
|
+
coverageRatio: sourceFiles.length === 0 ? 0 : Math.round((tested.length / sourceFiles.length) * 100) / 100,
|
|
162
|
+
links,
|
|
163
|
+
tested,
|
|
164
|
+
untested,
|
|
165
|
+
orphanTests,
|
|
166
|
+
};
|
|
167
|
+
}
|
package/package.json
CHANGED