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.
- package/README.md +9 -5
- package/bin/coverage-check.mjs +4 -0
- package/dist/src/cli.d.mts +1 -0
- package/dist/src/cli.mjs +14 -0
- package/dist/src/commands/check-args.d.mts +20 -0
- package/dist/src/commands/check-args.mjs +89 -0
- package/dist/src/commands/check.d.mts +4 -0
- package/dist/src/commands/check.mjs +128 -0
- package/dist/src/commands/store-put.d.mts +11 -0
- package/dist/src/commands/store-put.mjs +104 -0
- package/{src/coverage-check.mts → dist/src/coverage-check.d.mts} +1 -9
- package/dist/src/coverage-check.mjs +4 -0
- package/dist/src/diff-parser.d.mts +17 -0
- package/dist/src/diff-parser.mjs +127 -0
- package/dist/src/github-comment.d.mts +9 -0
- package/dist/src/github-comment.mjs +66 -0
- package/dist/src/lcov-merge.d.mts +5 -0
- package/dist/src/lcov-merge.mjs +29 -0
- package/dist/src/lcov-parser.d.mts +8 -0
- package/dist/src/lcov-parser.mjs +44 -0
- package/dist/src/load-artifacts.d.mts +9 -0
- package/dist/src/load-artifacts.mjs +41 -0
- package/dist/src/patch-coverage.d.mts +5 -0
- package/dist/src/patch-coverage.mjs +65 -0
- package/dist/src/report.d.mts +4 -0
- package/dist/src/report.mjs +65 -0
- package/dist/src/rules.d.mts +4 -0
- package/dist/src/rules.mjs +30 -0
- package/dist/src/s3-suite-store.d.mts +28 -0
- package/dist/src/s3-suite-store.mjs +147 -0
- package/dist/src/s3-utils.d.mts +2 -0
- package/dist/src/s3-utils.mjs +14 -0
- package/dist/src/step-summary.d.mts +9 -0
- package/dist/src/step-summary.mjs +70 -0
- package/dist/src/store-factory.d.mts +11 -0
- package/dist/src/store-factory.mjs +23 -0
- package/dist/src/suite-store.d.mts +51 -0
- package/dist/src/suite-store.mjs +154 -0
- package/dist/src/types.d.mts +36 -0
- package/dist/src/types.mjs +1 -0
- package/package.json +19 -5
- package/bin/coverage-check.mts +0 -6
- package/src/cli.mts +0 -15
- package/src/cli.test.mts +0 -45
- package/src/commands/check-args.mts +0 -110
- package/src/commands/check.mts +0 -147
- package/src/commands/check.test.mts +0 -870
- package/src/commands/store-put.mts +0 -115
- package/src/commands/store-put.test.mts +0 -248
- package/src/diff-parser.mts +0 -127
- package/src/diff-parser.test.mts +0 -178
- package/src/github-comment.mts +0 -79
- package/src/github-comment.test.mts +0 -63
- package/src/lcov-merge.mts +0 -34
- package/src/lcov-merge.test.mts +0 -57
- package/src/lcov-parser.mts +0 -46
- package/src/lcov-parser.test.mts +0 -86
- package/src/load-artifacts.mts +0 -42
- package/src/load-artifacts.test.mts +0 -115
- package/src/patch-coverage.mts +0 -82
- package/src/patch-coverage.test.mts +0 -91
- package/src/report.mts +0 -78
- package/src/report.test.mts +0 -142
- package/src/rules.mts +0 -34
- package/src/rules.test.mts +0 -98
- package/src/s3-suite-store.mts +0 -138
- package/src/s3-suite-store.test.mts +0 -308
- package/src/step-summary.mts +0 -89
- package/src/step-summary.test.mts +0 -189
- package/src/store-factory.mts +0 -23
- package/src/store-factory.test.mts +0 -67
- package/src/suite-store.mts +0 -112
- package/src/suite-store.test.mts +0 -209
- package/src/types.mts +0 -43
package/src/github-comment.mts
DELETED
|
@@ -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
|
-
});
|
package/src/lcov-merge.mts
DELETED
|
@@ -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
|
-
}
|
package/src/lcov-merge.test.mts
DELETED
|
@@ -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
|
-
});
|
package/src/lcov-parser.mts
DELETED
|
@@ -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
|
-
}
|
package/src/lcov-parser.test.mts
DELETED
|
@@ -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
|
-
});
|
package/src/load-artifacts.mts
DELETED
|
@@ -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
|
-
});
|
package/src/patch-coverage.mts
DELETED
|
@@ -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
|
-
}
|