aboutsummaryrefslogtreecommitdiff
path: root/languages/typescript/claude/scripts
diff options
context:
space:
mode:
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));