#!/usr/bin/env node /* * Whole-project coverage summary from an Istanbul json-summary report. * * Batch helper for `make coverage-summary`. After the test run writes an * Istanbul json-summary report (nyc / c8 `--reporter=json-summary`, or * vitest/jest coverage), this prints a file-weighted project number and the * source files present on disk but absent from the report. * * The value here is the missing-file detection: a module no test imports never * appears in the report, so a statement-weighted total silently skips it and * the suite looks healthier than it is. This counts such a file as 0% and * weights the project number by file rather than by statement, so untested * files stay visible. nyc's own text reporter prints the per-file table, so * this deliberately doesn't reimplement that. * * Node built-ins only (fs, path), so it runs anywhere the bundle lands via * `node coverage-summary.js`. The json-summary shape it reads is: * { "total": {...}, "": { "statements": {"total", "covered"}, ... } } * * Usage: * node coverage-summary.js REPORT SOURCE_DIR [PROJECT_ROOT] * REPORT path to the Istanbul json-summary report * SOURCE_DIR the directory to account for (walked recursively) * PROJECT_ROOT defaults to the current directory */ "use strict"; const fs = require("node:fs"); const path = require("node:path"); const SRC_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]); const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".claude", ".git"]); function die(msg) { process.stderr.write(`coverage-summary: ${msg}\n`); process.exit(1); } function toSlash(p) { return p.split(path.sep).join("/"); } // parseReport reads an Istanbul json-summary into { absPath: [covered, total] }. // Uses statements; falls back to lines when statements is absent. function parseReport(reportPath) { let raw; try { raw = fs.readFileSync(reportPath, "utf8"); } catch { die(`coverage report not found: ${reportPath}`); } let data; try { data = JSON.parse(raw); } catch (e) { die(`malformed coverage JSON in ${reportPath}: ${e.message}`); } const out = {}; for (const [key, entry] of Object.entries(data)) { if (key === "total" || !entry || typeof entry !== "object") continue; const metric = entry.statements || entry.lines; if (!metric) continue; out[path.resolve(key)] = [Number(metric.covered) || 0, Number(metric.total) || 0]; } return out; } function underDir(table, sourceRel) { if (sourceRel === "." || sourceRel === "") return table; const prefix = sourceRel + "/"; const out = {}; for (const [p, v] of Object.entries(table)) { if (p === sourceRel || p.startsWith(prefix)) out[p] = v; } return out; } function isSource(name) { if (/\.(test|spec)\.[^.]+$/.test(name)) return false; if (name.endsWith(".d.ts")) return false; return SRC_EXT.has(path.extname(name)); } // sourceFiles returns project-relative source files under sourceDir (recursive), // skipping test files, declarations, and non-source directories. function sourceFiles(sourceDir, root) { const out = []; const walk = (dir) => { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { const full = path.join(dir, e.name); if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(full); } else if (e.isFile() && isSource(e.name)) { out.push(toSlash(path.relative(root, full))); } } }; walk(sourceDir); return out.sort(); } function missing(tracked, sourceDir, root) { const have = new Set(Object.keys(tracked)); return sourceFiles(sourceDir, root).filter((f) => !have.has(f)); } function filePct(covered, total) { return total > 0 ? (100 * covered) / total : 100; } function projectPct(tracked, miss) { const total = Object.keys(tracked).length + miss.length; if (total === 0) return 0; let score = 0; for (const [c, t] of Object.values(tracked)) score += filePct(c, t); return score / total; } function summaryText(tracked, miss, sourceRel) { const trackedCount = Object.keys(tracked).length; const total = trackedCount + miss.length; const pct = projectPct(tracked, miss); const lines = [ `Coverage summary for ${sourceRel}`, "", `Project coverage: ${pct.toFixed(1)}% (${trackedCount} tracked, ${miss.length} missing, ` + `${total} total; missing files count as 0%)`, "", `Not in coverage report: ${miss.length} file${miss.length === 1 ? "" : "s"}`, ]; if (miss.length > 0) { lines.push("These files had no coverage entry; they count as 0% in project coverage."); for (const m of [...miss].sort()) lines.push(` ${m}`); } else { lines.push("Every source file appears in the coverage report."); } lines.push("", "(Per-file table: run nyc's text reporter.)"); return lines.join("\n") + "\n"; } function main(argv) { if (argv.length < 2 || argv.length > 3) { process.stderr.write("usage: coverage-summary.js REPORT SOURCE_DIR [PROJECT_ROOT]\n"); process.exit(2); } const [report, sourceDir] = argv; const root = path.resolve(argv[2] || "."); const absSource = path.resolve(sourceDir); const sourceRel = toSlash(path.relative(root, absSource)) || "."; const table = parseReport(report); // Re-key the report to project-relative paths for comparison with disk files. const rel = {}; for (const [abs, v] of Object.entries(table)) rel[toSlash(path.relative(root, abs))] = v; const tracked = underDir(rel, sourceRel); const miss = missing(tracked, absSource, root); process.stdout.write("\n" + summaryText(tracked, miss, sourceRel)); } main(process.argv.slice(2));