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>
188 lines
6.5 KiB
JavaScript
188 lines
6.5 KiB
JavaScript
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");
|
|
});
|
|
});
|