universal-ast-mapper 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,44 @@ since 1.0.0, guarantees a stable MCP tool / CLI surface across the 1.x line.
6
6
 
7
7
  ---
8
8
 
9
+ ## [1.28.0] — 2026-06-11 · Test coverage in the dashboard
10
+ - The health dashboard (`ast-map report` / `get_codebase_report`) now surfaces
11
+ v1.27's structural test coverage:
12
+ - **Test coverage card** — coverage bar (tested/total sources, % colored
13
+ green ≥ 70 / amber ≥ 40 / red below) + the **untested sources ranked by
14
+ risk** (fan-in Ca, then symbol count), capped at 12 with a "+N more" note.
15
+ - **Test coverage stat tile** in the header grid.
16
+ - **Root fallback** — reporting on `src/` only (no test files in the scanned
17
+ dir)? Test files are pulled in from the project root automatically and the
18
+ card notes "(from project root)".
19
+ - **Health score** now includes a structural-coverage penalty (capped at 8
20
+ points, proportional to the untested share).
21
+ - `ReportData` gains `testCoverage` (testFiles, sourceFiles, testedSources,
22
+ coverageRatio, untestedCount, untested[], rootFallback) — additive.
23
+ - CLI `ast-map report` summary line now shows `tests N%`.
24
+ - Tests: +6 checks in `test/analysis.mjs` (159 total).
25
+
26
+ ## [1.27.0] — 2026-06-11 · Test-coverage mapping
27
+ - **New MCP tool `get_test_coverage`** + **CLI `ast-map tests [dir]`** (alias
28
+ `coverage`) — structural test coverage with zero instrumentation: which source
29
+ files have tests at all, and which have **none**.
30
+ - Two pairing signals:
31
+ - **import** — a test file imports the source file (graph edge; definitive).
32
+ - **name** — conventions: `auth.test.ts` → `auth.ts`, `auth_test.go`,
33
+ `test_utils.py` → `utils.py`, `AuthTest.java` → `Auth.java`,
34
+ `foo-smoke.mjs` → `foo.*`, and bare `test/<name>.*` → `<name>.*`;
35
+ ambiguity resolved by longest shared path prefix.
36
+ - Test files detected by directory (`test/`, `tests/`, `__tests__/`, `spec/`, `e2e/`)
37
+ or basename pattern; **fixtures/mocks/testdata dirs excluded from both sides**.
38
+ - Output: coverage ratio, test→source `links` (with `via`), `tested`,
39
+ **`untested` ranked by risk** (fan-in Ca, then symbol count — load-bearing
40
+ files with no tests first), and `orphanTests` (no source matched; usually
41
+ integration/e2e).
42
+ - CLI: `-u/--untested`, `--links`, `-n/--top`, `--json`.
43
+ - New module `testmap` (`mapTestCoverage`, `isTestFile`, `testNameTarget`,
44
+ `isFixtureFile`) + `test/fixtures/testmap/` fixture tree. Tests: +9 checks
45
+ in `test/analysis.mjs` (153 total). **30 MCP tools / 32 CLI commands.**
46
+
9
47
  ## [1.26.0] — 2026-06-11 · Coupling overlay in the explorer
10
48
  - **`ast-map explore` color modes** — new toolbar dropdown: `color: folder`
11
49
  (existing per-directory hues) or **`color: coupling`** — nodes shaded by
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
- **29 MCP tools / 31 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** 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).
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** with test-coverage, coupling and SDP cards (`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.28.0** | **Test coverage in the dashboard** — `ast-map report` / `get_codebase_report` gain a **Test coverage** card (coverage bar + untested sources ranked by risk with Ca/symbols) and stat tile; structural coverage now factors into the health score (capped penalty). Reporting on `src/` only? Test files are **pulled in from the project root automatically**. |
839
+ | **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**) |
823
840
  | **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. |
824
841
  | **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**) |
825
842
  | **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%. |
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).
@@ -674,7 +675,7 @@ program
674
675
  fs.writeFileSync(out, buildReportHtml(data), "utf8");
675
676
  header(`Code Health \u2014 ${rel}/ ${dim(`(${data.fileCount} files)`)}`);
676
677
  const gcolor = data.grade === "A" || data.grade === "B" ? green : data.grade === "C" || data.grade === "D" ? yellow : (x) => x;
677
- console.log(indent(`Grade ${bold(gcolor(data.grade))} ${dim("(" + data.score + "/100)")} · ${data.dead.count} dead · ${data.cycles.count} cycles · max cx ${data.complexity.max}`));
678
+ console.log(indent(`Grade ${bold(gcolor(data.grade))} ${dim("(" + data.score + "/100)")} · ${data.dead.count} dead · ${data.cycles.count} cycles · max cx ${data.complexity.max} · tests ${Math.round(data.testCoverage.coverageRatio * 100)}%`));
678
679
  console.log(indent(green("✓ wrote " + path.relative(process.cwd(), out))));
679
680
  console.log();
680
681
  });
@@ -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/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";
@@ -793,7 +794,8 @@ server.registerTool("get_codebase_report", {
793
794
  title: "Codebase health report",
794
795
  description: "Scan a directory and return a one-shot health summary: file/symbol counts, language " +
795
796
  "breakdown, a health grade (A\u2013F) and score, complexity hotspots, god nodes (most-imported " +
796
- "symbols), dead exports, and circular dependencies. The `ast-map report` CLI renders this as HTML.",
797
+ "symbols), dead exports, circular dependencies, module coupling, SDP violations, and structural " +
798
+ "test coverage (untested sources ranked by risk). The `ast-map report` CLI renders this as HTML.",
797
799
  inputSchema: {
798
800
  path: z.string().optional().describe("Directory to scan. Defaults to the project root."),
799
801
  },
@@ -1191,6 +1193,45 @@ server.registerTool("semantic_search", {
1191
1193
  return errorText(describeError(err));
1192
1194
  }
1193
1195
  });
1196
+ /* ─────────────────── tool: get_test_coverage ───────────────────────────── */
1197
+ server.registerTool("get_test_coverage", {
1198
+ title: "Test-coverage map (tests ↔ sources)",
1199
+ description: "Structural test coverage: pair test files with the source files they exercise and list " +
1200
+ "source files no test touches. Two signals: a test file *importing* a source file " +
1201
+ "(definitive) and naming conventions (auth.test.ts → auth.ts, test_utils.py → utils.py). " +
1202
+ "No instrumentation or test runner needed. Untested files are ranked by risk " +
1203
+ "(fan-in, then symbol count). This is file-level coverage, not line coverage.",
1204
+ inputSchema: {
1205
+ path: z.string().optional().describe("Directory to scan (should include the test files). Default project root."),
1206
+ untestedOnly: z.boolean().optional().describe("Return only the untested-sources list. Default false."),
1207
+ },
1208
+ }, async ({ path: input, untestedOnly }) => {
1209
+ try {
1210
+ const { abs, rel, root } = resolveInRoot(input ?? ".");
1211
+ if (!fs.statSync(abs).isDirectory()) {
1212
+ return errorText(`"${input}" is not a directory. get_test_coverage requires a directory.`);
1213
+ }
1214
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
1215
+ const files = collectSourceFiles(abs, opts);
1216
+ const skels = [];
1217
+ for (const f of files) {
1218
+ const r = path.relative(root, f).split(path.sep).join("/");
1219
+ try {
1220
+ skels.push(await buildSkeleton(f, r, opts));
1221
+ }
1222
+ catch { /* skip */ }
1223
+ }
1224
+ const map = mapTestCoverage(buildSymbolGraph(skels, root));
1225
+ const dir = rel.split(path.sep).join("/") || ".";
1226
+ if (untestedOnly) {
1227
+ return jsonText({ directory: dir, untestedSources: map.untestedSources, coverageRatio: map.coverageRatio, untested: map.untested });
1228
+ }
1229
+ return jsonText({ directory: dir, ...map });
1230
+ }
1231
+ catch (err) {
1232
+ return errorText(describeError(err));
1233
+ }
1234
+ });
1194
1235
  /* ─────────────────── tool: get_file_deps ───────────────────────────────── */
1195
1236
  server.registerTool("get_file_deps", {
1196
1237
  title: "Get file-level import dependencies",
package/dist/report.js CHANGED
@@ -6,6 +6,7 @@ import { buildSymbolGraph } from "./graph.js";
6
6
  import { findDeadExports, findCircularDeps, getTopSymbols } from "./graph-analysis.js";
7
7
  import { findLayerViolations } from "./layers.js";
8
8
  import { computeModuleCoupling } from "./modulecoupling.js";
9
+ import { mapTestCoverage, isTestFile, isFixtureFile } from "./testmap.js";
9
10
  function gradeFor(score) {
10
11
  if (score >= 90)
11
12
  return "A";
@@ -53,6 +54,27 @@ export async function buildReport(absDir, root) {
53
54
  const god = getTopSymbols(graph, 8);
54
55
  const layerViolations = findLayerViolations(graph);
55
56
  const modules = computeModuleCoupling(graph).modules;
57
+ // Test coverage. If the scanned dir has no test files (common when reporting
58
+ // on src/ only), pull test files in from the project root so the map can
59
+ // still pair them with the scanned sources.
60
+ let covGraph = graph;
61
+ let rootFallback = false;
62
+ if (!skeletons.some((s) => isTestFile(s.file)) && path.resolve(absDir) !== path.resolve(root)) {
63
+ const have = new Set(items.map((i) => i.abs));
64
+ const testItems = collectSourceFiles(root, opts)
65
+ .filter((f) => !have.has(f))
66
+ .map((f) => ({ abs: f, rel: path.relative(root, f).split(path.sep).join("/") }))
67
+ .filter((i) => isTestFile(i.rel) && !isFixtureFile(i.rel));
68
+ if (testItems.length > 0) {
69
+ const builtTests = await buildSkeletonsBulk(testItems, opts);
70
+ const testSkels = builtTests.filter((r) => r !== null).map((r) => r.skel);
71
+ if (testSkels.length > 0) {
72
+ covGraph = buildSymbolGraph([...skeletons, ...testSkels], root);
73
+ rootFallback = true;
74
+ }
75
+ }
76
+ }
77
+ const cov = mapTestCoverage(covGraph);
56
78
  hotspots.sort((a, b) => b.complexity - a.complexity);
57
79
  const veryHigh = hotspots.filter((f) => f.complexity > 20).length;
58
80
  const high = hotspots.filter((f) => f.complexity > 10 && f.complexity <= 20).length;
@@ -63,6 +85,7 @@ export async function buildReport(absDir, root) {
63
85
  score -= Math.min(28, veryHigh * 4 + high * 1);
64
86
  score -= Math.min(12, god.filter((g) => g.importCount >= 8).length * 4);
65
87
  score -= Math.min(10, layerViolations.length);
88
+ score -= Math.min(8, Math.round((1 - cov.coverageRatio) * 8)); // structural test coverage
66
89
  score = Math.max(0, Math.round(score));
67
90
  const languages = [...langCount.entries()]
68
91
  .map(([lang, f]) => ({ lang, files: f }))
@@ -82,6 +105,15 @@ export async function buildReport(absDir, root) {
82
105
  complexity: { average: cxN ? Math.round((cxSum / cxN) * 10) / 10 : 0, max: cxMax, hotspots: hotspots.slice(0, 12) },
83
106
  layerViolations: { count: layerViolations.length, items: layerViolations.slice(0, 12) },
84
107
  modules: modules.slice(0, 10),
108
+ testCoverage: {
109
+ testFiles: cov.testFiles,
110
+ sourceFiles: cov.sourceFiles,
111
+ testedSources: cov.testedSources,
112
+ coverageRatio: cov.coverageRatio,
113
+ untestedCount: cov.untestedSources,
114
+ untested: cov.untested.slice(0, 12),
115
+ rootFallback,
116
+ },
85
117
  };
86
118
  }
87
119
  /* ─── Premium HTML dashboard ───────────────────────────────────────────────── */
@@ -125,6 +157,17 @@ export function buildReportHtml(d) {
125
157
  const modules = d.modules.length
126
158
  ? d.modules.map((m) => bar(`${m.module} · ${m.files} file(s)`, m.instability, 1, instColor(m.instability), `Ca ${m.afferent} · Ce ${m.efferent} · <b>I ${m.instability.toFixed(2)}</b>`)).join("")
127
159
  : `<div class="empty">No cross-module imports.</div>`;
160
+ const covPct = Math.round(d.testCoverage.coverageRatio * 100);
161
+ const covC = d.testCoverage.coverageRatio >= 0.7 ? "#1d9e75" : d.testCoverage.coverageRatio >= 0.4 ? "#ba7517" : "#e24b4a";
162
+ const covHead = d.testCoverage.testFiles > 0
163
+ ? bar(`${d.testCoverage.testedSources}/${d.testCoverage.sourceFiles} sources tested · ${d.testCoverage.testFiles} test file(s)${d.testCoverage.rootFallback ? " (from project root)" : ""}`, covPct, 100, covC, `<b>${covPct}%</b>`)
164
+ : "";
165
+ const covList = d.testCoverage.testFiles === 0
166
+ ? `<div class="empty">No test files found in the scanned directory or project root.</div>`
167
+ : d.testCoverage.untested.length === 0
168
+ ? `<div class="ok">✓ Every source file has at least one test</div>`
169
+ : d.testCoverage.untested.map((u) => `<div class="li"><span class="mono">${esc(u.file)}</span><span class="dim">${u.symbols} symbol(s)</span><span class="pill">Ca ${u.afferent}</span></div>`).join("")
170
+ + (d.testCoverage.untestedCount > d.testCoverage.untested.length ? `<div class="more">+${d.testCoverage.untestedCount - d.testCoverage.untested.length} more…</div>` : "");
128
171
  const sdp = d.layerViolations.count
129
172
  ? d.layerViolations.items.map((v) => `<div class="li"><span class="mono">${esc(v.from)}</span><span class="dim">→ ${esc(v.to)}</span><span class="pill" style="color:${instColor(0.9)}">+${v.severity.toFixed(2)}</span></div>`).join("")
130
173
  + (d.layerViolations.count > d.layerViolations.items.length ? `<div class="more">+${d.layerViolations.count - d.layerViolations.items.length} more…</div>` : "")
@@ -170,6 +213,7 @@ export function buildReportHtml(d) {
170
213
  ${statCard("Dead exports", d.dead.count, d.dead.count ? "#d85a30" : "#1d9e75")}
171
214
  ${statCard("Cycles", d.cycles.count, d.cycles.count ? "#e24b4a" : "#1d9e75")}
172
215
  ${statCard("SDP violations", d.layerViolations.count, d.layerViolations.count ? "#d85a30" : "#1d9e75")}
216
+ ${statCard("Test coverage", covPct + "%", covC)}
173
217
  </div>
174
218
  <div class="card"><h2>Language breakdown</h2>${langs}</div>
175
219
  <div class="card"><h2>Complexity hotspots</h2>${hotspots}</div>
@@ -181,6 +225,7 @@ export function buildReportHtml(d) {
181
225
  <div class="card"><h2>Module coupling (instability)</h2>${modules}</div>
182
226
  <div class="card"><h2>Layer violations (stable → volatile)</h2>${sdp}</div>
183
227
  </div>
228
+ <div class="card"><h2>Test coverage (untested by risk)</h2>${covHead}${covList}</div>
184
229
  <div class="card"><h2>Dead exports (high confidence)</h2>${dead}</div>
185
230
  <div class="foot">Generated by AST-MCP · universal-ast-mapper</div>
186
231
  </div></body></html>`;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-ast-mapper",
3
- "version": "1.26.0",
3
+ "version": "1.28.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",