#!/usr/bin/env node // scripts/coverage/diff.mjs — L1 of the coverage architecture (ADR-020). // // Reads the merged lcov (`coverage/lcov.info`) and the working tree's git // diff against a base ref, then asserts cover-the-diff: every changed // *executable* line must have execution count > 0. // // Output: // - stdout: JSON `{ status, summary, uncovered: [{ file, line, kind }] }` // (machine-readable for the dispatch loop) // - stderr: human summary // Exit: 0 on pass, 1 on fail. // // Usage: // pnpm coverage:diff # default base: origin/main // pnpm coverage:diff -- --base HEAD~1 # override base ref // pnpm coverage:diff -- --lcov path/to.info # override lcov path // pnpm coverage:diff -- --json # JSON only (no stderr) // // Implementation: zero deps. Pure Node ESM + child_process + fs. import fs from "node:fs"; import path from "node:path"; import { execSync } from "node:child_process"; /** * Files that don't need diff-coverage gating. Test files, configs, docs, * lockfiles, generated artifacts. Also covers the per-feature exclude * patterns documented in vitest configs (DI bootstrap, interfaces, CMS * collections, factories, contracts, UI). */ const ALLOWED_GLOBS = [ // Test artifacts /\.test\.(ts|tsx|js|mjs)$/, /\/__factories__\//, /\/__contracts__\//, /\/__fixtures__\//, /\/__seeds__\//, // Configs /\.config\.(ts|js|mjs|cjs)$/, /(^|\/)package\.json$/, /(^|\/)tsconfig.*\.json$/, /(^|\/)turbo\.json$/, // Docs / data /\.md$/, /\.json$/, /\.ya?ml$/, /\.gitignore$/, /\.prettierignore$/, /\.npmrc$/, /(^|\/)\.env(\.[^/]+)?$/, // .env, .env.example, .env.local, etc. // Shell scripts (not Vitest-covered) /\.sh$/, /\.bash$/, // Dev-tooling scripts — tested via `node --test`, outside vitest's v8 lcov. // (Their own test coverage is gated separately via the scripts' own tests.) /^scripts\//, /^turbo\/generators\//, // Per-package coverage excludes (mirror vitest config) /\/di\/bind-production\.ts$/, /\/application\/repositories\//, /\/application\/services\//, /\/integrations\/cms\//, /\/ui\//, // Pure type-alias / interface files (no executable code) /\.d\.ts$/, // ambient declaration files — no runtime code by definition /\.interface\.ts$/, /\/index\.ts$/, // barrel re-exports — no executable code // Build artifacts /\.tsbuildinfo$/, /\.lock$/, /(^|\/)dist\//, /(^|\/)\.next\//, /(^|\/)\.turbo\//, /(^|\/)node_modules\//, // Coverage output (anchored to package/app/root, NOT scripts/coverage/) /^coverage\//, /^packages\/[^/]+\/coverage\//, /^apps\/[^/]+\/coverage\//, ]; function isAllowed(file) { return ALLOWED_GLOBS.some((re) => re.test(file)); } /** * Parse lcov into a map of file -> Map. * Only DA records are read; LF/LH/BRDA/BRF/BRH/etc. are ignored. * * lcov is a simple line-oriented format: * SF: * DA:, * ... * end_of_record */ export function parseLcov(text) { const result = new Map(); let currentFile = null; let currentLines = null; for (const line of text.split("\n")) { if (line.startsWith("SF:")) { currentFile = line.slice(3); currentLines = new Map(); result.set(currentFile, currentLines); } else if (line.startsWith("DA:") && currentLines) { const [lineNo, count] = line.slice(3).split(","); currentLines.set(Number(lineNo), Number(count)); } else if (line === "end_of_record") { currentFile = null; currentLines = null; } } return result; } /** * Parse `git diff --unified=0` output into a map of file -> Set. * * Only NEW or MODIFIED lines in the new version are tracked (the `+N,M` * portion of `@@ -A,B +N,M @@`). Removed-only hunks contribute no lines * to check (the line is gone). * * Renamed files are tracked by their new path. */ export function parseGitDiff(text) { const result = new Map(); let currentFile = null; let currentLines = null; for (const line of text.split("\n")) { if (line.startsWith("+++ ")) { const p = line.slice(4).trim(); if (p === "/dev/null") { currentFile = null; currentLines = null; continue; } // Strip `b/` prefix git adds currentFile = p.startsWith("b/") ? p.slice(2) : p; currentLines = new Set(); result.set(currentFile, currentLines); } else if (line.startsWith("@@ ") && currentLines) { // @@ -A,B +N,M @@ const m = /@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line); if (!m) continue; const start = Number(m[1]); const count = m[2] === undefined ? 1 : Number(m[2]); if (count === 0) continue; // hunk has no lines in new version for (let i = 0; i < count; i++) { currentLines.add(start + i); } } } return result; } /** * Given parsed lcov + parsed diff, return the list of uncovered hits. * Each hit: { file, line, kind } * - kind = "uncovered": line is executable in lcov but count is 0 * - kind = "no-coverage-data": file is not in lcov at all * - kind = "non-executable": line has no DA record (not flagged; for * visibility only, currently filtered out) */ export function computeDiffCoverage(diff, lcov, opts = {}) { const repoRoot = opts.repoRoot ?? process.cwd(); const uncovered = []; const fileSummaries = []; for (const [file, lines] of diff) { if (isAllowed(file)) continue; // Match lcov keys (absolute paths) to diff keys (repo-relative) let lcovLines = lcov.get(file); if (!lcovLines) { const abs = path.resolve(repoRoot, file); lcovLines = lcov.get(abs); } if (!lcovLines) { // Try matching by suffix in either direction. lcov paths can be: // - absolute (vitest with `coverage.reportsDirectory` at default) // - repo-relative (after `pnpm coverage:aggregate` normalizes) // - package-relative (per-package lcov from `pnpm test -- --coverage`) // The diff path is always repo-relative. for (const [k, v] of lcov.entries()) { if (file.endsWith("/" + k) || k.endsWith("/" + file) || k === file) { lcovLines = v; break; } } } if (!lcovLines) { uncovered.push({ file, line: 0, kind: "no-coverage-data" }); fileSummaries.push({ file, changed: lines.size, missed: lines.size }); continue; } let missedInFile = 0; for (const line of [...lines].sort((a, b) => a - b)) { const count = lcovLines.get(line); if (count === undefined) { // Line isn't executable per lcov — skip (blank, comment, type-only) continue; } if (count === 0) { uncovered.push({ file, line, kind: "uncovered" }); missedInFile++; } } fileSummaries.push({ file, changed: lines.size, missed: missedInFile }); } return { status: uncovered.length === 0 ? "pass" : "fail", summary: { filesChanged: diff.size, filesGated: fileSummaries.length, uncoveredCount: uncovered.length, }, fileSummaries, uncovered, }; } // ---- CLI ---- function parseArgs(argv) { const out = { base: "origin/main", lcov: "coverage/lcov.info", json: false }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; if (a === "--base") out.base = argv[++i]; else if (a === "--lcov") out.lcov = argv[++i]; else if (a === "--json") out.json = true; else if (a === "--help" || a === "-h") { console.log( "Usage: pnpm coverage:diff [-- --base ] [--lcov ] [--json]", ); process.exit(0); } } return out; } function main() { const args = parseArgs(process.argv); const repoRoot = process.cwd(); // Load lcov const lcovPath = path.resolve(repoRoot, args.lcov); if (!fs.existsSync(lcovPath)) { process.stderr.write( `[coverage:diff] lcov file not found at ${lcovPath}\n` + `Run \`pnpm test -- --coverage\` first, then \`pnpm coverage:aggregate\`.\n`, ); // Emit JSON anyway so the dispatch loop can read it process.stdout.write( JSON.stringify({ status: "error", reason: "lcov-missing", lcovPath }) + "\n", ); process.exit(1); } const lcov = parseLcov(fs.readFileSync(lcovPath, "utf8")); // Get diff let diffText; try { diffText = execSync(`git diff --unified=0 --no-color ${args.base}...HEAD`, { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, }); } catch (err) { process.stderr.write( `[coverage:diff] git diff against base "${args.base}" failed: ${err.message}\n`, ); process.stdout.write( JSON.stringify({ status: "error", reason: "git-diff-failed" }) + "\n", ); process.exit(1); } const diff = parseGitDiff(diffText); // Compute const result = computeDiffCoverage(diff, lcov, { repoRoot }); // Emit JSON to stdout process.stdout.write(JSON.stringify(result, null, 2) + "\n"); // Emit human summary to stderr (unless --json) if (!args.json) { const { status, summary, uncovered } = result; if (status === "pass") { process.stderr.write( `[coverage:diff] PASS — ${summary.filesGated} file(s) gated, all changed lines covered.\n`, ); } else { process.stderr.write( `[coverage:diff] FAIL — ${summary.uncoveredCount} uncovered hit(s) across ${summary.filesGated} file(s):\n`, ); const byFile = new Map(); for (const u of uncovered) { if (!byFile.has(u.file)) byFile.set(u.file, []); byFile.get(u.file).push(u); } for (const [file, hits] of byFile) { const noData = hits[0]?.kind === "no-coverage-data"; if (noData) { process.stderr.write( ` ${file}\n no coverage data (new untested file?)\n`, ); } else { const lines = hits.map((h) => h.line).join(", "); process.stderr.write(` ${file}\n uncovered lines: ${lines}\n`); } } } } process.exit(result.status === "pass" ? 0 : 1); } // Only run main when invoked directly (not when imported by tests) const invokedDirectly = import.meta.url === `file://${process.argv[1]}`; if (invokedDirectly) { main(); }