1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
|
#!/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": {...}, "<abs-path>": { "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));
|