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
@@ -0,0 +1,66 @@
1
+ import { COMMENT_MARKER } from "./report.mjs";
2
+ /* c8 ignore start */
3
+ async function defaultGhRunner(args) {
4
+ const { spawn } = await import("node:child_process");
5
+ return new Promise((resolve, reject) => {
6
+ const chunks = [];
7
+ const proc = spawn("gh", args, { stdio: ["ignore", "pipe", "inherit"] });
8
+ proc.stdout.on("data", (chunk) => chunks.push(chunk));
9
+ proc.on("error", reject);
10
+ proc.on("close", (code) => code === 0
11
+ ? resolve(Buffer.concat(chunks).toString("utf8"))
12
+ : reject(new Error(`gh ${args[0]} exited with code ${code}`)));
13
+ });
14
+ }
15
+ /* c8 ignore stop */
16
+ /** Finds the ID of the existing coverage-check sticky comment, if any. */
17
+ async function findExistingComment(repo, pr, gh) {
18
+ try {
19
+ const raw = await gh([
20
+ "api",
21
+ `repos/${repo}/issues/${pr}/comments`,
22
+ "--paginate",
23
+ "-q",
24
+ `first(.[] | select(.body | startswith("${COMMENT_MARKER}"))) | .id`,
25
+ ]);
26
+ // --paginate applies the jq filter per page; take the first valid ID across all lines
27
+ const id = raw
28
+ .split("\n")
29
+ .map((line) => parseInt(line.trim(), 10))
30
+ .find((n) => Number.isFinite(n) && n > 0);
31
+ return id ?? null;
32
+ }
33
+ catch (err) {
34
+ process.stderr.write(`coverage-check: warning: failed to look up existing comment: ${err}\n`);
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Posts or updates the sticky coverage-check comment on a pull request.
40
+ *
41
+ * - On failure: upserts the failure comment body (POST if absent, PATCH if exists).
42
+ * - On pass with prior comment: deletes the prior comment.
43
+ * - On pass with no prior comment: stays silent.
44
+ */
45
+ export async function upsertComment(body, repo, pr, passed, gh = defaultGhRunner) {
46
+ const existingId = await findExistingComment(repo, pr, gh);
47
+ if (passed && existingId === null)
48
+ return;
49
+ if (passed && existingId !== null) {
50
+ await gh(["api", `repos/${repo}/issues/comments/${existingId}`, "-X", "DELETE"]);
51
+ return;
52
+ }
53
+ if (existingId !== null) {
54
+ await gh([
55
+ "api",
56
+ `repos/${repo}/issues/comments/${existingId}`,
57
+ "-X",
58
+ "PATCH",
59
+ "-f",
60
+ `body=${body}`,
61
+ ]);
62
+ }
63
+ else {
64
+ await gh(["api", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`]);
65
+ }
66
+ }
@@ -0,0 +1,5 @@
1
+ import type { LcovData } from "./types.mts";
2
+ /** Merges multiple LcovData maps by summing hit counts per file per line. */
3
+ export declare function mergeLcov(reports: LcovData[]): LcovData;
4
+ /** Serializes LcovData back to LCOV text format. */
5
+ export declare function toLcov(lcov: LcovData): string;
@@ -0,0 +1,29 @@
1
+ /** Merges multiple LcovData maps by summing hit counts per file per line. */
2
+ export function mergeLcov(reports) {
3
+ const merged = new Map();
4
+ for (const report of reports) {
5
+ for (const [file, lines] of report) {
6
+ let target = merged.get(file);
7
+ if (target === undefined) {
8
+ target = new Map();
9
+ merged.set(file, target);
10
+ }
11
+ for (const [lineNo, hits] of lines) {
12
+ target.set(lineNo, (target.get(lineNo) ?? 0) + hits);
13
+ }
14
+ }
15
+ }
16
+ return merged;
17
+ }
18
+ /** Serializes LcovData back to LCOV text format. */
19
+ export function toLcov(lcov) {
20
+ const lines = [];
21
+ for (const [file, fileLines] of lcov) {
22
+ lines.push(`SF:${file}`);
23
+ for (const [lineNo, hits] of fileLines) {
24
+ lines.push(`DA:${lineNo},${hits}`);
25
+ }
26
+ lines.push("end_of_record");
27
+ }
28
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
29
+ }
@@ -0,0 +1,8 @@
1
+ import type { LcovData } from "./types.mts";
2
+ /**
3
+ * Parses LCOV text into a map of repo-root-relative file path → line → hit count.
4
+ *
5
+ * Paths are normalized by stripping a given prefix (e.g. $GITHUB_WORKSPACE or cwd)
6
+ * so callers see repo-root-relative paths regardless of where the runner ran.
7
+ */
8
+ export declare function parseLcov(text: string, stripPrefixes?: string[]): LcovData;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Parses LCOV text into a map of repo-root-relative file path → line → hit count.
3
+ *
4
+ * Paths are normalized by stripping a given prefix (e.g. $GITHUB_WORKSPACE or cwd)
5
+ * so callers see repo-root-relative paths regardless of where the runner ran.
6
+ */
7
+ export function parseLcov(text, stripPrefixes = []) {
8
+ const result = new Map();
9
+ let currentLines = null;
10
+ for (const raw of text.split("\n")) {
11
+ const line = raw.trimEnd();
12
+ if (line.startsWith("SF:")) {
13
+ let path = line.slice(3);
14
+ for (const prefix of stripPrefixes) {
15
+ if (path.startsWith(prefix)) {
16
+ path = path.slice(prefix.length);
17
+ break;
18
+ }
19
+ }
20
+ path = normalizePath(path);
21
+ currentLines = result.get(path) ?? new Map();
22
+ result.set(path, currentLines);
23
+ }
24
+ else if (line.startsWith("DA:") && currentLines !== null) {
25
+ const rest = line.slice(3);
26
+ const comma = rest.indexOf(",");
27
+ if (comma === -1)
28
+ continue;
29
+ const lineNo = parseInt(rest.slice(0, comma), 10);
30
+ const hits = parseInt(rest.slice(comma + 1), 10);
31
+ if (!Number.isFinite(lineNo) || !Number.isFinite(hits))
32
+ continue;
33
+ const prev = currentLines.get(lineNo) ?? 0;
34
+ currentLines.set(lineNo, prev + hits);
35
+ }
36
+ else if (line === "end_of_record") {
37
+ currentLines = null;
38
+ }
39
+ }
40
+ return result;
41
+ }
42
+ function normalizePath(p) {
43
+ return p.replace(/\\/g, "/").replace(/^\.\//, "");
44
+ }
@@ -0,0 +1,9 @@
1
+ /** Recursively collects all lcov.info files under the given directory. */
2
+ export declare function collectLcovFiles(dir: string): string[];
3
+ /**
4
+ * Builds the list of path prefixes to strip from LCOV SF: lines.
5
+ *
6
+ * Defaults: $GITHUB_WORKSPACE (if set) and cwd are always prepended.
7
+ * Additional prefixes can be passed via the `extra` parameter.
8
+ */
9
+ export declare function buildStripPrefixes(extra?: string[]): string[];
@@ -0,0 +1,41 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /** Recursively collects all lcov.info files under the given directory. */
4
+ export function collectLcovFiles(dir) {
5
+ const results = [];
6
+ try {
7
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
8
+ const full = join(dir, entry.name);
9
+ if (entry.isDirectory()) {
10
+ results.push(...collectLcovFiles(full));
11
+ }
12
+ else if (entry.name === "lcov.info") {
13
+ results.push(full);
14
+ }
15
+ }
16
+ }
17
+ catch (err) {
18
+ /* c8 ignore next */
19
+ if (err.code !== "ENOENT")
20
+ /* c8 ignore next */
21
+ process.stderr.write(`coverage-check: unexpected error reading artifacts directory ${dir}: ${err}\n`);
22
+ // ENOENT: directory does not exist — no artifacts
23
+ }
24
+ return results;
25
+ }
26
+ /**
27
+ * Builds the list of path prefixes to strip from LCOV SF: lines.
28
+ *
29
+ * Defaults: $GITHUB_WORKSPACE (if set) and cwd are always prepended.
30
+ * Additional prefixes can be passed via the `extra` parameter.
31
+ */
32
+ export function buildStripPrefixes(extra = []) {
33
+ const prefixes = extra.map((p) => (p.endsWith("/") ? p : `${p}/`));
34
+ const ws = process.env["GITHUB_WORKSPACE"];
35
+ if (ws)
36
+ prefixes.push(ws.endsWith("/") ? ws : `${ws}/`);
37
+ const cwd = process.cwd();
38
+ /* c8 ignore next -- process.cwd() virtually never returns a trailing-slash path */
39
+ prefixes.push(cwd.endsWith("/") ? cwd : `${cwd}/`);
40
+ return prefixes;
41
+ }
@@ -0,0 +1,5 @@
1
+ import type { BucketResult, CoverageRule, DiffLines, FileCoverageResult, LcovData } from "./types.mts";
2
+ export declare function computePatchCoverage(diff: DiffLines, lcov: LcovData, rules: CoverageRule[]): {
3
+ buckets: BucketResult[];
4
+ informational: FileCoverageResult[];
5
+ };
@@ -0,0 +1,65 @@
1
+ import { matchRule } from "./rules.mjs";
2
+ export function computePatchCoverage(diff, lcov, rules) {
3
+ const bucketMap = new Map();
4
+ const informational = [];
5
+ for (const [file, changedLineSet] of diff) {
6
+ const fileLines = lcov.get(file);
7
+ if (fileLines === undefined)
8
+ continue; // not in lcov scope — skip
9
+ const coverable = [];
10
+ const uncoveredLines = [];
11
+ let hit = 0;
12
+ for (const lineNo of changedLineSet) {
13
+ if (!fileLines.has(lineNo))
14
+ continue; // line not tracked by coverage
15
+ coverable.push(lineNo);
16
+ if (fileLines.get(lineNo) > 0) {
17
+ hit++;
18
+ }
19
+ else {
20
+ uncoveredLines.push(lineNo);
21
+ }
22
+ }
23
+ if (coverable.length === 0)
24
+ continue;
25
+ const rule = matchRule(file, rules);
26
+ const fileResult = {
27
+ file,
28
+ coverable: coverable.length,
29
+ hit,
30
+ uncoveredLines: uncoveredLines.sort((a, b) => a - b),
31
+ rule: rule?.paths ?? null,
32
+ };
33
+ if (rule === null) {
34
+ informational.push(fileResult);
35
+ continue;
36
+ }
37
+ let bucket = bucketMap.get(rule.paths);
38
+ if (bucket === undefined) {
39
+ bucket = {
40
+ rule: rule.paths,
41
+ threshold: rule.patch_coverage_min,
42
+ coverable: 0,
43
+ hit: 0,
44
+ files: [],
45
+ passed: true,
46
+ };
47
+ bucketMap.set(rule.paths, bucket);
48
+ }
49
+ bucket.coverable += coverable.length;
50
+ bucket.hit += hit;
51
+ bucket.files.push(fileResult);
52
+ }
53
+ const buckets = [...bucketMap.values()];
54
+ for (const bucket of buckets) {
55
+ /* c8 ignore next -- bucket always has coverable>0 (L36 guard prevents empty-coverable files from reaching bucketMap) */
56
+ if (bucket.coverable === 0) {
57
+ bucket.passed = true;
58
+ }
59
+ else {
60
+ const pct = (bucket.hit / bucket.coverable) * 100;
61
+ bucket.passed = pct >= bucket.threshold;
62
+ }
63
+ }
64
+ return { buckets, informational };
65
+ }
@@ -0,0 +1,4 @@
1
+ import type { CoverageCheckResult } from "./types.mts";
2
+ export declare const COMMENT_MARKER = "<!-- coverage-check -->";
3
+ export declare function collapseRanges(lines: number[]): string;
4
+ export declare function renderFailureComment(result: CoverageCheckResult, runUrl: string, now?: string): string;
@@ -0,0 +1,65 @@
1
+ export const COMMENT_MARKER = "<!-- coverage-check -->";
2
+ export function collapseRanges(lines) {
3
+ if (lines.length === 0)
4
+ return "";
5
+ const sorted = [...lines].sort((a, b) => a - b);
6
+ const ranges = [];
7
+ let start = sorted[0];
8
+ let end = start;
9
+ for (let i = 1; i < sorted.length; i++) {
10
+ const n = sorted[i];
11
+ if (n === end + 1) {
12
+ end = n;
13
+ }
14
+ else {
15
+ ranges.push(start === end ? `L${start}` : `L${start}-${end}`);
16
+ start = n;
17
+ end = n;
18
+ }
19
+ }
20
+ ranges.push(start === end ? `L${start}` : `L${start}-${end}`);
21
+ return ranges.join(", ");
22
+ }
23
+ function pct(bucket) {
24
+ if (bucket.coverable === 0)
25
+ return "—";
26
+ return `${((bucket.hit / bucket.coverable) * 100).toFixed(1)}% (${bucket.hit}/${bucket.coverable})`;
27
+ }
28
+ function renderFileList(files) {
29
+ return files
30
+ .filter((f) => f.uncoveredLines.length > 0)
31
+ .map((f) => `- \`${f.file}\`: ${collapseRanges(f.uncoveredLines)}`)
32
+ .join("\n");
33
+ }
34
+ export function renderFailureComment(result, runUrl, now = new Date().toISOString()) {
35
+ const failingBuckets = result.buckets.filter((b) => !b.passed);
36
+ const table = [
37
+ "| Workspace rule | Patch coverage | Threshold |",
38
+ "|---|---|---|",
39
+ ...failingBuckets.map((b) => `| \`${b.rule}\` | ${pct(b)} | ${b.threshold}% |`),
40
+ ].join("\n");
41
+ const sections = failingBuckets
42
+ .map((b) => {
43
+ const fileList = renderFileList(b.files);
44
+ return `**\`${b.rule}\`** (threshold ${b.threshold}%):\n${fileList || "_No line-level data available_"}`;
45
+ })
46
+ .join("\n\n");
47
+ const informationalLines = result.informational
48
+ .filter((f) => f.uncoveredLines.length > 0)
49
+ .map((f) => `- \`${f.file}\`: ${collapseRanges(f.uncoveredLines)}`)
50
+ .join("\n");
51
+ const informationalSection = informationalLines.length > 0
52
+ ? `\n<details><summary>Informational (no rule)</summary>\n\n${informationalLines}\n</details>`
53
+ : "";
54
+ return `${COMMENT_MARKER}
55
+ ## Patch coverage gate failed
56
+
57
+ ${table}
58
+
59
+ ### Uncovered lines
60
+
61
+ ${sections}
62
+ ${informationalSection}
63
+
64
+ _Last updated: ${now} · [Workflow run](${runUrl})_`;
65
+ }
@@ -0,0 +1,4 @@
1
+ import type { CoverageRule } from "./types.mts";
2
+ export declare function loadRules(rulesPath: string): CoverageRule[];
3
+ /** Returns the first matching rule for a repo-root-relative file path, or null. */
4
+ export declare function matchRule(file: string, rules: CoverageRule[]): CoverageRule | null;
@@ -0,0 +1,30 @@
1
+ /* c8 ignore next */
2
+ import { readFileSync } from "node:fs";
3
+ import { matchesGlob } from "node:path";
4
+ import yaml from "js-yaml";
5
+ export function loadRules(rulesPath) {
6
+ const text = readFileSync(rulesPath, "utf8");
7
+ const parsed = yaml.load(text);
8
+ if (!Array.isArray(parsed?.rules)) {
9
+ throw new Error(`${rulesPath}: expected a 'rules' array`);
10
+ }
11
+ for (let i = 0; i < parsed.rules.length; i++) {
12
+ const rule = parsed.rules[i];
13
+ if (typeof rule?.paths !== "string") {
14
+ throw new Error(`${rulesPath}: rule[${i}].paths must be a string`);
15
+ }
16
+ const min = rule.patch_coverage_min;
17
+ if (!Number.isFinite(min) || min < 0 || min > 100) {
18
+ throw new Error(`${rulesPath}: rule[${i}].patch_coverage_min must be a number between 0 and 100`);
19
+ }
20
+ }
21
+ return parsed.rules;
22
+ }
23
+ /** Returns the first matching rule for a repo-root-relative file path, or null. */
24
+ export function matchRule(file, rules) {
25
+ for (const rule of rules) {
26
+ if (matchesGlob(file, rule.paths))
27
+ return rule;
28
+ }
29
+ return null;
30
+ }
@@ -0,0 +1,28 @@
1
+ import type { SuitePutMeta, SuiteStore } from "./suite-store.mts";
2
+ type ClientLike = {
3
+ send(cmd: object): Promise<unknown>;
4
+ };
5
+ export type S3SuiteStoreOptions = {
6
+ bucket: string;
7
+ prefix?: string;
8
+ region?: string;
9
+ /** Inject a custom S3 client (e.g. for testing). */
10
+ client?: ClientLike;
11
+ };
12
+ export declare class S3SuiteStore implements SuiteStore {
13
+ private readonly bucket;
14
+ private readonly prefix;
15
+ private readonly client;
16
+ constructor({ bucket, prefix, region, client }: S3SuiteStoreOptions);
17
+ private key;
18
+ list(): Promise<string[]>;
19
+ get(suite: string, opts?: {
20
+ sha?: string;
21
+ branch?: string;
22
+ }): Promise<Buffer | null>;
23
+ put(suite: string, lcov: Buffer, meta?: SuitePutMeta): Promise<void>;
24
+ private getLegacy;
25
+ private shouldWritePointer;
26
+ private readPointer;
27
+ }
28
+ export {};
@@ -0,0 +1,147 @@
1
+ import { GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
2
+ import { assertSafePathComponent, assertValidTimestamp, encodeBranchName, isNewerTimestamp, } from "./suite-store.mjs";
3
+ import { bodyToBuffer, isNotFound } from "./s3-utils.mjs";
4
+ export class S3SuiteStore {
5
+ bucket;
6
+ prefix;
7
+ client;
8
+ constructor({ bucket, prefix, region, client }) {
9
+ this.bucket = bucket;
10
+ this.prefix = prefix ? prefix.replace(/\/+$/, "") : "";
11
+ this.client = client ?? new S3Client({ region });
12
+ }
13
+ key(...parts) {
14
+ return this.prefix ? [this.prefix, ...parts].join("/") : parts.join("/");
15
+ }
16
+ async list() {
17
+ const pfx = this.prefix ? `${this.prefix}/` : "";
18
+ const suites = [];
19
+ let continuationToken;
20
+ do {
21
+ const resp = (await this.client.send(new ListObjectsV2Command({
22
+ Bucket: this.bucket,
23
+ Prefix: pfx,
24
+ Delimiter: "/",
25
+ ...(continuationToken ? { ContinuationToken: continuationToken } : {}),
26
+ })));
27
+ suites.push(...(resp.CommonPrefixes ?? [])
28
+ .map((cp) => cp.Prefix?.replace(pfx, "").replace(/\/$/, "") ?? "")
29
+ .filter(Boolean));
30
+ continuationToken = resp.IsTruncated ? resp.NextContinuationToken : undefined;
31
+ } while (continuationToken);
32
+ return suites;
33
+ }
34
+ async get(suite, opts) {
35
+ assertSafePathComponent(suite, "suite");
36
+ if (opts?.sha !== undefined)
37
+ assertSafePathComponent(opts.sha, "sha");
38
+ let sha = opts?.sha;
39
+ if (!sha) {
40
+ const branch = opts?.branch ?? "main";
41
+ try {
42
+ const pointer = await this.readPointer(suite, branch);
43
+ assertSafePathComponent(pointer.sha, "sha");
44
+ sha = pointer.sha;
45
+ }
46
+ catch (err) {
47
+ if (isNotFound(err))
48
+ return this.getLegacy(suite);
49
+ throw err;
50
+ }
51
+ }
52
+ try {
53
+ const resp = (await this.client.send(new GetObjectCommand({
54
+ Bucket: this.bucket,
55
+ Key: this.key(suite, "sha", sha, "lcov.info"),
56
+ })));
57
+ return bodyToBuffer(resp.Body);
58
+ }
59
+ catch (err) {
60
+ if (isNotFound(err))
61
+ return null;
62
+ throw err;
63
+ }
64
+ }
65
+ async put(suite, lcov, meta) {
66
+ assertSafePathComponent(suite, "suite");
67
+ if (meta === undefined) {
68
+ await this.client.send(new PutObjectCommand({
69
+ Bucket: this.bucket,
70
+ Key: this.key(suite, "lcov.info"),
71
+ Body: lcov,
72
+ ContentType: "text/plain",
73
+ }));
74
+ return;
75
+ }
76
+ const { sha, branch } = meta;
77
+ assertSafePathComponent(sha, "sha");
78
+ const ts = meta.timestamp ?? new Date().toISOString();
79
+ assertValidTimestamp(ts);
80
+ await this.client.send(new PutObjectCommand({
81
+ Bucket: this.bucket,
82
+ Key: this.key(suite, "sha", sha, "lcov.info"),
83
+ Body: lcov,
84
+ ContentType: "text/plain",
85
+ }));
86
+ if (!(await this.shouldWritePointer(suite, branch, ts)))
87
+ return;
88
+ await this.client.send(new PutObjectCommand({
89
+ Bucket: this.bucket,
90
+ Key: this.key(suite, "branch", encodeBranchName(branch), "latest.json"),
91
+ Body: Buffer.from(JSON.stringify({ sha, timestamp: ts }), "utf8"),
92
+ ContentType: "application/json",
93
+ }));
94
+ }
95
+ async getLegacy(suite) {
96
+ try {
97
+ const resp = (await this.client.send(new GetObjectCommand({
98
+ Bucket: this.bucket,
99
+ Key: this.key(suite, "lcov.info"),
100
+ })));
101
+ return bodyToBuffer(resp.Body);
102
+ }
103
+ catch (err) {
104
+ if (isNotFound(err))
105
+ return null;
106
+ throw err;
107
+ }
108
+ }
109
+ async shouldWritePointer(suite, branch, incomingTimestamp) {
110
+ try {
111
+ const resp = (await this.client.send(new GetObjectCommand({
112
+ Bucket: this.bucket,
113
+ Key: this.key(suite, "branch", encodeBranchName(branch), "latest.json"),
114
+ })));
115
+ if (resp.Body === undefined)
116
+ return true;
117
+ const body = await bodyToBuffer(resp.Body);
118
+ const current = JSON.parse(body.toString("utf8"));
119
+ return !isNewerTimestamp(current.timestamp, incomingTimestamp);
120
+ }
121
+ catch (err) {
122
+ if (isNotFound(err))
123
+ return true;
124
+ throw err;
125
+ }
126
+ }
127
+ async readPointer(suite, branch) {
128
+ const keys = [
129
+ this.key(suite, "branch", encodeBranchName(branch), "latest.json"),
130
+ this.key(suite, "branch", branch, "latest.json"),
131
+ ];
132
+ let lastNotFound;
133
+ for (const key of keys) {
134
+ try {
135
+ const resp = (await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })));
136
+ const body = await bodyToBuffer(resp.Body);
137
+ return JSON.parse(body.toString("utf8"));
138
+ }
139
+ catch (err) {
140
+ if (!isNotFound(err))
141
+ throw err;
142
+ lastNotFound = err;
143
+ }
144
+ }
145
+ throw lastNotFound;
146
+ }
147
+ }
@@ -0,0 +1,2 @@
1
+ export declare function isNotFound(err: unknown): boolean;
2
+ export declare function bodyToBuffer(body: unknown): Promise<Buffer>;
@@ -0,0 +1,14 @@
1
+ import { Readable } from "node:stream";
2
+ import { buffer } from "node:stream/consumers";
3
+ export function isNotFound(err) {
4
+ return err instanceof Error && (err.name === "NoSuchKey" || err.name === "NotFound");
5
+ }
6
+ export async function bodyToBuffer(body) {
7
+ if (body instanceof Readable)
8
+ return buffer(body);
9
+ if (body instanceof Uint8Array)
10
+ return Buffer.from(body);
11
+ if (body instanceof Blob)
12
+ return Buffer.from(await body.arrayBuffer());
13
+ throw new Error("unexpected S3 response body type");
14
+ }
@@ -0,0 +1,9 @@
1
+ import type { LcovData } from "./types.mts";
2
+ import type { CoverageCheckResult } from "./types.mts";
3
+ export type SuiteSource = {
4
+ suite: string;
5
+ source: "fresh" | "store";
6
+ lcov: LcovData;
7
+ };
8
+ export declare function buildSummaryMarkdown(suiteSources: SuiteSource[], result: CoverageCheckResult, runUrl: string, branch?: string): string;
9
+ export declare function writeSummary(summaryFile: string, suiteSources: SuiteSource[], result: CoverageCheckResult, runUrl: string, branch?: string): void;
@@ -0,0 +1,70 @@
1
+ import { appendFileSync } from "node:fs";
2
+ function suiteTotals(lcov) {
3
+ let hit = 0;
4
+ let total = 0;
5
+ for (const lines of lcov.values()) {
6
+ for (const count of lines.values()) {
7
+ total++;
8
+ if (count > 0)
9
+ hit++;
10
+ }
11
+ }
12
+ return { hit, total };
13
+ }
14
+ function pctStr(hit, total) {
15
+ if (total === 0)
16
+ return "—";
17
+ return `${((hit / total) * 100).toFixed(1)}% (${hit}/${total})`;
18
+ }
19
+ function escMd(s) {
20
+ return s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
21
+ }
22
+ function codeSpan(s) {
23
+ const escaped = escMd(s);
24
+ const longestRun = Math.max(0, ...Array.from(escaped.matchAll(/`+/g), (m) => m[0].length));
25
+ if (longestRun === 0)
26
+ return `\`${escaped}\``;
27
+ const ticks = "`".repeat(longestRun + 1);
28
+ return `${ticks} ${escaped} ${ticks}`;
29
+ }
30
+ export function buildSummaryMarkdown(suiteSources, result, runUrl, branch = "main") {
31
+ const suiteRows = suiteSources
32
+ .map(({ suite, source, lcov }) => {
33
+ const { hit, total } = suiteTotals(lcov);
34
+ const sourceLabel = source === "fresh" ? "fresh" : `store (${escMd(branch)})`;
35
+ return `| ${codeSpan(suite)} | ${sourceLabel} | ${pctStr(hit, total)} |`;
36
+ })
37
+ .join("\n");
38
+ const suiteTable = [
39
+ "| Suite | Source | Line coverage |",
40
+ "|---|---|---|",
41
+ suiteRows || "| — | — | — |",
42
+ ].join("\n");
43
+ const ruleRows = result.buckets
44
+ .map((b) => {
45
+ const status = b.passed ? "✅" : "❌";
46
+ const pct = b.coverable > 0 ? `${((b.hit / b.coverable) * 100).toFixed(1)}%` : "—";
47
+ return `| ${codeSpan(b.rule)} | ${b.threshold}% | ${pct} | ${status} |`;
48
+ })
49
+ .join("\n");
50
+ const ruleTable = [
51
+ "| Rule | Threshold | Patch coverage | Status |",
52
+ "|---|---|---|---|",
53
+ ruleRows || "| — | — | — | — |",
54
+ ].join("\n");
55
+ const overall = result.passed ? "✅ passed" : "❌ failed";
56
+ const runLink = runUrl !== "N/A" ? `\n\n_[View run](${runUrl})_` : "";
57
+ return `## Coverage summary — ${overall}
58
+
59
+ ### Suite totals
60
+
61
+ ${suiteTable}
62
+
63
+ ### Patch coverage
64
+
65
+ ${ruleTable}${runLink}
66
+ `;
67
+ }
68
+ export function writeSummary(summaryFile, suiteSources, result, runUrl, branch) {
69
+ appendFileSync(summaryFile, buildSummaryMarkdown(suiteSources, result, runUrl, branch), "utf8");
70
+ }