#!/usr/bin/env node // Renders a Vitest coverage-summary.json into either a shields.io badge URL // (default) or a sticky PR-comment markdown table (--pr-comment). // // Usage: // node scripts/coverage-summary.mjs # prints shields.io URL // node scripts/coverage-summary.mjs --pr-comment # prints markdown for PR // node scripts/coverage-summary.mjs --json # prints raw totals import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const MARKER = ""; const repoRoot = path.resolve(fileURLToPath(import.meta.url), "..", ".."); const targetsPath = path.join(repoRoot, "scripts", "coverage-targets.json"); export function resolveSummaryPath({ root = repoRoot, env = process.env, fsApi = fs } = {}) { if (env.COVERAGE_SUMMARY_PATH) { return path.resolve(root, env.COVERAGE_SUMMARY_PATH); } const candidates = [ path.join(root, "coverage-gate", "coverage-summary.json"), path.join(root, "coverage", "coverage-summary.json"), ]; return candidates.find((candidate) => fsApi.existsSync(candidate)) ?? candidates[0]; } function loadSummary() { const summaryPath = resolveSummaryPath(); if (!fs.existsSync(summaryPath)) return null; return JSON.parse(fs.readFileSync(summaryPath, "utf8")); } function loadTargets() { if (!fs.existsSync(targetsPath)) return []; return JSON.parse(fs.readFileSync(targetsPath, "utf8")); } function aggregateTotals(summary) { const total = summary.total ?? null; if (!total) return null; return { statements: total.statements?.pct ?? 0, branches: total.branches?.pct ?? 0, functions: total.functions?.pct ?? 0, lines: total.lines?.pct ?? 0, }; } function pickColor(pct) { if (pct >= 90) return "brightgreen"; if (pct >= 75) return "green"; if (pct >= 60) return "yellowgreen"; if (pct >= 50) return "yellow"; if (pct >= 40) return "orange"; return "red"; } function shieldsUrl(totals) { if (!totals) return "https://img.shields.io/badge/coverage-unknown-lightgrey"; const pct = totals.statements.toFixed(1); return `https://img.shields.io/badge/coverage-${pct}%25-${pickColor(totals.statements)}`; } function evaluatePerPath(summary, targets) { const rows = []; for (const target of targets) { const filtered = Object.entries(summary).filter(([file]) => { if (file === "total") return false; const rel = path.relative(repoRoot, file); return target.pathPrefixes.some((prefix) => rel.startsWith(prefix)); }); if (filtered.length === 0) { rows.push({ name: target.name, statementsPct: null, branchesPct: null, target, status: "skip" }); continue; } const totals = filtered.reduce( (acc, [, m]) => ({ statements: acc.statements + m.statements.covered, statementsTotal: acc.statementsTotal + m.statements.total, branches: acc.branches + m.branches.covered, branchesTotal: acc.branchesTotal + m.branches.total, }), { statements: 0, statementsTotal: 0, branches: 0, branchesTotal: 0 } ); const statementsPct = totals.statementsTotal > 0 ? (totals.statements / totals.statementsTotal) * 100 : 0; const branchesPct = totals.branchesTotal > 0 ? (totals.branches / totals.branchesTotal) * 100 : 0; const passes = statementsPct >= target.statements && branchesPct >= target.branches; rows.push({ name: target.name, statementsPct, branchesPct, target, status: passes ? "pass" : "fail", }); } return rows; } function renderPrComment(totals, rows) { const lines = []; lines.push(MARKER); lines.push("## Coverage Report"); lines.push(""); if (!totals) { lines.push("_coverage-summary.json not found — vitest with --coverage did not run._"); return lines.join("\n"); } lines.push("| metric | total |"); lines.push("|---|---|"); lines.push(`| statements | **${totals.statements.toFixed(1)}%** |`); lines.push(`| branches | **${totals.branches.toFixed(1)}%** |`); lines.push(`| functions | **${totals.functions.toFixed(1)}%** |`); lines.push(`| lines | **${totals.lines.toFixed(1)}%** |`); lines.push(""); lines.push("### Per-path thresholds"); lines.push(""); lines.push("| path | statements | branches | gate | status |"); lines.push("|---|---|---|---|---|"); for (const row of rows) { const tag = row.target.criticalPath ? " (critical)" : ""; const statements = row.statementsPct === null ? "n/a" : `${row.statementsPct.toFixed(1)}%`; const branches = row.branchesPct === null ? "n/a" : `${row.branchesPct.toFixed(1)}%`; const gate = `${row.target.statements}% / ${row.target.branches}%`; const icon = row.status === "pass" ? "PASS" : row.status === "fail" ? "FAIL" : "SKIP"; lines.push(`| ${row.name}${tag} | ${statements} | ${branches} | ${gate} | ${icon} |`); } lines.push(""); lines.push("_Bump thresholds by editing `scripts/coverage-targets.json`. See `docs/architecture/test-coverage.md`._"); return lines.join("\n"); } export function main() { const argv = process.argv.slice(2); const summary = loadSummary(); const targets = loadTargets(); const totals = summary ? aggregateTotals(summary) : null; if (argv.includes("--pr-comment")) { const rows = summary ? evaluatePerPath(summary, targets) : []; process.stdout.write(renderPrComment(totals, rows) + "\n"); return; } if (argv.includes("--json")) { process.stdout.write(JSON.stringify(totals ?? {}, null, 2) + "\n"); return; } // Default: shields.io badge URL process.stdout.write(shieldsUrl(totals) + "\n"); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(); }