aboutsummaryrefslogtreecommitdiff
path: root/languages/typescript/claude/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-31 13:57:40 -0500
committerCraig Jennings <c@cjennings.net>2026-05-31 13:57:40 -0500
commitee903e4b63257573773e93d10612250e3634cae9 (patch)
tree89cc6199b53f445a4c32252c6b982a6c86964e3b /languages/typescript/claude/scripts
parent47ca509e69b6a1472a735a4b9521a952e7434491 (diff)
downloadrulesets-ee903e4b63257573773e93d10612250e3634cae9.tar.gz
rulesets-ee903e4b63257573773e93d10612250e3634cae9.zip
feat(typescript): add coverage-summary to the TypeScript bundle
Last language in the coverage-summary fan-out, after Elisp, Python, and Go. Same kernel: count every source file on disk that's absent from the coverage report as 0% and weight the project number by file, so an untested file stays visible instead of being averaged away. The script at languages/typescript/claude/scripts/coverage-summary.js parses an Istanbul json-summary report (the coverage-summary.json that c8, Vitest, and Jest all emit), takes per-file statements covered over total, and reports a file-weighted number plus the missing files. It walks the source dir for .ts/.js, skipping test files, declarations, and node_modules. Node built-ins only, so it runs via node with no install, and it doesn't reimplement the per-file table nyc already prints. Tests are black-box, run with node's own test runner: a temp tree plus a json-summary report, the script invoked via node, output asserted. They cover missing-file detection, all-tracked, test-file and node_modules exclusion, and the missing-report error. make test gained a node --test discovery path for languages/*/tests, guarded so environments without Node skip it cleanly. As with Python, the TypeScript bundle had no gitignore-add.txt, which would have left the script un-gitignored on install, so I added one. This finishes the fan-out: coverage-summary now ships in all four bundles, each parsing its own tool's report behind the same file-weighted, missing-as-0% kernel. I proved the Go and TypeScript scripts by running them (Go against a live profile, TS against a synthetic report and the CLI). Python and TypeScript weren't run against a live coverage tool, since neither coverage.py nor nyc is installed here, so the first adopter of each should check against a real report.
Diffstat (limited to 'languages/typescript/claude/scripts')
-rw-r--r--languages/typescript/claude/scripts/coverage-summary.js169
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));