Initial commit
This commit is contained in:
211
scripts/coverage/aggregate.mjs
Normal file
211
scripts/coverage/aggregate.mjs
Normal file
@@ -0,0 +1,211 @@
|
||||
#!/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();
|
||||
}
|
||||
Reference in New Issue
Block a user