feat(coverage): pnpm coverage:diff script + L1 implementation
Lands L1 of the agent-first coverage architecture (ADR-020) — the
cover-the-diff gate. Reads a merged lcov + git diff against a base
ref, asserts every changed *executable* line was exercised.
Script: scripts/coverage/diff.mjs (zero-dep Node ESM)
- parseLcov: SF -> Map<line, count>; only DA records read
- parseGitDiff: parses --unified=0 output into Map<file, Set<line>>
- computeDiffCoverage: cross-references both, emits result tree
- Allowlist of paths that don't gate (tests, configs, docs, .sh,
DI bootstrap, interfaces, CMS, factories, contracts, UI)
- Path matching handles three lcov path conventions: absolute,
repo-relative, and per-package relative
- CLI flags: --base (default origin/main), --lcov (default
coverage/lcov.info), --json (suppress stderr summary)
- stdout: machine-readable JSON for the dispatch loop
- stderr: human summary
- Exit 0 on pass, 1 on fail or error
Test surface: scripts/coverage/diff.test.mjs (14 tests, all green)
- Fixtures at scripts/coverage/__fixtures__/{sample.lcov,sample-diff.patch}
- Covers: lcov parsing, diff parsing, pass path, uncovered lines,
non-executable line skipping, no-coverage-data detection,
allowlist filtering, end-to-end mixed case, path matching
Wired:
- root package.json adds "coverage:diff" script
- .gitignore anchored so per-package coverage/ stays ignored but
scripts/coverage/ stays tracked
Smoke-tested end-to-end against packages/auth/coverage/lcov.info —
correctly skips shell scripts + manifest files (via allowlist + path
suffix match), correctly flags files not present in the per-package
lcov (which is expected; full repo coverage needs the L2 aggregate
that the next story lands).
CI integration deferred to the L2 aggregate story (the merged
coverage/lcov.info this script reads doesn't exist yet — pnpm
coverage:aggregate produces it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -21,8 +21,12 @@ storybook-static
|
|||||||
*.local.*
|
*.local.*
|
||||||
**/settings.local.json
|
**/settings.local.json
|
||||||
|
|
||||||
# Testing
|
# Testing — per-package and aggregated coverage output (vitest + coverage:aggregate)
|
||||||
coverage
|
/coverage/
|
||||||
|
packages/*/coverage/
|
||||||
|
apps/*/coverage/
|
||||||
|
# But keep the source scripts at scripts/coverage/ tracked
|
||||||
|
!/scripts/coverage/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"test:visual": "pnpm --filter @repo/storybook exec concurrently -k -s first -n 'SB,VRT' -c 'magenta,blue' 'pnpm --filter @repo/storybook exec http-server storybook-static --port 6006 --silent' 'pnpm --filter @repo/storybook exec wait-on tcp:6006 && pnpm exec playwright test'",
|
"test:visual": "pnpm --filter @repo/storybook exec concurrently -k -s first -n 'SB,VRT' -c 'magenta,blue' 'pnpm --filter @repo/storybook exec http-server storybook-static --port 6006 --silent' 'pnpm --filter @repo/storybook exec wait-on tcp:6006 && pnpm exec playwright test'",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"conformance": "node scripts/conformance.mjs",
|
"conformance": "node scripts/conformance.mjs",
|
||||||
|
"coverage:diff": "node scripts/coverage/diff.mjs",
|
||||||
"fallow": "fallow",
|
"fallow": "fallow",
|
||||||
"fallow:audit": "fallow audit --base main",
|
"fallow:audit": "fallow audit --base main",
|
||||||
"work": "node scripts/work/cli.mjs",
|
"work": "node scripts/work/cli.mjs",
|
||||||
|
|||||||
49
scripts/coverage/__fixtures__/sample-diff.patch
Normal file
49
scripts/coverage/__fixtures__/sample-diff.patch
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
diff --git a/packages/auth/src/application/use-cases/sign-in.use-case.ts b/packages/auth/src/application/use-cases/sign-in.use-case.ts
|
||||||
|
index abc..def 100644
|
||||||
|
--- a/packages/auth/src/application/use-cases/sign-in.use-case.ts
|
||||||
|
+++ b/packages/auth/src/application/use-cases/sign-in.use-case.ts
|
||||||
|
@@ -1,0 +2,2 @@
|
||||||
|
+const a = 1;
|
||||||
|
+const b = 2;
|
||||||
|
@@ -4,1 +5,2 @@
|
||||||
|
-old
|
||||||
|
+const c = 3;
|
||||||
|
+const d = 4;
|
||||||
|
diff --git a/packages/auth/src/application/use-cases/sign-in.use-case.test.ts b/packages/auth/src/application/use-cases/sign-in.use-case.test.ts
|
||||||
|
index abc..def 100644
|
||||||
|
--- a/packages/auth/src/application/use-cases/sign-in.use-case.test.ts
|
||||||
|
+++ b/packages/auth/src/application/use-cases/sign-in.use-case.test.ts
|
||||||
|
@@ -10,0 +11 @@
|
||||||
|
+new test line
|
||||||
|
diff --git a/packages/auth/src/entities/models/user.ts b/packages/auth/src/entities/models/user.ts
|
||||||
|
index abc..def 100644
|
||||||
|
--- a/packages/auth/src/entities/models/user.ts
|
||||||
|
+++ b/packages/auth/src/entities/models/user.ts
|
||||||
|
@@ -2,1 +2,1 @@
|
||||||
|
-old
|
||||||
|
+modified
|
||||||
|
diff --git a/packages/blog/src/application/use-cases/get-article.use-case.ts b/packages/blog/src/application/use-cases/get-article.use-case.ts
|
||||||
|
index abc..def 100644
|
||||||
|
--- a/packages/blog/src/application/use-cases/get-article.use-case.ts
|
||||||
|
+++ b/packages/blog/src/application/use-cases/get-article.use-case.ts
|
||||||
|
@@ -11,0 +12 @@
|
||||||
|
+const uncovered = true;
|
||||||
|
diff --git a/packages/media/src/application/use-cases/upload.use-case.ts b/packages/media/src/application/use-cases/upload.use-case.ts
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..abc
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/packages/media/src/application/use-cases/upload.use-case.ts
|
||||||
|
@@ -0,0 +1,5 @@
|
||||||
|
+export const uploadUseCase = () => {
|
||||||
|
+ return "uploaded";
|
||||||
|
+};
|
||||||
|
+// Line 4
|
||||||
|
+// Line 5
|
||||||
|
diff --git a/CLAUDE.md b/CLAUDE.md
|
||||||
|
index abc..def 100644
|
||||||
|
--- a/CLAUDE.md
|
||||||
|
+++ b/CLAUDE.md
|
||||||
|
@@ -1,1 +1,2 @@
|
||||||
|
-old text
|
||||||
|
+new text
|
||||||
|
+more text
|
||||||
25
scripts/coverage/__fixtures__/sample.lcov
Normal file
25
scripts/coverage/__fixtures__/sample.lcov
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
TN:
|
||||||
|
SF:/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts
|
||||||
|
DA:1,5
|
||||||
|
DA:2,5
|
||||||
|
DA:3,5
|
||||||
|
DA:5,0
|
||||||
|
DA:6,0
|
||||||
|
DA:8,3
|
||||||
|
LF:6
|
||||||
|
LH:4
|
||||||
|
end_of_record
|
||||||
|
SF:/repo/packages/auth/src/entities/models/user.ts
|
||||||
|
DA:1,1
|
||||||
|
DA:2,1
|
||||||
|
DA:3,1
|
||||||
|
LF:3
|
||||||
|
LH:3
|
||||||
|
end_of_record
|
||||||
|
SF:/repo/packages/blog/src/application/use-cases/get-article.use-case.ts
|
||||||
|
DA:10,2
|
||||||
|
DA:11,2
|
||||||
|
DA:12,0
|
||||||
|
LF:3
|
||||||
|
LH:2
|
||||||
|
end_of_record
|
||||||
316
scripts/coverage/diff.mjs
Normal file
316
scripts/coverage/diff.mjs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
#!/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$/,
|
||||||
|
/\.npmrc$/,
|
||||||
|
// Shell scripts (not Vitest-covered)
|
||||||
|
/\.sh$/,
|
||||||
|
/\.bash$/,
|
||||||
|
// 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)
|
||||||
|
/\.interface\.ts$/,
|
||||||
|
/\/index\.ts$/, // barrel re-exports — no executable code
|
||||||
|
// Build artifacts
|
||||||
|
/\.tsbuildinfo$/,
|
||||||
|
/\.lock$/,
|
||||||
|
/(^|\/)dist\//,
|
||||||
|
/(^|\/)\.next\//,
|
||||||
|
/(^|\/)\.turbo\//,
|
||||||
|
/(^|\/)node_modules\//,
|
||||||
|
/(^|\/)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();
|
||||||
|
}
|
||||||
187
scripts/coverage/diff.test.mjs
Normal file
187
scripts/coverage/diff.test.mjs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { test, describe } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { parseLcov, parseGitDiff, computeDiffCoverage } from "./diff.mjs";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const FIXTURES = path.join(__dirname, "__fixtures__");
|
||||||
|
|
||||||
|
const lcovText = fs.readFileSync(path.join(FIXTURES, "sample.lcov"), "utf8");
|
||||||
|
const diffText = fs.readFileSync(
|
||||||
|
path.join(FIXTURES, "sample-diff.patch"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("parseLcov", () => {
|
||||||
|
test("groups DA records by SF file", () => {
|
||||||
|
const lcov = parseLcov(lcovText);
|
||||||
|
assert.equal(lcov.size, 3);
|
||||||
|
assert.ok(
|
||||||
|
lcov.has(
|
||||||
|
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(lcov.has("/repo/packages/auth/src/entities/models/user.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves per-line execution counts", () => {
|
||||||
|
const lcov = parseLcov(lcovText);
|
||||||
|
const lines = lcov.get(
|
||||||
|
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||||
|
);
|
||||||
|
assert.equal(lines.get(1), 5);
|
||||||
|
assert.equal(lines.get(5), 0);
|
||||||
|
assert.equal(lines.get(8), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores non-DA records (LF, LH, BRDA)", () => {
|
||||||
|
const lcov = parseLcov(lcovText);
|
||||||
|
const lines = lcov.get("/repo/packages/auth/src/entities/models/user.ts");
|
||||||
|
assert.equal(lines.size, 3); // Only DA records, not LF/LH counters
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseGitDiff", () => {
|
||||||
|
test("extracts new + modified line numbers per file from the new version", () => {
|
||||||
|
const diff = parseGitDiff(diffText);
|
||||||
|
const signIn = diff.get(
|
||||||
|
"packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||||
|
);
|
||||||
|
// Hunks: +2,2 (lines 2,3) and +5,2 (lines 5,6)
|
||||||
|
assert.deepEqual(
|
||||||
|
[...signIn].sort((a, b) => a - b),
|
||||||
|
[2, 3, 5, 6],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strips the b/ prefix from new-version paths", () => {
|
||||||
|
const diff = parseGitDiff(diffText);
|
||||||
|
assert.ok(diff.has("packages/auth/src/entities/models/user.ts"));
|
||||||
|
assert.ok(!diff.has("b/packages/auth/src/entities/models/user.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles single-line hunks (no comma in +N,M)", () => {
|
||||||
|
const diff = parseGitDiff(diffText);
|
||||||
|
const blog = diff.get(
|
||||||
|
"packages/blog/src/application/use-cases/get-article.use-case.ts",
|
||||||
|
);
|
||||||
|
// @@ -11,0 +12 @@ -> single line at 12
|
||||||
|
assert.deepEqual([...blog], [12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles new files (entire file is in the diff)", () => {
|
||||||
|
const diff = parseGitDiff(diffText);
|
||||||
|
const upload = diff.get(
|
||||||
|
"packages/media/src/application/use-cases/upload.use-case.ts",
|
||||||
|
);
|
||||||
|
// @@ -0,0 +1,5 @@ -> lines 1-5
|
||||||
|
assert.deepEqual(
|
||||||
|
[...upload].sort((a, b) => a - b),
|
||||||
|
[1, 2, 3, 4, 5],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeDiffCoverage", () => {
|
||||||
|
test("passes when changed executable lines are all covered", () => {
|
||||||
|
const lcov = parseLcov(lcovText);
|
||||||
|
const diff = new Map([
|
||||||
|
["/repo/packages/auth/src/entities/models/user.ts", new Set([1, 2, 3])],
|
||||||
|
]);
|
||||||
|
const result = computeDiffCoverage(diff, lcov);
|
||||||
|
assert.equal(result.status, "pass");
|
||||||
|
assert.equal(result.uncovered.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails when a changed line is in lcov with count 0", () => {
|
||||||
|
const lcov = parseLcov(lcovText);
|
||||||
|
const diff = new Map([
|
||||||
|
[
|
||||||
|
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||||
|
new Set([5, 6]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const result = computeDiffCoverage(diff, lcov);
|
||||||
|
assert.equal(result.status, "fail");
|
||||||
|
assert.equal(result.uncovered.length, 2);
|
||||||
|
assert.deepEqual(result.uncovered.map((u) => u.line).sort(), [5, 6]);
|
||||||
|
assert.ok(result.uncovered.every((u) => u.kind === "uncovered"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores lines without DA records (non-executable)", () => {
|
||||||
|
const lcov = parseLcov(lcovText);
|
||||||
|
// Line 4 has no DA record in the fixture
|
||||||
|
const diff = new Map([
|
||||||
|
[
|
||||||
|
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||||
|
new Set([4]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const result = computeDiffCoverage(diff, lcov);
|
||||||
|
assert.equal(result.status, "pass");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flags files with no coverage data (new untested file)", () => {
|
||||||
|
const lcov = parseLcov(lcovText);
|
||||||
|
const diff = new Map([
|
||||||
|
[
|
||||||
|
"packages/media/src/application/use-cases/upload.use-case.ts",
|
||||||
|
new Set([1, 2, 3, 4, 5]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const result = computeDiffCoverage(diff, lcov);
|
||||||
|
assert.equal(result.status, "fail");
|
||||||
|
assert.equal(result.uncovered.length, 1);
|
||||||
|
assert.equal(result.uncovered[0].kind, "no-coverage-data");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips allowed extensions (.md, .json, .test.ts, configs)", () => {
|
||||||
|
const lcov = parseLcov(lcovText);
|
||||||
|
const diff = new Map([
|
||||||
|
["CLAUDE.md", new Set([1, 2])],
|
||||||
|
["package.json", new Set([1])],
|
||||||
|
["packages/auth/vitest.config.ts", new Set([1])],
|
||||||
|
[
|
||||||
|
"packages/auth/src/application/use-cases/sign-in.use-case.test.ts",
|
||||||
|
new Set([11]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const result = computeDiffCoverage(diff, lcov);
|
||||||
|
assert.equal(result.status, "pass");
|
||||||
|
assert.equal(result.summary.filesGated, 0);
|
||||||
|
assert.equal(result.summary.filesChanged, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("end-to-end fixture: mixed pass/fail/skip/no-data", () => {
|
||||||
|
const lcov = parseLcov(lcovText);
|
||||||
|
const diff = parseGitDiff(diffText);
|
||||||
|
const result = computeDiffCoverage(diff, lcov, { repoRoot: "/repo" });
|
||||||
|
|
||||||
|
assert.equal(result.status, "fail");
|
||||||
|
// CLAUDE.md, sign-in.use-case.test.ts -> skipped (allowlist)
|
||||||
|
// sign-in.use-case.ts lines 2,3,5,6 -> 5,6 are uncovered, 2,3 don't have DA records (not in lcov for those lines) so they don't count
|
||||||
|
// user.ts line 2 -> covered (count: 1)
|
||||||
|
// get-article.use-case.ts line 12 -> uncovered (count: 0)
|
||||||
|
// upload.use-case.ts -> no coverage data
|
||||||
|
// The expected uncovered set: sign-in lines 5,6 + get-article line 12 + upload (no-data)
|
||||||
|
const uncoveredKinds = result.uncovered.map((u) => u.kind);
|
||||||
|
assert.ok(uncoveredKinds.includes("no-coverage-data"));
|
||||||
|
assert.ok(uncoveredKinds.includes("uncovered"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves repo-relative diff paths against lcov absolute paths", () => {
|
||||||
|
const lcov = parseLcov(lcovText);
|
||||||
|
const diff = new Map([
|
||||||
|
// Diff uses repo-relative; lcov has absolute. Suffix match should
|
||||||
|
// bridge them.
|
||||||
|
[
|
||||||
|
"packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||||
|
new Set([8]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const result = computeDiffCoverage(diff, lcov, { repoRoot: "/repo" });
|
||||||
|
assert.equal(result.status, "pass");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user