#!/usr/bin/env node // scripts/coverage/aggregate.mjs — L2 of the coverage architecture (ADR-020). // // Discovers every per-package lcov (`packages/*/coverage/lcov.info`, // `apps/*/coverage/lcov.info`), normalizes their paths to repo-relative, // merges into `coverage/lcov.info` at the repo root, and emits // `coverage/summary.json` — the committed trend store. // // Output: // - coverage/lcov.info (gitignored — large) // - coverage/summary.json (committed — trend via `git log -- ...`) // - stdout: short status line // Exit: 0 on success, 1 if no lcov files found. // // Usage: // pnpm coverage:aggregate # default discovery + emit // pnpm coverage:aggregate -- --json # print summary to stdout // // Implementation: zero deps. Pure Node ESM. import fs from "node:fs"; import path from "node:path"; import { execSync } from "node:child_process"; /** * Find every per-package / per-app lcov.info file under packages/* and apps/*. * Returns absolute paths. */ export function discoverLcovs(repoRoot) { const results = []; for (const root of ["packages", "apps"]) { const dir = path.join(repoRoot, root); if (!fs.existsSync(dir)) continue; for (const pkg of fs.readdirSync(dir)) { const lcov = path.join(dir, pkg, "coverage", "lcov.info"); if (fs.existsSync(lcov)) { results.push({ packageDir: path.join(root, pkg), // repo-relative lcov, }); } } } return results.sort((a, b) => a.packageDir.localeCompare(b.packageDir)); } /** * Normalize lcov text so every SF line is repo-relative. Vitest emits paths * relative to the package's vitest.config.ts (e.g. `src/foo.ts`), so we * prepend `packages//` (or `apps//`) to each SF. */ export function normalizeLcov(text, packageDir) { return text .split("\n") .map((line) => { if (!line.startsWith("SF:")) return line; const p = line.slice(3); // If already absolute or already prefixed with packages/apps, leave alone if (path.isAbsolute(p) || p.startsWith(packageDir + "/")) return line; return `SF:${packageDir}/${p}`; }) .join("\n"); } /** * Compute lcov-level summary stats from a parsed lcov map. * Returns { statements, branches, functions, lines } as percentages * (statements ≈ lines in V8's lcov output). * * Algorithm: walk all SF blocks (each record has LF/LH for line totals, * BRF/BRH for branches, FNF/FNH for functions). Sum across files; divide. */ export function summarizeLcov(lcovText) { let lf = 0, lh = 0, brf = 0, brh = 0, fnf = 0, fnh = 0; for (const line of lcovText.split("\n")) { if (line.startsWith("LF:")) lf += Number(line.slice(3)); else if (line.startsWith("LH:")) lh += Number(line.slice(3)); else if (line.startsWith("BRF:")) brf += Number(line.slice(4)); else if (line.startsWith("BRH:")) brh += Number(line.slice(4)); else if (line.startsWith("FNF:")) fnf += Number(line.slice(4)); else if (line.startsWith("FNH:")) fnh += Number(line.slice(4)); } const pct = (hit, found) => found === 0 ? 100 : Math.round((hit / found) * 10000) / 100; return { statements: pct(lh, lf), // V8 lcov: statements ≈ lines branches: pct(brh, brf), functions: pct(fnh, fnf), lines: pct(lh, lf), counts: { lf, lh, brf, brh, fnf, fnh }, }; } /** * Aggregate the discovered lcovs. Returns: * { * mergedLcov: string, * summary: { generatedAt, commit, repo: {...}, byPackage: { ... } } * } */ export function aggregate(repoRoot, opts = {}) { const lcovs = opts.lcovs ?? discoverLcovs(repoRoot); if (lcovs.length === 0) { return { mergedLcov: "", summary: null, lcovs: [] }; } const merged = []; const byPackage = {}; for (const { packageDir, lcov } of lcovs) { const text = fs.readFileSync(lcov, "utf8"); const normalized = normalizeLcov(text, packageDir); merged.push(normalized); // The package's @repo/ identifier comes from its package.json const pkgJsonPath = path.join(repoRoot, packageDir, "package.json"); let pkgName = packageDir; if (fs.existsSync(pkgJsonPath)) { try { pkgName = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")).name ?? packageDir; } catch { // fall through } } byPackage[pkgName] = summarizeLcov(normalized); } const mergedLcov = merged.join("\n"); const repo = summarizeLcov(mergedLcov); let commit = "unknown"; try { commit = execSync("git rev-parse --short HEAD", { cwd: repoRoot, encoding: "utf8", }).trim(); } catch { // not in a git repo, leave as "unknown" } return { mergedLcov, lcovs, summary: { generatedAt: opts.now ?? new Date().toISOString(), commit, repo, byPackage, }, }; } // ---- CLI ---- function parseArgs(argv) { const out = { json: false }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; if (a === "--json") out.json = true; else if (a === "--help" || a === "-h") { console.log("Usage: pnpm coverage:aggregate [-- --json]"); process.exit(0); } } return out; } function main() { const args = parseArgs(process.argv); const repoRoot = process.cwd(); const { mergedLcov, summary, lcovs } = aggregate(repoRoot); if (lcovs.length === 0) { process.stderr.write( `[coverage:aggregate] No per-package lcov.info files found.\n` + `Run \`pnpm test -- --coverage\` first.\n`, ); process.exit(1); } const outDir = path.join(repoRoot, "coverage"); fs.mkdirSync(outDir, { recursive: true }); fs.writeFileSync(path.join(outDir, "lcov.info"), mergedLcov); fs.writeFileSync( path.join(outDir, "summary.json"), JSON.stringify(summary, null, 2) + "\n", ); if (args.json) { process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); } else { process.stdout.write( `[coverage:aggregate] Merged ${lcovs.length} lcov(s); ` + `repo coverage: statements ${summary.repo.statements}%, ` + `branches ${summary.repo.branches}%, ` + `functions ${summary.repo.functions}%, ` + `lines ${summary.repo.lines}%\n` + `Wrote coverage/lcov.info + coverage/summary.json\n`, ); } } const invokedDirectly = import.meta.url === `file://${process.argv[1]}`; if (invokedDirectly) { main(); }