diff options
Diffstat (limited to 'languages/typescript/claude/scripts')
| -rw-r--r-- | languages/typescript/claude/scripts/coverage-summary.js | 169 |
1 files changed, 169 insertions, 0 deletions
diff --git a/languages/typescript/claude/scripts/coverage-summary.js b/languages/typescript/claude/scripts/coverage-summary.js new file mode 100644 index 0000000..72fa78d --- /dev/null +++ b/languages/typescript/claude/scripts/coverage-summary.js @@ -0,0 +1,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)); |
