coverage-check 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { upsertComment, type GhRunner } from "./github-comment.mts";
3
+ import { COMMENT_MARKER } from "./report.mts";
4
+
5
+ function makeGh(responses: Record<string, string>): GhRunner & ReturnType<typeof vi.fn> {
6
+ const impl: GhRunner = (args) => {
7
+ const key = args.join(" ");
8
+ for (const [pattern, response] of Object.entries(responses)) {
9
+ if (key.includes(pattern)) return Promise.resolve(response);
10
+ }
11
+ return Promise.resolve("");
12
+ };
13
+ return vi.fn<typeof impl>(impl) as GhRunner & ReturnType<typeof vi.fn>;
14
+ }
15
+
16
+ const FAIL_BODY = `${COMMENT_MARKER}\n## failed`;
17
+ const PASS_BODY = `${COMMENT_MARKER}\n## passed`;
18
+
19
+ describe("upsertComment", () => {
20
+ it("posts a new comment on failure when none exists", async () => {
21
+ const gh = makeGh({ "issues/42/comments --paginate": "" });
22
+ await upsertComment(FAIL_BODY, "owner/repo", 42, false, gh);
23
+ const calls = gh.mock.calls.map((c) => c[0].join(" "));
24
+ expect(calls.some((c) => c.includes("issues/42/comments") && !c.includes("PATCH"))).toBe(true);
25
+ });
26
+
27
+ it("patches an existing comment on failure", async () => {
28
+ const gh = makeGh({ "issues/42/comments --paginate": "99\n" });
29
+ await upsertComment(FAIL_BODY, "owner/repo", 42, false, gh);
30
+ const calls = gh.mock.calls.map((c) => c[0].join(" "));
31
+ expect(calls.some((c) => c.includes("comments/99") && c.includes("PATCH"))).toBe(true);
32
+ });
33
+
34
+ it("patches an existing failure comment to pass body", async () => {
35
+ const gh = makeGh({ "issues/42/comments --paginate": "99\n" });
36
+ await upsertComment(PASS_BODY, "owner/repo", 42, true, gh);
37
+ const calls = gh.mock.calls.map((c) => c[0].join(" "));
38
+ expect(calls.some((c) => c.includes("comments/99") && c.includes("PATCH"))).toBe(true);
39
+ });
40
+
41
+ it("does nothing on pass when no prior comment exists", async () => {
42
+ const gh = makeGh({ "issues/42/comments --paginate": "" });
43
+ await upsertComment(PASS_BODY, "owner/repo", 42, true, gh);
44
+ expect(gh.mock.calls.length).toBe(1);
45
+ });
46
+
47
+ it("finds comment id when paginate produces null on first page then id on second", async () => {
48
+ const gh = makeGh({ "issues/42/comments --paginate": "\n99\n" });
49
+ await upsertComment(FAIL_BODY, "owner/repo", 42, false, gh);
50
+ const calls = gh.mock.calls.map((c) => c[0].join(" "));
51
+ expect(calls.some((c) => c.includes("comments/99") && c.includes("PATCH"))).toBe(true);
52
+ });
53
+
54
+ it("falls back to POST and does not throw when comment lookup fails", async () => {
55
+ const lookupErrorGh = vi.fn<GhRunner>((args) =>
56
+ args.includes("--paginate") ? Promise.reject(new Error("API error")) : Promise.resolve(""),
57
+ );
58
+ await expect(
59
+ upsertComment(FAIL_BODY, "owner/repo", 42, false, lookupErrorGh),
60
+ ).resolves.toBeUndefined();
61
+ const calls = lookupErrorGh.mock.calls.map((c) => c[0].join(" "));
62
+ expect(calls.some((c) => c.includes("issues/42/comments") && !c.includes("PATCH"))).toBe(true);
63
+ });
64
+ });
@@ -0,0 +1,34 @@
1
+ import type { LcovData } from "./types.mts";
2
+
3
+ /** Merges multiple LcovData maps by summing hit counts per file per line. */
4
+ export function mergeLcov(reports: LcovData[]): LcovData {
5
+ const merged: LcovData = new Map();
6
+
7
+ for (const report of reports) {
8
+ for (const [file, lines] of report) {
9
+ let target = merged.get(file);
10
+ if (target === undefined) {
11
+ target = new Map();
12
+ merged.set(file, target);
13
+ }
14
+ for (const [lineNo, hits] of lines) {
15
+ target.set(lineNo, (target.get(lineNo) ?? 0) + hits);
16
+ }
17
+ }
18
+ }
19
+
20
+ return merged;
21
+ }
22
+
23
+ /** Serializes LcovData back to LCOV text format. */
24
+ export function toLcov(lcov: LcovData): string {
25
+ const lines: string[] = [];
26
+ for (const [file, fileLines] of lcov) {
27
+ lines.push(`SF:${file}`);
28
+ for (const [lineNo, hits] of fileLines) {
29
+ lines.push(`DA:${lineNo},${hits}`);
30
+ }
31
+ lines.push("end_of_record");
32
+ }
33
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
34
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseLcov } from "./lcov-parser.mts";
3
+ import { mergeLcov, toLcov } from "./lcov-merge.mts";
4
+
5
+ describe("mergeLcov", () => {
6
+ it("merges two reports by summing hit counts", () => {
7
+ const a = parseLcov(`SF:web/foo.mts\nDA:1,1\nDA:2,0\nend_of_record\n`);
8
+ const b = parseLcov(`SF:web/foo.mts\nDA:1,2\nDA:3,1\nend_of_record\n`);
9
+ const merged = mergeLcov([a, b]);
10
+ const lines = merged.get("web/foo.mts")!;
11
+ expect(lines.get(1)).toBe(3);
12
+ expect(lines.get(2)).toBe(0);
13
+ expect(lines.get(3)).toBe(1);
14
+ });
15
+
16
+ it("includes files present in only one report", () => {
17
+ const a = parseLcov(`SF:backend/a.mts\nDA:1,1\nend_of_record\n`);
18
+ const b = parseLcov(`SF:backend/b.mts\nDA:2,1\nend_of_record\n`);
19
+ const merged = mergeLcov([a, b]);
20
+ expect(merged.has("backend/a.mts")).toBe(true);
21
+ expect(merged.has("backend/b.mts")).toBe(true);
22
+ });
23
+
24
+ it("handles a shard that did not execute a given file", () => {
25
+ const a = parseLcov(`SF:backend/service.mts\nDA:10,1\nend_of_record\n`);
26
+ const b = parseLcov(`SF:backend/other.mts\nDA:1,1\nend_of_record\n`);
27
+ const merged = mergeLcov([a, b]);
28
+ expect(merged.get("backend/service.mts")?.get(10)).toBe(1);
29
+ });
30
+
31
+ it("returns empty map for empty input", () => {
32
+ expect(mergeLcov([])).toEqual(new Map());
33
+ });
34
+ });
35
+
36
+ describe("toLcov", () => {
37
+ it("serializes an empty map to an empty string", () => {
38
+ expect(toLcov(new Map())).toBe("");
39
+ });
40
+
41
+ it("round-trips through parseLcov", () => {
42
+ const original = parseLcov(`SF:backend/foo.mts\nDA:1,2\nDA:3,0\nend_of_record\n`);
43
+ const text = toLcov(original);
44
+ const roundTripped = parseLcov(text);
45
+ expect(roundTripped.get("backend/foo.mts")?.get(1)).toBe(2);
46
+ expect(roundTripped.get("backend/foo.mts")?.get(3)).toBe(0);
47
+ });
48
+
49
+ it("serializes multiple files", () => {
50
+ const data = parseLcov(`SF:a.mts\nDA:1,1\nend_of_record\nSF:b.mts\nDA:2,3\nend_of_record\n`);
51
+ const text = toLcov(data);
52
+ expect(text).toContain("SF:a.mts");
53
+ expect(text).toContain("SF:b.mts");
54
+ expect(text).toContain("DA:1,1");
55
+ expect(text).toContain("DA:2,3");
56
+ });
57
+ });
@@ -0,0 +1,46 @@
1
+ import type { LcovData } from "./types.mts";
2
+
3
+ /**
4
+ * Parses LCOV text into a map of repo-root-relative file path → line → hit count.
5
+ *
6
+ * Paths are normalized by stripping a given prefix (e.g. $GITHUB_WORKSPACE or cwd)
7
+ * so callers see repo-root-relative paths regardless of where the runner ran.
8
+ */
9
+ export function parseLcov(text: string, stripPrefixes: string[] = []): LcovData {
10
+ const result: LcovData = new Map();
11
+ let currentLines: Map<number, number> | null = null;
12
+
13
+ for (const raw of text.split("\n")) {
14
+ const line = raw.trimEnd();
15
+
16
+ if (line.startsWith("SF:")) {
17
+ let path = line.slice(3);
18
+ for (const prefix of stripPrefixes) {
19
+ if (path.startsWith(prefix)) {
20
+ path = path.slice(prefix.length);
21
+ break;
22
+ }
23
+ }
24
+ path = normalizePath(path);
25
+ currentLines = result.get(path) ?? new Map();
26
+ result.set(path, currentLines);
27
+ } else if (line.startsWith("DA:") && currentLines !== null) {
28
+ const rest = line.slice(3);
29
+ const comma = rest.indexOf(",");
30
+ if (comma === -1) continue;
31
+ const lineNo = parseInt(rest.slice(0, comma), 10);
32
+ const hits = parseInt(rest.slice(comma + 1), 10);
33
+ if (!Number.isFinite(lineNo) || !Number.isFinite(hits)) continue;
34
+ const prev = currentLines.get(lineNo) ?? 0;
35
+ currentLines.set(lineNo, prev + hits);
36
+ } else if (line === "end_of_record") {
37
+ currentLines = null;
38
+ }
39
+ }
40
+
41
+ return result;
42
+ }
43
+
44
+ function normalizePath(p: string): string {
45
+ return p.replace(/\\/g, "/").replace(/^\.\//, "");
46
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseLcov } from "./lcov-parser.mts";
3
+
4
+ const SIMPLE_LCOV = `
5
+ SF:web/components/Foo.tsx
6
+ DA:1,1
7
+ DA:2,0
8
+ DA:3,1
9
+ end_of_record
10
+ SF:web/components/Bar.tsx
11
+ DA:10,2
12
+ end_of_record
13
+ `;
14
+
15
+ const ABSOLUTE_LCOV = `
16
+ SF:/home/runner/work/repo/repo/web/lib/api/client.mts
17
+ DA:5,1
18
+ DA:6,0
19
+ end_of_record
20
+ `;
21
+
22
+ const WINDOWS_LCOV = `
23
+ SF:.\\backend\\services\\foo.mts
24
+ DA:1,1
25
+ end_of_record
26
+ `;
27
+
28
+ describe("parseLcov", () => {
29
+ it("parses basic SF and DA records", () => {
30
+ const result = parseLcov(SIMPLE_LCOV);
31
+ expect(result.get("web/components/Foo.tsx")?.get(1)).toBe(1);
32
+ expect(result.get("web/components/Foo.tsx")?.get(2)).toBe(0);
33
+ expect(result.get("web/components/Bar.tsx")?.get(10)).toBe(2);
34
+ });
35
+
36
+ it("strips provided prefix from absolute paths", () => {
37
+ const result = parseLcov(ABSOLUTE_LCOV, ["/home/runner/work/repo/repo/"]);
38
+ expect(result.has("web/lib/api/client.mts")).toBe(true);
39
+ expect(result.get("web/lib/api/client.mts")?.get(5)).toBe(1);
40
+ });
41
+
42
+ it("normalizes Windows backslash separators", () => {
43
+ const result = parseLcov(WINDOWS_LCOV);
44
+ expect(result.has("backend/services/foo.mts")).toBe(true);
45
+ });
46
+
47
+ it("strips leading ./ from SF paths", () => {
48
+ const lcov = `SF:./web/components/Baz.tsx\nDA:1,1\nend_of_record\n`;
49
+ const result = parseLcov(lcov);
50
+ expect(result.has("web/components/Baz.tsx")).toBe(true);
51
+ });
52
+
53
+ it("handles missing end_of_record gracefully", () => {
54
+ const lcov = `SF:web/foo.mts\nDA:1,1\n`;
55
+ const result = parseLcov(lcov);
56
+ expect(result.get("web/foo.mts")?.get(1)).toBe(1);
57
+ });
58
+
59
+ it("sums hits when the same file appears multiple times (shard merge)", () => {
60
+ const lcov = `
61
+ SF:web/components/Foo.tsx
62
+ DA:1,2
63
+ end_of_record
64
+ SF:web/components/Foo.tsx
65
+ DA:1,3
66
+ DA:2,1
67
+ end_of_record
68
+ `;
69
+ const result = parseLcov(lcov);
70
+ expect(result.get("web/components/Foo.tsx")?.get(1)).toBe(5);
71
+ expect(result.get("web/components/Foo.tsx")?.get(2)).toBe(1);
72
+ });
73
+
74
+ it("skips DA lines with malformed content (no comma)", () => {
75
+ const lcov = `SF:web/foo.mts\nDA:badline\nDA:1,1\nend_of_record\n`;
76
+ const result = parseLcov(lcov);
77
+ expect(result.get("web/foo.mts")?.get(1)).toBe(1);
78
+ expect(result.get("web/foo.mts")?.size).toBe(1);
79
+ });
80
+
81
+ it("skips DA lines with non-finite values", () => {
82
+ const lcov = `SF:web/foo.mts\nDA:NaN,1\nDA:1,1\nend_of_record\n`;
83
+ const result = parseLcov(lcov);
84
+ expect(result.get("web/foo.mts")?.get(1)).toBe(1);
85
+ });
86
+ });
@@ -0,0 +1,42 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** Recursively collects all lcov.info files under the given directory. */
5
+ export function collectLcovFiles(dir: string): string[] {
6
+ const results: string[] = [];
7
+ try {
8
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
9
+ const full = join(dir, entry.name);
10
+ if (entry.isDirectory()) {
11
+ results.push(...collectLcovFiles(full));
12
+ } else if (entry.name === "lcov.info") {
13
+ results.push(full);
14
+ }
15
+ }
16
+ } catch (err) {
17
+ /* c8 ignore next */
18
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT")
19
+ /* c8 ignore next */
20
+ process.stderr.write(
21
+ `coverage-check: unexpected error reading artifacts directory ${dir}: ${err}\n`,
22
+ );
23
+ // ENOENT: directory does not exist — no artifacts
24
+ }
25
+ return results;
26
+ }
27
+
28
+ /**
29
+ * Builds the list of path prefixes to strip from LCOV SF: lines.
30
+ *
31
+ * Defaults: $GITHUB_WORKSPACE (if set) and cwd are always prepended.
32
+ * Additional prefixes can be passed via the `extra` parameter.
33
+ */
34
+ export function buildStripPrefixes(extra: string[] = []): string[] {
35
+ const prefixes: string[] = extra.map((p) => (p.endsWith("/") ? p : `${p}/`));
36
+ const ws = process.env["GITHUB_WORKSPACE"];
37
+ if (ws) prefixes.push(ws.endsWith("/") ? ws : `${ws}/`);
38
+ const cwd = process.cwd();
39
+ /* c8 ignore next -- process.cwd() virtually never returns a trailing-slash path */
40
+ prefixes.push(cwd.endsWith("/") ? cwd : `${cwd}/`);
41
+ return prefixes;
42
+ }
@@ -0,0 +1,115 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { collectLcovFiles, buildStripPrefixes } from "./load-artifacts.mts";
6
+
7
+ describe("collectLcovFiles", () => {
8
+ let tmpDir: string;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = mkdtempSync(join(tmpdir(), "load-artifacts-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ rmSync(tmpDir, { recursive: true, force: true });
16
+ });
17
+
18
+ it("returns empty array for missing directory (ENOENT silenced)", () => {
19
+ expect(collectLcovFiles(join(tmpDir, "nonexistent"))).toEqual([]);
20
+ });
21
+
22
+ it("returns empty array for empty directory", () => {
23
+ expect(collectLcovFiles(tmpDir)).toEqual([]);
24
+ });
25
+
26
+ it("finds lcov.info at the top level", () => {
27
+ writeFileSync(join(tmpDir, "lcov.info"), "");
28
+ const results = collectLcovFiles(tmpDir);
29
+ expect(results).toHaveLength(1);
30
+ expect(results[0]).toContain("lcov.info");
31
+ });
32
+
33
+ it("ignores non-lcov.info files", () => {
34
+ writeFileSync(join(tmpDir, "coverage.json"), "");
35
+ writeFileSync(join(tmpDir, "lcov.txt"), "");
36
+ expect(collectLcovFiles(tmpDir)).toHaveLength(0);
37
+ });
38
+
39
+ it("recursively finds lcov.info in subdirectories", () => {
40
+ const sub1 = join(tmpDir, "backend");
41
+ const sub2 = join(tmpDir, "backend", "nested");
42
+ mkdirSync(sub2, { recursive: true });
43
+ writeFileSync(join(sub1, "lcov.info"), "");
44
+ writeFileSync(join(sub2, "lcov.info"), "");
45
+ const results = collectLcovFiles(tmpDir);
46
+ expect(results).toHaveLength(2);
47
+ });
48
+
49
+ it("finds lcov.info in multiple sibling subdirectories", () => {
50
+ mkdirSync(join(tmpDir, "backend"));
51
+ mkdirSync(join(tmpDir, "frontend"));
52
+ writeFileSync(join(tmpDir, "backend", "lcov.info"), "");
53
+ writeFileSync(join(tmpDir, "frontend", "lcov.info"), "");
54
+ expect(collectLcovFiles(tmpDir)).toHaveLength(2);
55
+ });
56
+ });
57
+
58
+ describe("buildStripPrefixes", () => {
59
+ it("always includes cwd as a suffix-slash prefix", () => {
60
+ const prefixes = buildStripPrefixes();
61
+ const cwd = process.cwd();
62
+ const expected = cwd.endsWith("/") ? cwd : `${cwd}/`;
63
+ expect(prefixes).toContain(expected);
64
+ });
65
+
66
+ it("includes GITHUB_WORKSPACE when set", () => {
67
+ const origWs = process.env["GITHUB_WORKSPACE"];
68
+ process.env["GITHUB_WORKSPACE"] = "/home/runner/work/repo";
69
+ try {
70
+ const prefixes = buildStripPrefixes();
71
+ expect(prefixes.some((p) => p.startsWith("/home/runner/work/repo"))).toBe(true);
72
+ } finally {
73
+ if (origWs === undefined) {
74
+ delete process.env["GITHUB_WORKSPACE"];
75
+ } else {
76
+ process.env["GITHUB_WORKSPACE"] = origWs;
77
+ }
78
+ }
79
+ });
80
+
81
+ it("does not include github workspace prefix when env var is absent", () => {
82
+ const origWs = process.env["GITHUB_WORKSPACE"];
83
+ delete process.env["GITHUB_WORKSPACE"];
84
+ try {
85
+ const prefixes = buildStripPrefixes();
86
+ // Only cwd and extra prefixes should be present
87
+ expect(prefixes).toHaveLength(1);
88
+ } finally {
89
+ if (origWs !== undefined) {
90
+ process.env["GITHUB_WORKSPACE"] = origWs;
91
+ }
92
+ }
93
+ });
94
+
95
+ it("normalizes extra prefixes to end with /", () => {
96
+ const prefixes = buildStripPrefixes(["/some/path", "/other/path/"]);
97
+ expect(prefixes[0]).toBe("/some/path/");
98
+ expect(prefixes[1]).toBe("/other/path/");
99
+ });
100
+
101
+ it("includes GITHUB_WORKSPACE with trailing slash when it already ends with /", () => {
102
+ const origWs = process.env["GITHUB_WORKSPACE"];
103
+ process.env["GITHUB_WORKSPACE"] = "/home/runner/work/repo/";
104
+ try {
105
+ const prefixes = buildStripPrefixes();
106
+ expect(prefixes.some((p) => p === "/home/runner/work/repo/")).toBe(true);
107
+ } finally {
108
+ if (origWs === undefined) {
109
+ delete process.env["GITHUB_WORKSPACE"];
110
+ } else {
111
+ process.env["GITHUB_WORKSPACE"] = origWs;
112
+ }
113
+ }
114
+ });
115
+ });
@@ -0,0 +1,82 @@
1
+ import type {
2
+ BucketResult,
3
+ CoverageRule,
4
+ DiffLines,
5
+ FileCoverageResult,
6
+ LcovData,
7
+ } from "./types.mts";
8
+ import { matchRule } from "./rules.mts";
9
+
10
+ export function computePatchCoverage(
11
+ diff: DiffLines,
12
+ lcov: LcovData,
13
+ rules: CoverageRule[],
14
+ ): { buckets: BucketResult[]; informational: FileCoverageResult[] } {
15
+ const bucketMap = new Map<string, BucketResult>();
16
+ const informational: FileCoverageResult[] = [];
17
+
18
+ for (const [file, changedLineSet] of diff) {
19
+ const fileLines = lcov.get(file);
20
+ if (fileLines === undefined) continue; // not in lcov scope — skip
21
+
22
+ const coverable: number[] = [];
23
+ const uncoveredLines: number[] = [];
24
+ let hit = 0;
25
+
26
+ for (const lineNo of changedLineSet) {
27
+ if (!fileLines.has(lineNo)) continue; // line not tracked by coverage
28
+ coverable.push(lineNo);
29
+ if (fileLines.get(lineNo)! > 0) {
30
+ hit++;
31
+ } else {
32
+ uncoveredLines.push(lineNo);
33
+ }
34
+ }
35
+
36
+ if (coverable.length === 0) continue;
37
+
38
+ const rule = matchRule(file, rules);
39
+ const fileResult: FileCoverageResult = {
40
+ file,
41
+ coverable: coverable.length,
42
+ hit,
43
+ uncoveredLines: uncoveredLines.sort((a, b) => a - b),
44
+ rule: rule?.paths ?? null,
45
+ };
46
+
47
+ if (rule === null) {
48
+ informational.push(fileResult);
49
+ continue;
50
+ }
51
+
52
+ let bucket = bucketMap.get(rule.paths);
53
+ if (bucket === undefined) {
54
+ bucket = {
55
+ rule: rule.paths,
56
+ threshold: rule.patch_coverage_min,
57
+ coverable: 0,
58
+ hit: 0,
59
+ files: [],
60
+ passed: true,
61
+ };
62
+ bucketMap.set(rule.paths, bucket);
63
+ }
64
+
65
+ bucket.coverable += coverable.length;
66
+ bucket.hit += hit;
67
+ bucket.files.push(fileResult);
68
+ }
69
+
70
+ const buckets = [...bucketMap.values()];
71
+ for (const bucket of buckets) {
72
+ /* c8 ignore next -- bucket always has coverable>0 (L36 guard prevents empty-coverable files from reaching bucketMap) */
73
+ if (bucket.coverable === 0) {
74
+ bucket.passed = true;
75
+ } else {
76
+ const pct = (bucket.hit / bucket.coverable) * 100;
77
+ bucket.passed = pct >= bucket.threshold;
78
+ }
79
+ }
80
+
81
+ return { buckets, informational };
82
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseLcov } from "./lcov-parser.mts";
3
+ import { parseDiff } from "./diff-parser.mts";
4
+ import { computePatchCoverage } from "./patch-coverage.mts";
5
+ import type { CoverageRule } from "./types.mts";
6
+
7
+ const rules: CoverageRule[] = [
8
+ { paths: "backend/**", patch_coverage_min: 90 },
9
+ { paths: "web/**", patch_coverage_min: 5 },
10
+ ];
11
+
12
+ describe("computePatchCoverage", () => {
13
+ it("passes when all changed lines are covered", () => {
14
+ const lcov = parseLcov(`SF:backend/foo.mts\nDA:1,1\nDA:2,1\nend_of_record\n`);
15
+ const diff: ReturnType<typeof parseDiff> = new Map([["backend/foo.mts", new Set([1, 2])]]);
16
+ const { buckets } = computePatchCoverage(diff, lcov, rules);
17
+ const bucket = buckets.find((b) => b.rule === "backend/**")!;
18
+ expect(bucket.passed).toBe(true);
19
+ expect(bucket.hit).toBe(2);
20
+ expect(bucket.coverable).toBe(2);
21
+ });
22
+
23
+ it("fails when coverage drops below threshold", () => {
24
+ const lcov = parseLcov(
25
+ `SF:backend/foo.mts\nDA:1,1\nDA:2,1\nDA:3,1\nDA:4,1\nDA:5,1\nDA:6,1\nDA:7,1\nDA:8,1\nDA:9,0\nDA:10,0\nend_of_record\n`,
26
+ );
27
+ const diff: ReturnType<typeof parseDiff> = new Map([
28
+ ["backend/foo.mts", new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])],
29
+ ]);
30
+ const { buckets } = computePatchCoverage(diff, lcov, rules);
31
+ const bucket = buckets.find((b) => b.rule === "backend/**")!;
32
+ expect(bucket.passed).toBe(false);
33
+ expect(bucket.hit).toBe(8);
34
+ expect(bucket.coverable).toBe(10);
35
+ });
36
+
37
+ it("passes vacuously when no coverable changed lines", () => {
38
+ const lcov = parseLcov(`SF:backend/foo.mts\nDA:1,1\nend_of_record\n`);
39
+ const diff: ReturnType<typeof parseDiff> = new Map([["backend/foo.mts", new Set([99])]]);
40
+ const { buckets } = computePatchCoverage(diff, lcov, rules);
41
+ const bucket = buckets.find((b) => b.rule === "backend/**");
42
+ expect(!bucket || bucket.passed).toBe(true);
43
+ });
44
+
45
+ it("skips files not in lcov", () => {
46
+ const lcov = parseLcov(`SF:backend/other.mts\nDA:1,1\nend_of_record\n`);
47
+ const diff: ReturnType<typeof parseDiff> = new Map([
48
+ ["backend/missing.mts", new Set([1, 2, 3])],
49
+ ]);
50
+ const { buckets, informational } = computePatchCoverage(diff, lcov, rules);
51
+ expect(buckets).toHaveLength(0);
52
+ expect(informational).toHaveLength(0);
53
+ });
54
+
55
+ it("routes unmatched files to informational", () => {
56
+ const lcov = parseLcov(`SF:scripts/ci.mts\nDA:1,0\nend_of_record\n`);
57
+ const diff: ReturnType<typeof parseDiff> = new Map([["scripts/ci.mts", new Set([1])]]);
58
+ const { buckets, informational } = computePatchCoverage(diff, lcov, rules);
59
+ expect(buckets).toHaveLength(0);
60
+ expect(informational).toHaveLength(1);
61
+ expect(informational[0]?.file).toBe("scripts/ci.mts");
62
+ });
63
+
64
+ it("accumulates multiple files into the same bucket", () => {
65
+ // Two backend files → both go into the 'backend/**' bucket
66
+ const lcov = parseLcov(
67
+ `SF:backend/a.mts\nDA:1,1\nDA:2,1\nend_of_record\nSF:backend/b.mts\nDA:10,0\nDA:11,0\nend_of_record\n`,
68
+ );
69
+ const diff: ReturnType<typeof parseDiff> = new Map([
70
+ ["backend/a.mts", new Set([1, 2])],
71
+ ["backend/b.mts", new Set([10, 11])],
72
+ ]);
73
+ const { buckets } = computePatchCoverage(diff, lcov, rules);
74
+ const bucket = buckets.find((b) => b.rule === "backend/**")!;
75
+ expect(bucket.coverable).toBe(4);
76
+ expect(bucket.hit).toBe(2);
77
+ expect(bucket.files).toHaveLength(2);
78
+ });
79
+
80
+ it("reports uncovered lines correctly", () => {
81
+ const lcov = parseLcov(
82
+ `SF:backend/svc.mts\nDA:10,1\nDA:11,0\nDA:12,0\nDA:13,1\nend_of_record\n`,
83
+ );
84
+ const diff: ReturnType<typeof parseDiff> = new Map([
85
+ ["backend/svc.mts", new Set([10, 11, 12, 13])],
86
+ ]);
87
+ const { buckets } = computePatchCoverage(diff, lcov, rules);
88
+ const file = buckets[0]?.files[0]!;
89
+ expect(file.uncoveredLines).toEqual([11, 12]);
90
+ });
91
+ });