Files
agentic-dev/scripts/coverage/diff.mjs
Danijel Martinek 7d3125627c fix(scripts): allow .prettierignore in coverage diff gate
.prettierignore is a config file with no executable code. Without this
allowlist entry, pnpm coverage:diff fails with no-coverage-data when
.prettierignore is part of the diff.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:08:24 +00:00

327 lines
10 KiB
JavaScript

#!/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<lineNumber, executionCount>.
* Only DA records are read; LF/LH/BRDA/BRF/BRH/etc. are ignored.
*
* lcov is a simple line-oriented format:
* SF:<file>
* DA:<line>,<count>
* ...
* 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<lineNumber>.
*
* 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 <ref>] [--lcov <path>] [--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();
}