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
package/README.md CHANGED
@@ -22,46 +22,68 @@ coverage-check check \
22
22
 
23
23
  Exits `0` on pass, `1` on failure, `2` on configuration error.
24
24
 
25
- ### Suite store (conditional CI)
25
+ ### Suite store with S3 (conditional CI)
26
26
 
27
- When only some CI suites run per PR (e.g. backend tests only when backend files change), store each suite's LCOV on every run and merge them when checking:
27
+ When only some CI suites run per PR (e.g. backend tests only when backend files change), store each suite's LCOV in S3 and merge them during coverage checks:
28
28
 
29
29
  ```sh
30
- # After backend tests run — store this suite's coverage
30
+ # After backend tests run on the main branch — store this suite's coverage
31
31
  coverage-check store-put \
32
32
  --suite backend \
33
- --store ./coverage-store \
33
+ --store-s3 my-bucket/coverage-store \
34
34
  --artifacts ./coverage-artifacts \
35
35
  --sha "$GITHUB_SHA" \
36
- --ref "$GITHUB_REF"
36
+ --branch main
37
37
 
38
- # Sync the store directory to persistent storage (e.g. S3, git orphan branch)
39
- aws s3 sync ./coverage-store s3://my-bucket/coverage-store/
38
+ # On a PR that only runs frontend tests:
39
+ coverage-check check \
40
+ --rules .coverage-rules.yml \
41
+ --artifacts ./coverage-artifacts \
42
+ --store-s3 my-bucket/coverage-store \
43
+ --suite frontend \
44
+ --branch main \
45
+ --base origin/main \
46
+ --head HEAD
47
+ ```
48
+
49
+ The `--suite` flag on `check` tells the tool to use fresh `--artifacts` for the current suite and pull historical coverage from the store for all other suites. The `--branch` flag selects which branch pointer to follow when reading from the store.
50
+
51
+ **S3 key layout:**
52
+
53
+ ```text
54
+ <prefix>/<suite>/sha/<sha>/lcov.info # payload
55
+ <prefix>/<suite>/branch/<encoded-branch>/latest.json # pointer: { "sha": "...", "timestamp": "..." }
56
+ ```
57
+
58
+ S3-backed stores need `s3:PutObject` for writes and `s3:GetObject` for reading branch pointers and baselines. The pointer reader also checks the previous unencoded pointer key (for example `branch/main/latest.json`) so stores written before branch-name encoding remain readable.
40
59
 
41
- # --- On the next PR that runs only frontend tests ---
60
+ ### Suite store with filesystem
42
61
 
43
- # Pull the store
44
- aws s3 sync s3://my-bucket/coverage-store/ ./coverage-store
62
+ For local development or simpler deployments:
45
63
 
46
- # Store-put the current suite
47
- coverage-check store-put --suite frontend --store ./coverage-store --artifacts ./coverage-artifacts
64
+ ```sh
65
+ coverage-check store-put \
66
+ --suite backend \
67
+ --store-fs ./coverage-store \
68
+ --artifacts ./coverage-artifacts \
69
+ --sha "$GITHUB_SHA" \
70
+ --branch main
48
71
 
49
- # Check: merges all stored suites (backend from baseline + frontend from this run)
50
72
  coverage-check check \
51
73
  --rules .coverage-rules.yml \
52
74
  --artifacts ./coverage-artifacts \
53
- --store ./coverage-store \
75
+ --store-fs ./coverage-store \
54
76
  --suite frontend \
55
77
  --base origin/main \
56
78
  --head HEAD
57
79
  ```
58
80
 
59
- The `--suite` flag on `check` tells the tool to replace the same-named suite in the store with the fresh `--artifacts` (so you always see this PR's coverage for the suite that ran, and historical coverage for suites that didn't).
60
-
61
81
  ### GitHub PR sticky comment
62
82
 
63
83
  Pass `--pr` and `--repo` to post (or update) a sticky comment on a pull request. Requires the `gh` CLI and `GH_TOKEN`/`GITHUB_TOKEN`.
64
84
 
85
+ On **failure**, the comment is created or updated with the list of uncovered lines. On **pass**, any existing failure comment is **deleted** — no new comment is posted.
86
+
65
87
  ```sh
66
88
  coverage-check check \
67
89
  --rules .coverage-rules.yml \
@@ -70,6 +92,10 @@ coverage-check check \
70
92
  --repo "${{ github.repository }}"
71
93
  ```
72
94
 
95
+ ### GitHub Actions step summary
96
+
97
+ When `$GITHUB_STEP_SUMMARY` is set, a per-suite totals and per-rule patch-coverage table is appended to the job summary automatically.
98
+
73
99
  ## Rules file
74
100
 
75
101
  ```yaml
@@ -89,49 +115,49 @@ Rules are matched in order; the first match wins. Files in the diff not matched
89
115
 
90
116
  ### `coverage-check check`
91
117
 
92
- | Flag | Default | Description |
93
- | ---------------- | ---------------------- | ---------------------------------------------------------------------------- |
94
- | `--rules` | `.coverage-rules.yml` | Path to YAML rules file |
95
- | `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
96
- | `--base` | `origin/main` | Base git ref for `git diff` |
97
- | `--head` | `HEAD` | Head git ref for `git diff` |
98
- | `--store` | — | Path to a suite store directory |
99
- | `--suite` | — | Name of the current suite (fresh artifacts override this suite in the store) |
100
- | `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
101
- | `--pr` | | Pull request number for sticky comment |
102
- | `--repo` | `$GITHUB_REPOSITORY` | `owner/repo` for sticky comment |
103
- | `--json` | — | Write JSON result to this path |
118
+ | Flag | Default | Description |
119
+ | ---------------- | ---------------------- | -------------------------------------------------------------------------------------------- |
120
+ | `--rules` | `.coverage-rules.yml` | Path to YAML rules file |
121
+ | `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
122
+ | `--base` | `origin/main` | Base git ref for `git diff` |
123
+ | `--head` | `HEAD` | Head git ref for `git diff` |
124
+ | `--store-fs` | — | Path to a filesystem suite store directory |
125
+ | `--store` | — | Alias for `--store-fs` |
126
+ | `--store-s3` | — | S3 suite store spec: `<bucket>[/<prefix>]` |
127
+ | `--branch` | `"main"` | Branch pointer to follow when reading from the store |
128
+ | `--suite` | | Name of the current suite (no `/` or `\\`); fresh artifacts override this suite in the store |
129
+ | `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
130
+ | `--pr` | — | Pull request number for sticky comment |
131
+ | `--repo` | `$GITHUB_REPOSITORY` | `owner/repo` for sticky comment |
132
+ | `--json` | — | Write JSON result to this path |
104
133
 
105
134
  ### `coverage-check store-put`
106
135
 
107
- | Flag | Default | Description |
108
- | ---------------- | ---------------------- | --------------------------------------- |
109
- | `--suite` | required | Suite name to store |
110
- | `--store` | required | Path to the suite store directory |
111
- | `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
112
- | `--strip-prefix` | | Extra path prefix to strip (repeatable) |
113
- | `--sha` | — | Git SHA to record in metadata |
114
- | `--ref` | — | Git ref to record in metadata |
136
+ | Flag | Default | Description |
137
+ | ---------------- | ---------------------- | ------------------------------------------------------------- |
138
+ | `--suite` | required | Suite name to store |
139
+ | `--store-fs` | required\* | Path to a filesystem suite store directory |
140
+ | `--store` | | Alias for `--store-fs` |
141
+ | `--store-s3` | required\* | S3 suite store spec: `<bucket>[/<prefix>]` |
142
+ | `--sha` | — | Git SHA to associate with this coverage payload |
143
+ | `--branch` | — | Branch name for the pointer (e.g. `main` or `feature/foo`) |
144
+ | `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
145
+ | `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
146
+
147
+ \* Exactly one of `--store-fs` or `--store-s3` is required.
148
+
149
+ When `--sha` and `--branch` are both provided, `store-put` writes a SHA-addressed payload and advances the branch pointer only if the incoming timestamp is not older than the current pointer. Omitting both flags preserves the legacy `<suite>/lcov.info` storage layout.
115
150
 
116
151
  ## Programmatic API
117
152
 
118
153
  ```ts
119
- import { runCheck, runStorePut, FileSystemSuiteStore } from "coverage-check";
154
+ import { runCheck, runStorePut, FileSystemSuiteStore, S3SuiteStore } from "coverage-check";
120
155
 
121
- // Custom store adapter (e.g. S3)
122
- import { SuiteStore } from "coverage-check";
156
+ // FileSystem store
157
+ const fsStore = new FileSystemSuiteStore("/path/to/store");
123
158
 
124
- class S3SuiteStore implements SuiteStore {
125
- async list() {
126
- /* ... */
127
- }
128
- async get(suite: string) {
129
- /* ... */
130
- }
131
- async put(suite: string, lcov: Buffer, meta?: SuiteMeta) {
132
- /* ... */
133
- }
134
- }
159
+ // S3 store (requires AWS credentials — see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html)
160
+ const s3Store = new S3SuiteStore({ bucket: "my-bucket", prefix: "coverage" });
135
161
 
136
162
  await runCheck({
137
163
  rules: ".coverage-rules.yml",
@@ -142,9 +168,41 @@ await runCheck({
142
168
  repo: "",
143
169
  json: null,
144
170
  stripPrefixes: [],
145
- store: new S3SuiteStore(),
171
+ store: s3Store,
146
172
  suite: "backend",
173
+ branch: "main",
147
174
  });
175
+
176
+ await runStorePut({
177
+ suite: "backend",
178
+ store: s3Store,
179
+ artifacts: "./coverage",
180
+ stripPrefixes: [],
181
+ sha: "abc123",
182
+ branch: "main",
183
+ });
184
+ ```
185
+
186
+ You can also implement your own `SuiteStore`:
187
+
188
+ ```ts
189
+ import type { SuiteStore } from "coverage-check";
190
+
191
+ class MyCustomStore implements SuiteStore {
192
+ async list(): Promise<string[]> {
193
+ /* ... */
194
+ }
195
+ async get(suite: string, opts?: { sha?: string; branch?: string }): Promise<Buffer | null> {
196
+ /* ... */
197
+ }
198
+ async put(
199
+ suite: string,
200
+ lcov: Buffer,
201
+ meta?: { sha: string; branch: string; timestamp?: string },
202
+ ): Promise<void> {
203
+ /* ... */
204
+ }
205
+ }
148
206
  ```
149
207
 
150
208
  ## License
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../dist/src/cli.mjs";
3
+ /* c8 ignore next */
4
+ process.exit(await main(process.argv.slice(2)));
@@ -0,0 +1 @@
1
+ export declare function main(argv: string[]): Promise<number>;
@@ -0,0 +1,14 @@
1
+ import { main as checkMain } from "./commands/check.mjs";
2
+ import { main as storePutMain } from "./commands/store-put.mjs";
3
+ const stderr = (msg) => process.stderr.write(`${msg}\n`);
4
+ export async function main(argv) {
5
+ const sub = argv[0];
6
+ if (!sub || sub.startsWith("-"))
7
+ return checkMain(argv);
8
+ if (sub === "check")
9
+ return checkMain(argv.slice(1));
10
+ if (sub === "store-put")
11
+ return storePutMain(argv.slice(1));
12
+ stderr(`coverage-check: unknown subcommand: ${JSON.stringify(sub)}`);
13
+ return 2;
14
+ }
@@ -0,0 +1,20 @@
1
+ import type { SuiteStore } from "../suite-store.mts";
2
+ import type { GhRunner } from "../github-comment.mts";
3
+ export type CheckArgs = {
4
+ rules: string;
5
+ artifacts: string;
6
+ base: string;
7
+ head: string;
8
+ pr: number | null;
9
+ repo: string;
10
+ json: string | null;
11
+ stripPrefixes: string[];
12
+ store: SuiteStore | null;
13
+ suite: string | null;
14
+ /** Branch used to resolve baseline from the store. Default: "main". */
15
+ branch?: string;
16
+ gh?: GhRunner;
17
+ /** Path to append the GitHub step summary. Default: $GITHUB_STEP_SUMMARY. */
18
+ summaryFile?: string | null;
19
+ };
20
+ export declare function parseCheckArgs(argv: string[]): CheckArgs;
@@ -0,0 +1,89 @@
1
+ import { makeStore } from "../store-factory.mjs";
2
+ import { assertSafePathComponent } from "../suite-store.mjs";
3
+ export function parseCheckArgs(argv) {
4
+ let storeFs = null;
5
+ let storeS3 = null;
6
+ const args = {
7
+ rules: ".coverage-rules.yml",
8
+ artifacts: "./coverage-artifacts",
9
+ base: "origin/main",
10
+ head: "HEAD",
11
+ pr: null,
12
+ repo: process.env["GITHUB_REPOSITORY"] ?? "",
13
+ json: null,
14
+ stripPrefixes: [],
15
+ store: null,
16
+ suite: null,
17
+ branch: "main",
18
+ summaryFile: process.env["GITHUB_STEP_SUMMARY"] ?? null,
19
+ };
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const flag = argv[i];
22
+ const next = argv[i + 1];
23
+ const val = () => {
24
+ if (next === undefined || next.startsWith("--")) {
25
+ throw new Error(`${flag} requires a value`);
26
+ }
27
+ i++;
28
+ return next;
29
+ };
30
+ switch (flag) {
31
+ case "--rules":
32
+ args.rules = val();
33
+ break;
34
+ case "--artifacts":
35
+ args.artifacts = val();
36
+ break;
37
+ case "--base":
38
+ args.base = val();
39
+ break;
40
+ case "--head":
41
+ args.head = val();
42
+ break;
43
+ case "--repo":
44
+ args.repo = val();
45
+ break;
46
+ case "--json":
47
+ args.json = val();
48
+ break;
49
+ case "--suite": {
50
+ const s = val();
51
+ assertSafePathComponent(s, "suite");
52
+ args.suite = s;
53
+ break;
54
+ }
55
+ case "--strip-prefix":
56
+ args.stripPrefixes.push(val());
57
+ break;
58
+ case "--branch": {
59
+ const branch = val();
60
+ if (branch.length === 0)
61
+ throw new Error(`invalid branch: ${JSON.stringify(branch)}`);
62
+ args.branch = branch;
63
+ break;
64
+ }
65
+ case "--store":
66
+ case "--store-fs":
67
+ storeFs = val();
68
+ break;
69
+ case "--store-s3":
70
+ storeS3 = val();
71
+ break;
72
+ case "--pr": {
73
+ const raw = val();
74
+ if (!/^\d+$/.test(raw) || raw === "0")
75
+ throw new Error(`--pr must be a positive integer, got: ${JSON.stringify(raw)}`);
76
+ args.pr = parseInt(raw, 10);
77
+ break;
78
+ }
79
+ default:
80
+ throw new Error(`unknown flag: ${flag}`);
81
+ }
82
+ }
83
+ if (storeFs && storeS3)
84
+ throw new Error("--store-fs and --store-s3 are mutually exclusive");
85
+ if (args.pr !== null && args.repo.trim() === "")
86
+ throw new Error("--repo is required when --pr is set (or define GITHUB_REPOSITORY)");
87
+ args.store = makeStore({ fs: storeFs, s3: storeS3 });
88
+ return args;
89
+ }
@@ -0,0 +1,4 @@
1
+ import type { CheckArgs } from "./check-args.mts";
2
+ export type { CheckArgs } from "./check-args.mts";
3
+ export declare function main(argv: string[]): Promise<number>;
4
+ export declare function runCheck(args: CheckArgs): Promise<number>;
@@ -0,0 +1,128 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { parseLcov } from "../lcov-parser.mjs";
3
+ import { mergeLcov } from "../lcov-merge.mjs";
4
+ import { getChangedLines } from "../diff-parser.mjs";
5
+ import { loadRules } from "../rules.mjs";
6
+ import { computePatchCoverage } from "../patch-coverage.mjs";
7
+ import { collapseRanges, renderFailureComment } from "../report.mjs";
8
+ import { upsertComment } from "../github-comment.mjs";
9
+ import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
10
+ import { writeSummary } from "../step-summary.mjs";
11
+ import { parseCheckArgs } from "./check-args.mjs";
12
+ const stdout = (msg) => process.stdout.write(`${msg}\n`);
13
+ const stderr = (msg) => process.stderr.write(`${msg}\n`);
14
+ export async function main(argv) {
15
+ let args;
16
+ try {
17
+ args = parseCheckArgs(argv);
18
+ }
19
+ catch (err) {
20
+ stderr(`coverage-check: ${String(err)}`);
21
+ return 2;
22
+ }
23
+ return runCheck(args);
24
+ }
25
+ export async function runCheck(args) {
26
+ let rules;
27
+ try {
28
+ rules = loadRules(args.rules);
29
+ }
30
+ catch (err) {
31
+ stderr(`coverage-check: failed to load rules: ${err}`);
32
+ return 2;
33
+ }
34
+ const branch = args.branch ?? "main";
35
+ const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
36
+ const reports = [];
37
+ const suiteSources = [];
38
+ if (args.store !== null) {
39
+ const suites = await args.store.list();
40
+ for (const suite of suites) {
41
+ if (suite === args.suite)
42
+ continue;
43
+ const buf = await args.store.get(suite, { branch });
44
+ if (buf !== null) {
45
+ const lcov = parseLcov(buf.toString("utf8"), stripPrefixes);
46
+ reports.push(lcov);
47
+ suiteSources.push({ suite, source: "store", lcov });
48
+ }
49
+ }
50
+ }
51
+ const lcovFiles = collectLcovFiles(args.artifacts);
52
+ const freshLcovs = [];
53
+ for (const f of lcovFiles) {
54
+ const lcov = parseLcov(readFileSync(f, "utf8"), stripPrefixes);
55
+ reports.push(lcov);
56
+ freshLcovs.push(lcov);
57
+ }
58
+ if (freshLcovs.length > 0) {
59
+ suiteSources.push({
60
+ suite: args.suite ?? "(current)",
61
+ source: "fresh",
62
+ lcov: mergeLcov(freshLcovs),
63
+ });
64
+ }
65
+ if (reports.length === 0) {
66
+ stderr(`coverage-check: no coverage data found — skipping`);
67
+ return 0;
68
+ }
69
+ const lcov = mergeLcov(reports);
70
+ let diff;
71
+ try {
72
+ diff = await getChangedLines(args.base, args.head);
73
+ }
74
+ catch (err) {
75
+ stderr(`coverage-check: git diff failed: ${err}`);
76
+ return 2;
77
+ }
78
+ const { buckets, informational } = computePatchCoverage(diff, lcov, rules);
79
+ const passed = buckets.every((b) => b.passed);
80
+ const result = { buckets, informational, passed };
81
+ if (args.json) {
82
+ writeFileSync(args.json, JSON.stringify(result, null, 2));
83
+ }
84
+ const runUrl = process.env["GITHUB_SERVER_URL"] && process.env["GITHUB_RUN_ID"]
85
+ ? `${process.env["GITHUB_SERVER_URL"]}/${args.repo}/actions/runs/${process.env["GITHUB_RUN_ID"]}`
86
+ : "N/A";
87
+ if (!passed) {
88
+ stdout("\ncoverage-check: FAILED\n");
89
+ for (const bucket of buckets.filter((b) => !b.passed)) {
90
+ /* c8 ignore next -- bucket.coverable is always > 0 by patch-coverage.mts L36 guard */
91
+ const pct = bucket.coverable > 0 ? `${((bucket.hit / bucket.coverable) * 100).toFixed(1)}%` : "—";
92
+ stdout(` ${bucket.rule}: ${pct} (${bucket.hit}/${bucket.coverable}) — threshold ${bucket.threshold}%`);
93
+ for (const file of bucket.files.filter((f) => f.uncoveredLines.length > 0)) {
94
+ stdout(` ${file.file}: ${collapseRanges(file.uncoveredLines)}`);
95
+ }
96
+ }
97
+ }
98
+ else {
99
+ stdout("\ncoverage-check: PASSED\n");
100
+ for (const bucket of buckets) {
101
+ /* c8 ignore next -- bucket.coverable is always > 0 by patch-coverage.mts L36 guard */
102
+ const pct = bucket.coverable > 0 ? `${((bucket.hit / bucket.coverable) * 100).toFixed(1)}%` : "—";
103
+ stdout(` ${bucket.rule}: ${pct} ✓`);
104
+ }
105
+ }
106
+ const summaryFile = args.summaryFile !== undefined
107
+ ? args.summaryFile
108
+ : (process.env["GITHUB_STEP_SUMMARY"] ?? null);
109
+ if (summaryFile) {
110
+ try {
111
+ writeSummary(summaryFile, suiteSources, result, runUrl, branch);
112
+ }
113
+ catch (err) {
114
+ stderr(`coverage-check: failed to write step summary: ${err}`);
115
+ return 2;
116
+ }
117
+ }
118
+ if (args.pr !== null && args.repo) {
119
+ const body = passed ? "" : renderFailureComment(result, runUrl);
120
+ try {
121
+ await upsertComment(body, args.repo, args.pr, passed, args.gh);
122
+ }
123
+ catch (err) {
124
+ stderr(`coverage-check: failed to post PR comment: ${err}`);
125
+ }
126
+ }
127
+ return passed ? 0 : 1;
128
+ }
@@ -0,0 +1,11 @@
1
+ import type { SuiteStore } from "../suite-store.mts";
2
+ export type StorePutArgs = {
3
+ suite: string;
4
+ store: SuiteStore;
5
+ artifacts: string;
6
+ stripPrefixes: string[];
7
+ sha?: string;
8
+ branch?: string;
9
+ };
10
+ export declare function main(argv: string[]): Promise<number>;
11
+ export declare function runStorePut(args: StorePutArgs): Promise<number>;
@@ -0,0 +1,104 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseLcov } from "../lcov-parser.mjs";
3
+ import { mergeLcov, toLcov } from "../lcov-merge.mjs";
4
+ import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
5
+ import { makeStore } from "../store-factory.mjs";
6
+ import { assertSafePathComponent } from "../suite-store.mjs";
7
+ const stdout = (msg) => process.stdout.write(`${msg}\n`);
8
+ const stderr = (msg) => process.stderr.write(`${msg}\n`);
9
+ function parseArgs(argv) {
10
+ let storeFs = null;
11
+ let storeS3 = null;
12
+ const args = {
13
+ suite: "",
14
+ artifacts: "./coverage-artifacts",
15
+ stripPrefixes: [],
16
+ sha: undefined,
17
+ branch: undefined,
18
+ };
19
+ for (let i = 0; i < argv.length; i++) {
20
+ const flag = argv[i];
21
+ const next = argv[i + 1];
22
+ const val = () => {
23
+ if (next === undefined || next.startsWith("--")) {
24
+ throw new Error(`${flag} requires a value`);
25
+ }
26
+ i++;
27
+ return next;
28
+ };
29
+ switch (flag) {
30
+ case "--suite":
31
+ args.suite = val();
32
+ break;
33
+ case "--store":
34
+ case "--store-fs":
35
+ storeFs = val();
36
+ break;
37
+ case "--store-s3":
38
+ storeS3 = val();
39
+ break;
40
+ case "--artifacts":
41
+ args.artifacts = val();
42
+ break;
43
+ case "--strip-prefix":
44
+ args.stripPrefixes.push(val());
45
+ break;
46
+ case "--sha":
47
+ args.sha = val();
48
+ break;
49
+ case "--branch":
50
+ args.branch = val();
51
+ break;
52
+ default:
53
+ throw new Error(`unknown flag: ${flag}`);
54
+ }
55
+ }
56
+ if (!args.suite)
57
+ throw new Error("--suite is required");
58
+ if (storeFs && storeS3)
59
+ throw new Error("--store-fs and --store-s3 are mutually exclusive");
60
+ if (!storeFs && !storeS3)
61
+ throw new Error("--store-fs/--store or --store-s3 is required");
62
+ const hasSha = args.sha !== undefined;
63
+ const hasBranch = args.branch !== undefined;
64
+ if (hasSha !== hasBranch) {
65
+ throw new Error("--sha and --branch must be provided together");
66
+ }
67
+ assertSafePathComponent(args.suite, "suite");
68
+ if (args.sha !== undefined)
69
+ assertSafePathComponent(args.sha, "sha");
70
+ if (args.branch !== undefined && args.branch.length === 0) {
71
+ throw new Error(`invalid branch: ${JSON.stringify(args.branch)}`);
72
+ }
73
+ const store = makeStore({ fs: storeFs, s3: storeS3 });
74
+ return { ...args, store };
75
+ }
76
+ export async function main(argv) {
77
+ let args;
78
+ try {
79
+ args = parseArgs(argv);
80
+ }
81
+ catch (err) {
82
+ stderr(`coverage-check store-put: ${String(err)}`);
83
+ return 2;
84
+ }
85
+ return runStorePut(args);
86
+ }
87
+ export async function runStorePut(args) {
88
+ const lcovFiles = collectLcovFiles(args.artifacts);
89
+ if (lcovFiles.length === 0) {
90
+ stderr(`coverage-check store-put: no lcov.info files found under ${args.artifacts}`);
91
+ return 2;
92
+ }
93
+ const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
94
+ const reports = lcovFiles.map((f) => parseLcov(readFileSync(f, "utf8"), stripPrefixes));
95
+ const merged = mergeLcov(reports);
96
+ const lcovText = toLcov(merged);
97
+ const meta = args.sha !== undefined && args.branch !== undefined
98
+ ? { sha: args.sha, branch: args.branch }
99
+ : undefined;
100
+ await args.store.put(args.suite, Buffer.from(lcovText, "utf8"), meta);
101
+ const metaLabel = args.sha !== undefined ? ` sha=${args.sha} branch=${args.branch}` : "";
102
+ stdout(`coverage-check store-put: stored suite "${args.suite}" (${lcovFiles.length} file(s))${metaLabel}`);
103
+ return 0;
104
+ }
@@ -1,15 +1,9 @@
1
1
  export { runCheck } from "./commands/check.mts";
2
2
  export { runStorePut } from "./commands/store-put.mts";
3
3
  export { FileSystemSuiteStore } from "./suite-store.mts";
4
-
4
+ export { S3SuiteStore } from "./s3-suite-store.mts";
5
5
  export type { CheckArgs } from "./commands/check.mts";
6
6
  export type { StorePutArgs } from "./commands/store-put.mts";
7
7
  export type { SuiteStore, SuiteMeta } from "./suite-store.mts";
8
- export type {
9
- CoverageCheckResult,
10
- BucketResult,
11
- FileCoverageResult,
12
- LcovData,
13
- DiffLines,
14
- CoverageRule,
15
- } from "./types.mts";
8
+ export type { S3SuiteStoreOptions } from "./s3-suite-store.mts";
9
+ export type { CoverageCheckResult, BucketResult, FileCoverageResult, LcovData, DiffLines, CoverageRule, } from "./types.mts";
@@ -0,0 +1,4 @@
1
+ export { runCheck } from "./commands/check.mjs";
2
+ export { runStorePut } from "./commands/store-put.mjs";
3
+ export { FileSystemSuiteStore } from "./suite-store.mjs";
4
+ export { S3SuiteStore } from "./s3-suite-store.mjs";
@@ -0,0 +1,17 @@
1
+ import type { DiffLines } from "./types.mts";
2
+ /**
3
+ * Decodes a git C-string (inner content between surrounding double-quotes).
4
+ * Git quotes unusual paths (non-ASCII, spaces, etc.) with core.quotePath=true.
5
+ * Handles octal byte escapes (\nnn), \\, \", \n, \t.
6
+ */
7
+ export declare function decodeGitCString(s: string): string;
8
+ /**
9
+ * Parses the output of `git diff --unified=0` into a map of
10
+ * repo-root-relative file path → set of added/modified line numbers.
11
+ *
12
+ * Only added lines (lines in the new version) are tracked. Deleted-only
13
+ * hunks (where the `+` count is 0) are skipped.
14
+ */
15
+ export declare function parseDiff(text: string): DiffLines;
16
+ /** Runs git diff and returns the parsed result. */
17
+ export declare function getChangedLines(baseRef: string, headRef: string): Promise<DiffLines>;