coverage-check 0.2.1 → 0.2.2

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.
Files changed (74) hide show
  1. package/README.md +9 -5
  2. package/bin/coverage-check.mjs +4 -0
  3. package/dist/src/cli.d.mts +1 -0
  4. package/dist/src/cli.mjs +14 -0
  5. package/dist/src/commands/check-args.d.mts +20 -0
  6. package/dist/src/commands/check-args.mjs +89 -0
  7. package/dist/src/commands/check.d.mts +4 -0
  8. package/dist/src/commands/check.mjs +128 -0
  9. package/dist/src/commands/store-put.d.mts +11 -0
  10. package/dist/src/commands/store-put.mjs +104 -0
  11. package/{src/coverage-check.mts → dist/src/coverage-check.d.mts} +1 -9
  12. package/dist/src/coverage-check.mjs +4 -0
  13. package/dist/src/diff-parser.d.mts +17 -0
  14. package/dist/src/diff-parser.mjs +127 -0
  15. package/dist/src/github-comment.d.mts +9 -0
  16. package/dist/src/github-comment.mjs +66 -0
  17. package/dist/src/lcov-merge.d.mts +5 -0
  18. package/dist/src/lcov-merge.mjs +29 -0
  19. package/dist/src/lcov-parser.d.mts +8 -0
  20. package/dist/src/lcov-parser.mjs +44 -0
  21. package/dist/src/load-artifacts.d.mts +9 -0
  22. package/dist/src/load-artifacts.mjs +41 -0
  23. package/dist/src/patch-coverage.d.mts +5 -0
  24. package/dist/src/patch-coverage.mjs +65 -0
  25. package/dist/src/report.d.mts +4 -0
  26. package/dist/src/report.mjs +65 -0
  27. package/dist/src/rules.d.mts +4 -0
  28. package/dist/src/rules.mjs +30 -0
  29. package/dist/src/s3-suite-store.d.mts +28 -0
  30. package/dist/src/s3-suite-store.mjs +147 -0
  31. package/dist/src/s3-utils.d.mts +2 -0
  32. package/dist/src/s3-utils.mjs +14 -0
  33. package/dist/src/step-summary.d.mts +9 -0
  34. package/dist/src/step-summary.mjs +70 -0
  35. package/dist/src/store-factory.d.mts +11 -0
  36. package/dist/src/store-factory.mjs +23 -0
  37. package/dist/src/suite-store.d.mts +51 -0
  38. package/dist/src/suite-store.mjs +154 -0
  39. package/dist/src/types.d.mts +36 -0
  40. package/dist/src/types.mjs +1 -0
  41. package/package.json +19 -5
  42. package/bin/coverage-check.mts +0 -6
  43. package/src/cli.mts +0 -15
  44. package/src/cli.test.mts +0 -45
  45. package/src/commands/check-args.mts +0 -110
  46. package/src/commands/check.mts +0 -147
  47. package/src/commands/check.test.mts +0 -870
  48. package/src/commands/store-put.mts +0 -115
  49. package/src/commands/store-put.test.mts +0 -248
  50. package/src/diff-parser.mts +0 -127
  51. package/src/diff-parser.test.mts +0 -178
  52. package/src/github-comment.mts +0 -79
  53. package/src/github-comment.test.mts +0 -63
  54. package/src/lcov-merge.mts +0 -34
  55. package/src/lcov-merge.test.mts +0 -57
  56. package/src/lcov-parser.mts +0 -46
  57. package/src/lcov-parser.test.mts +0 -86
  58. package/src/load-artifacts.mts +0 -42
  59. package/src/load-artifacts.test.mts +0 -115
  60. package/src/patch-coverage.mts +0 -82
  61. package/src/patch-coverage.test.mts +0 -91
  62. package/src/report.mts +0 -78
  63. package/src/report.test.mts +0 -142
  64. package/src/rules.mts +0 -34
  65. package/src/rules.test.mts +0 -98
  66. package/src/s3-suite-store.mts +0 -138
  67. package/src/s3-suite-store.test.mts +0 -308
  68. package/src/step-summary.mts +0 -89
  69. package/src/step-summary.test.mts +0 -189
  70. package/src/store-factory.mts +0 -23
  71. package/src/store-factory.test.mts +0 -67
  72. package/src/suite-store.mts +0 -112
  73. package/src/suite-store.test.mts +0 -209
  74. package/src/types.mts +0 -43
@@ -1,79 +0,0 @@
1
- import { COMMENT_MARKER } from "./report.mts";
2
-
3
- export type GhRunner = (args: string[]) => Promise<string>;
4
-
5
- /* c8 ignore start */
6
- async function defaultGhRunner(args: string[]): Promise<string> {
7
- const { spawn } = await import("node:child_process");
8
- return new Promise((resolve, reject) => {
9
- const chunks: Buffer[] = [];
10
- const proc = spawn("gh", args, { stdio: ["ignore", "pipe", "inherit"] });
11
- proc.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
12
- proc.on("error", reject);
13
- proc.on("close", (code: number | null) =>
14
- code === 0
15
- ? resolve(Buffer.concat(chunks).toString("utf8"))
16
- : reject(new Error(`gh ${args[0]} exited with code ${code}`)),
17
- );
18
- });
19
- }
20
- /* c8 ignore stop */
21
-
22
- /** Finds the ID of the existing coverage-check sticky comment, if any. */
23
- async function findExistingComment(repo: string, pr: number, gh: GhRunner): Promise<number | null> {
24
- try {
25
- const raw = await gh([
26
- "api",
27
- `repos/${repo}/issues/${pr}/comments`,
28
- "--paginate",
29
- "-q",
30
- `first(.[] | select(.body | startswith("${COMMENT_MARKER}"))) | .id`,
31
- ]);
32
- // --paginate applies the jq filter per page; take the first valid ID across all lines
33
- const id = raw
34
- .split("\n")
35
- .map((line) => parseInt(line.trim(), 10))
36
- .find((n) => Number.isFinite(n) && n > 0);
37
- return id ?? null;
38
- } catch (err) {
39
- process.stderr.write(`coverage-check: warning: failed to look up existing comment: ${err}\n`);
40
- return null;
41
- }
42
- }
43
-
44
- /**
45
- * Posts or updates the sticky coverage-check comment on a pull request.
46
- *
47
- * - On failure: upserts the failure comment body (POST if absent, PATCH if exists).
48
- * - On pass with prior comment: deletes the prior comment.
49
- * - On pass with no prior comment: stays silent.
50
- */
51
- export async function upsertComment(
52
- body: string,
53
- repo: string,
54
- pr: number,
55
- passed: boolean,
56
- gh: GhRunner = defaultGhRunner,
57
- ): Promise<void> {
58
- const existingId = await findExistingComment(repo, pr, gh);
59
-
60
- if (passed && existingId === null) return;
61
-
62
- if (passed && existingId !== null) {
63
- await gh(["api", `repos/${repo}/issues/comments/${existingId}`, "-X", "DELETE"]);
64
- return;
65
- }
66
-
67
- if (existingId !== null) {
68
- await gh([
69
- "api",
70
- `repos/${repo}/issues/comments/${existingId}`,
71
- "-X",
72
- "PATCH",
73
- "-f",
74
- `body=${body}`,
75
- ]);
76
- } else {
77
- await gh(["api", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`]);
78
- }
79
- }
@@ -1,63 +0,0 @@
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
-
18
- describe("upsertComment", () => {
19
- it("posts a new comment on failure when none exists", async () => {
20
- const gh = makeGh({ "issues/42/comments --paginate": "" });
21
- await upsertComment(FAIL_BODY, "owner/repo", 42, false, gh);
22
- const calls = gh.mock.calls.map((c) => c[0].join(" "));
23
- expect(calls.some((c) => c.includes("issues/42/comments") && !c.includes("PATCH"))).toBe(true);
24
- });
25
-
26
- it("patches an existing comment on failure", async () => {
27
- const gh = makeGh({ "issues/42/comments --paginate": "99\n" });
28
- await upsertComment(FAIL_BODY, "owner/repo", 42, false, gh);
29
- const calls = gh.mock.calls.map((c) => c[0].join(" "));
30
- expect(calls.some((c) => c.includes("comments/99") && c.includes("PATCH"))).toBe(true);
31
- });
32
-
33
- it("deletes an existing comment on pass", async () => {
34
- const gh = makeGh({ "issues/42/comments --paginate": "99\n" });
35
- await upsertComment("", "owner/repo", 42, true, gh);
36
- const calls = gh.mock.calls.map((c) => c[0].join(" "));
37
- expect(calls.some((c) => c.includes("comments/99") && c.includes("DELETE"))).toBe(true);
38
- });
39
-
40
- it("does nothing on pass when no prior comment exists", async () => {
41
- const gh = makeGh({ "issues/42/comments --paginate": "" });
42
- await upsertComment("", "owner/repo", 42, true, gh);
43
- expect(gh.mock.calls.length).toBe(1);
44
- });
45
-
46
- it("finds comment id when paginate produces null on first page then id on second", async () => {
47
- const gh = makeGh({ "issues/42/comments --paginate": "\n99\n" });
48
- await upsertComment(FAIL_BODY, "owner/repo", 42, false, gh);
49
- const calls = gh.mock.calls.map((c) => c[0].join(" "));
50
- expect(calls.some((c) => c.includes("comments/99") && c.includes("PATCH"))).toBe(true);
51
- });
52
-
53
- it("falls back to POST and does not throw when comment lookup fails", async () => {
54
- const lookupErrorGh = vi.fn<GhRunner>((args) =>
55
- args.includes("--paginate") ? Promise.reject(new Error("API error")) : Promise.resolve(""),
56
- );
57
- await expect(
58
- upsertComment(FAIL_BODY, "owner/repo", 42, false, lookupErrorGh),
59
- ).resolves.toBeUndefined();
60
- const calls = lookupErrorGh.mock.calls.map((c) => c[0].join(" "));
61
- expect(calls.some((c) => c.includes("issues/42/comments") && !c.includes("PATCH"))).toBe(true);
62
- });
63
- });
@@ -1,34 +0,0 @@
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
- }
@@ -1,57 +0,0 @@
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
- });
@@ -1,46 +0,0 @@
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
- }
@@ -1,86 +0,0 @@
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
- });
@@ -1,42 +0,0 @@
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
- }
@@ -1,115 +0,0 @@
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
- });
@@ -1,82 +0,0 @@
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
- }