Files
agentic-dev/scripts/coverage/diff.test.mjs
Danijel Martinek 412d994733 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>
2026-05-13 14:04:09 +02:00

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");
});
});