coverage-check 0.1.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 (67) hide show
  1. package/README.md +109 -51
  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} +3 -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 +20 -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.mts +0 -200
  46. package/src/commands/check.test.mts +0 -642
  47. package/src/commands/store-put.mts +0 -100
  48. package/src/commands/store-put.test.mts +0 -154
  49. package/src/diff-parser.mts +0 -127
  50. package/src/diff-parser.test.mts +0 -178
  51. package/src/github-comment.mts +0 -74
  52. package/src/github-comment.test.mts +0 -64
  53. package/src/lcov-merge.mts +0 -34
  54. package/src/lcov-merge.test.mts +0 -57
  55. package/src/lcov-parser.mts +0 -46
  56. package/src/lcov-parser.test.mts +0 -86
  57. package/src/load-artifacts.mts +0 -42
  58. package/src/load-artifacts.test.mts +0 -115
  59. package/src/patch-coverage.mts +0 -82
  60. package/src/patch-coverage.test.mts +0 -91
  61. package/src/report.mts +0 -87
  62. package/src/report.test.mts +0 -159
  63. package/src/rules.mts +0 -34
  64. package/src/rules.test.mts +0 -98
  65. package/src/suite-store.mts +0 -62
  66. package/src/suite-store.test.mts +0 -115
  67. package/src/types.mts +0 -43
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Decodes a git C-string (inner content between surrounding double-quotes).
3
+ * Git quotes unusual paths (non-ASCII, spaces, etc.) with core.quotePath=true.
4
+ * Handles octal byte escapes (\nnn), \\, \", \n, \t.
5
+ */
6
+ export function decodeGitCString(s) {
7
+ const bytes = [];
8
+ for (let i = 0; i < s.length;) {
9
+ if (s[i] === "\\" && i + 1 < s.length) {
10
+ const next = s[i + 1];
11
+ if (next >= "0" && next <= "7") {
12
+ bytes.push(parseInt(s.slice(i + 1, i + 4), 8));
13
+ i += 4;
14
+ }
15
+ else if (next === "\\") {
16
+ bytes.push(92);
17
+ i += 2;
18
+ }
19
+ else if (next === '"') {
20
+ bytes.push(34);
21
+ i += 2;
22
+ }
23
+ else if (next === "n") {
24
+ bytes.push(10);
25
+ i += 2;
26
+ }
27
+ else if (next === "t") {
28
+ bytes.push(9);
29
+ i += 2;
30
+ }
31
+ else {
32
+ bytes.push(s.charCodeAt(i));
33
+ i++;
34
+ }
35
+ }
36
+ else {
37
+ bytes.push(s.charCodeAt(i));
38
+ i++;
39
+ }
40
+ }
41
+ return Buffer.from(new Uint8Array(bytes)).toString("utf8");
42
+ }
43
+ /**
44
+ * Parses the output of `git diff --unified=0` into a map of
45
+ * repo-root-relative file path → set of added/modified line numbers.
46
+ *
47
+ * Only added lines (lines in the new version) are tracked. Deleted-only
48
+ * hunks (where the `+` count is 0) are skipped.
49
+ */
50
+ export function parseDiff(text) {
51
+ const result = new Map();
52
+ let currentLines = null;
53
+ let inHeader = false;
54
+ for (const raw of text.split("\n")) {
55
+ const line = raw.trimEnd();
56
+ // Only parse +++ as a file header when we are in the diff header block
57
+ // (after `diff --git` / `---`). Without this guard a source line beginning
58
+ // with `++ b/` would appear as `+++ b/…` in the diff and be misclassified.
59
+ let newFilePath = null;
60
+ if (inHeader) {
61
+ if (line.startsWith("+++ b/")) {
62
+ newFilePath = line.slice(6);
63
+ }
64
+ else if (line.startsWith('+++ "b/') && line.endsWith('"')) {
65
+ newFilePath = decodeGitCString(line.slice(5, -1)).slice(2);
66
+ }
67
+ }
68
+ if (newFilePath !== null) {
69
+ inHeader = false;
70
+ const path = newFilePath;
71
+ if (path === "dev/null") {
72
+ currentLines = null;
73
+ continue;
74
+ }
75
+ currentLines = result.get(path) ?? new Set();
76
+ result.set(path, currentLines);
77
+ }
78
+ else if (line.startsWith("--- ")) {
79
+ // ignore (part of diff header)
80
+ }
81
+ else if (line.startsWith("@@ ") && currentLines !== null) {
82
+ // @@ -old_start[,old_count] +new_start[,new_count] @@
83
+ const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
84
+ if (!match)
85
+ continue;
86
+ const newStart = parseInt(match[1], 10);
87
+ const newCount = match[2] !== undefined ? parseInt(match[2], 10) : 1;
88
+ if (newCount === 0)
89
+ continue;
90
+ for (let i = 0; i < newCount; i++) {
91
+ currentLines.add(newStart + i);
92
+ }
93
+ }
94
+ else if (line.startsWith("diff --git ")) {
95
+ currentLines = null;
96
+ inHeader = true;
97
+ }
98
+ }
99
+ return result;
100
+ }
101
+ /** Runs git diff and returns the parsed result. */
102
+ export async function getChangedLines(baseRef, headRef) {
103
+ const { spawn } = await import("node:child_process");
104
+ const spawnProcess = (cmd, args) => new Promise((resolve, reject) => {
105
+ const chunks = [];
106
+ const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "inherit"] });
107
+ proc.stdout.on("data", (chunk) => chunks.push(chunk));
108
+ proc.on("error", reject);
109
+ proc.on("close", (code) => code === 0
110
+ ? resolve(Buffer.concat(chunks).toString("utf8"))
111
+ : reject(new Error(`${cmd} exited with code ${code}`)));
112
+ });
113
+ const mergeBase = await spawnProcess("git", ["merge-base", baseRef, headRef]);
114
+ const base = mergeBase.trim();
115
+ // --src-prefix/--dst-prefix override diff.noprefix and diff.mnemonicPrefix git config
116
+ const diff = await spawnProcess("git", [
117
+ "diff",
118
+ "--unified=0",
119
+ "--inter-hunk-context=0",
120
+ "--no-color",
121
+ "--src-prefix=a/",
122
+ "--dst-prefix=b/",
123
+ base,
124
+ headRef,
125
+ ]);
126
+ return parseDiff(diff);
127
+ }
@@ -0,0 +1,9 @@
1
+ export type GhRunner = (args: string[]) => Promise<string>;
2
+ /**
3
+ * Posts or updates the sticky coverage-check comment on a pull request.
4
+ *
5
+ * - On failure: upserts the failure comment body (POST if absent, PATCH if exists).
6
+ * - On pass with prior comment: deletes the prior comment.
7
+ * - On pass with no prior comment: stays silent.
8
+ */
9
+ export declare function upsertComment(body: string, repo: string, pr: number, passed: boolean, gh?: GhRunner): Promise<void>;
@@ -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 {};