Files
agentic-dev/scripts/coverage/aggregate.mjs
Danijel Martinek bd5a077227 feat(coverage): pnpm coverage:aggregate + L2 implementation
Lands L2 of the agent-first coverage architecture (ADR-020) — the
aggregated trend store.

Script: scripts/coverage/aggregate.mjs (zero-dep Node ESM)
  - discoverLcovs: walks packages/* and apps/* for coverage/lcov.info
  - normalizeLcov: rewrites SF entries from package-relative (vitest's
    output) to repo-relative, so the merged file matches git diff paths
  - summarizeLcov: computes statement/branch/function/line percentages
    from LF/LH/BRF/BRH/FNF/FNH summary records
  - aggregate: merges all lcovs and returns mergedLcov + summary
  - Writes coverage/lcov.info (gitignored — large) and
    coverage/summary.json (committed — trend via git log -- ...) with
    timestamp, short commit SHA, repo + per-package percentages

Test surface: scripts/coverage/aggregate.test.mjs (10 tests, all green)
  - Fixtures at __fixtures__/aggregate-pkg-a.lcov +
    aggregate-pkg-b.lcov (synthetic, structured to make percentages
    deterministic)
  - Covers: path normalization (prefix, absolute, double-prefix
    avoidance), summary computation (percentages, zero-division,
    rounding), discovery (packages + apps, missing dirs), full
    aggregation in a tmp repo

Wired:
  - root package.json adds "coverage:aggregate" script
  - .gitignore restructured: per-package coverage/ stays ignored,
    aggregated /coverage/ ignored EXCEPT summary.json (committed for
    trend) and .gitkeep markers

L1 allowlist fix folded in (scripts/coverage/diff.mjs):
  - The previous (^|/)coverage/ regex accidentally caught
    scripts/coverage/* — replaced with anchored patterns
    (^coverage/, ^packages/*/coverage/, ^apps/*/coverage/)
  - Allowlist scripts/ and turbo/generators/ since they're dev tooling
    tested via node --test, outside vitest's v8 lcov pipeline

Smoke-tested end-to-end:
  - pnpm coverage:aggregate merged 3 lcovs (auth + media + navigation
    from this session's earlier runs), repo coverage 95.22% statements
  - pnpm coverage:diff against HEAD~1 with the new merged lcov reports
    PASS — all 6 diff files correctly allowlisted

First committed snapshot of coverage/summary.json lands with this
commit, anchoring the trend history at this state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:10:22 +02:00

212 lines
6.3 KiB
JavaScript

#!/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/<pkg>/` (or `apps/<pkg>/`) 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/<name> 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();
}