coverage-check 0.1.1 → 0.2.1

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 CHANGED
@@ -22,46 +22,66 @@ 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:**
40
52
 
41
- # --- On the next PR that runs only frontend tests ---
53
+ ```text
54
+ <prefix>/<suite>/sha/<sha>/lcov.info # payload
55
+ <prefix>/<suite>/branch/<branch>/latest.json # pointer: { "sha": "...", "timestamp": "..." }
56
+ ```
42
57
 
43
- # Pull the store
44
- aws s3 sync s3://my-bucket/coverage-store/ ./coverage-store
58
+ ### Suite store with filesystem
45
59
 
46
- # Store-put the current suite
47
- coverage-check store-put --suite frontend --store ./coverage-store --artifacts ./coverage-artifacts
60
+ For local development or simpler deployments:
61
+
62
+ ```sh
63
+ coverage-check store-put \
64
+ --suite backend \
65
+ --store-fs ./coverage-store \
66
+ --artifacts ./coverage-artifacts \
67
+ --sha "$GITHUB_SHA" \
68
+ --branch main
48
69
 
49
- # Check: merges all stored suites (backend from baseline + frontend from this run)
50
70
  coverage-check check \
51
71
  --rules .coverage-rules.yml \
52
72
  --artifacts ./coverage-artifacts \
53
- --store ./coverage-store \
73
+ --store-fs ./coverage-store \
54
74
  --suite frontend \
55
75
  --base origin/main \
56
76
  --head HEAD
57
77
  ```
58
78
 
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
79
  ### GitHub PR sticky comment
62
80
 
63
81
  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
82
 
83
+ 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.
84
+
65
85
  ```sh
66
86
  coverage-check check \
67
87
  --rules .coverage-rules.yml \
@@ -70,6 +90,10 @@ coverage-check check \
70
90
  --repo "${{ github.repository }}"
71
91
  ```
72
92
 
93
+ ### GitHub Actions step summary
94
+
95
+ When `$GITHUB_STEP_SUMMARY` is set, a per-suite totals and per-rule patch-coverage table is appended to the job summary automatically.
96
+
73
97
  ## Rules file
74
98
 
75
99
  ```yaml
@@ -89,49 +113,47 @@ Rules are matched in order; the first match wins. Files in the diff not matched
89
113
 
90
114
  ### `coverage-check check`
91
115
 
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 |
116
+ | Flag | Default | Description |
117
+ | ---------------- | ---------------------- | -------------------------------------------------------------------------------------------- |
118
+ | `--rules` | `.coverage-rules.yml` | Path to YAML rules file |
119
+ | `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
120
+ | `--base` | `origin/main` | Base git ref for `git diff` |
121
+ | `--head` | `HEAD` | Head git ref for `git diff` |
122
+ | `--store-fs` | — | Path to a filesystem suite store directory |
123
+ | `--store` | — | Alias for `--store-fs` |
124
+ | `--store-s3` | — | S3 suite store spec: `<bucket>[/<prefix>]` |
125
+ | `--branch` | `"main"` | Branch pointer to follow when reading from the store (no `/` or `\\`) |
126
+ | `--suite` | | Name of the current suite (no `/` or `\\`); fresh artifacts override this suite in the store |
127
+ | `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
128
+ | `--pr` | — | Pull request number for sticky comment |
129
+ | `--repo` | `$GITHUB_REPOSITORY` | `owner/repo` for sticky comment |
130
+ | `--json` | — | Write JSON result to this path |
104
131
 
105
132
  ### `coverage-check store-put`
106
133
 
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 |
134
+ | Flag | Default | Description |
135
+ | ---------------- | ---------------------- | ------------------------------------------------------------- |
136
+ | `--suite` | required | Suite name to store |
137
+ | `--store-fs` | required\* | Path to a filesystem suite store directory |
138
+ | `--store` | | Alias for `--store-fs` |
139
+ | `--store-s3` | required\* | S3 suite store spec: `<bucket>[/<prefix>]` |
140
+ | `--sha` | required | Git SHA to associate with this coverage payload |
141
+ | `--branch` | required | Branch name for the pointer (e.g. `main`) |
142
+ | `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
143
+ | `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
144
+
145
+ \* Exactly one of `--store-fs` or `--store-s3` is required.
115
146
 
116
147
  ## Programmatic API
117
148
 
118
149
  ```ts
119
- import { runCheck, runStorePut, FileSystemSuiteStore } from "coverage-check";
150
+ import { runCheck, runStorePut, FileSystemSuiteStore, S3SuiteStore } from "coverage-check";
120
151
 
121
- // Custom store adapter (e.g. S3)
122
- import { SuiteStore } from "coverage-check";
152
+ // FileSystem store
153
+ const fsStore = new FileSystemSuiteStore("/path/to/store");
123
154
 
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
- }
155
+ // S3 store (requires AWS credentials — see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html)
156
+ const s3Store = new S3SuiteStore({ bucket: "my-bucket", prefix: "coverage" });
135
157
 
136
158
  await runCheck({
137
159
  rules: ".coverage-rules.yml",
@@ -142,9 +164,41 @@ await runCheck({
142
164
  repo: "",
143
165
  json: null,
144
166
  stripPrefixes: [],
145
- store: new S3SuiteStore(),
167
+ store: s3Store,
146
168
  suite: "backend",
169
+ branch: "main",
147
170
  });
171
+
172
+ await runStorePut({
173
+ suite: "backend",
174
+ store: s3Store,
175
+ artifacts: "./coverage",
176
+ stripPrefixes: [],
177
+ sha: "abc123",
178
+ branch: "main",
179
+ });
180
+ ```
181
+
182
+ You can also implement your own `SuiteStore`:
183
+
184
+ ```ts
185
+ import type { SuiteStore } from "coverage-check";
186
+
187
+ class MyCustomStore implements SuiteStore {
188
+ async list(): Promise<string[]> {
189
+ /* ... */
190
+ }
191
+ async get(suite: string, opts?: { sha?: string; branch?: string }): Promise<Buffer | null> {
192
+ /* ... */
193
+ }
194
+ async put(
195
+ suite: string,
196
+ lcov: Buffer,
197
+ meta: { sha: string; branch: string; timestamp?: string },
198
+ ): Promise<void> {
199
+ /* ... */
200
+ }
201
+ }
148
202
  ```
149
203
 
150
204
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coverage-check",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Patch-coverage gate: checks that newly added lines meet per-path coverage thresholds. Supports per-suite LCOV accumulation for conditional CI.",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -18,6 +18,7 @@
18
18
  "node": ">=22.0.0"
19
19
  },
20
20
  "dependencies": {
21
+ "@aws-sdk/client-s3": "^3.1048.0",
21
22
  "js-yaml": "^4.1.1"
22
23
  },
23
24
  "devDependencies": {
@@ -0,0 +1,110 @@
1
+ import { makeStore } from "../store-factory.mts";
2
+ import { assertSafePathComponent } from "../suite-store.mts";
3
+ import type { SuiteStore } from "../suite-store.mts";
4
+ import type { GhRunner } from "../github-comment.mts";
5
+
6
+ export type CheckArgs = {
7
+ rules: string;
8
+ artifacts: string;
9
+ base: string;
10
+ head: string;
11
+ pr: number | null;
12
+ repo: string;
13
+ json: string | null;
14
+ stripPrefixes: string[];
15
+ store: SuiteStore | null;
16
+ suite: string | null;
17
+ /** Branch used to resolve baseline from the store. Default: "main". */
18
+ branch?: string;
19
+ gh?: GhRunner;
20
+ /** Path to append the GitHub step summary. Default: $GITHUB_STEP_SUMMARY. */
21
+ summaryFile?: string | null;
22
+ };
23
+
24
+ export function parseCheckArgs(argv: string[]): CheckArgs {
25
+ let storeFs: string | null = null;
26
+ let storeS3: string | null = null;
27
+ const args: Omit<CheckArgs, "store"> & { store: SuiteStore | null } = {
28
+ rules: ".coverage-rules.yml",
29
+ artifacts: "./coverage-artifacts",
30
+ base: "origin/main",
31
+ head: "HEAD",
32
+ pr: null,
33
+ repo: process.env["GITHUB_REPOSITORY"] ?? "",
34
+ json: null,
35
+ stripPrefixes: [],
36
+ store: null,
37
+ suite: null,
38
+ branch: "main",
39
+ summaryFile: process.env["GITHUB_STEP_SUMMARY"] ?? null,
40
+ };
41
+
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const flag = argv[i]!;
44
+ const next = argv[i + 1];
45
+ const val = (): string => {
46
+ if (next === undefined || next.startsWith("--")) {
47
+ throw new Error(`${flag} requires a value`);
48
+ }
49
+ i++;
50
+ return next;
51
+ };
52
+ switch (flag) {
53
+ case "--rules":
54
+ args.rules = val();
55
+ break;
56
+ case "--artifacts":
57
+ args.artifacts = val();
58
+ break;
59
+ case "--base":
60
+ args.base = val();
61
+ break;
62
+ case "--head":
63
+ args.head = val();
64
+ break;
65
+ case "--repo":
66
+ args.repo = val();
67
+ break;
68
+ case "--json":
69
+ args.json = val();
70
+ break;
71
+ case "--suite": {
72
+ const s = val();
73
+ assertSafePathComponent(s, "suite");
74
+ args.suite = s;
75
+ break;
76
+ }
77
+ case "--strip-prefix":
78
+ args.stripPrefixes.push(val());
79
+ break;
80
+ case "--branch": {
81
+ const b = val();
82
+ assertSafePathComponent(b, "branch");
83
+ args.branch = b;
84
+ break;
85
+ }
86
+ case "--store":
87
+ case "--store-fs":
88
+ storeFs = val();
89
+ break;
90
+ case "--store-s3":
91
+ storeS3 = val();
92
+ break;
93
+ case "--pr": {
94
+ const raw = val();
95
+ if (!/^\d+$/.test(raw) || raw === "0")
96
+ throw new Error(`--pr must be a positive integer, got: ${JSON.stringify(raw)}`);
97
+ args.pr = parseInt(raw, 10);
98
+ break;
99
+ }
100
+ default:
101
+ throw new Error(`unknown flag: ${flag}`);
102
+ }
103
+ }
104
+
105
+ if (storeFs && storeS3) throw new Error("--store-fs and --store-s3 are mutually exclusive");
106
+ if (args.pr !== null && args.repo.trim() === "")
107
+ throw new Error("--repo is required when --pr is set (or define GITHUB_REPOSITORY)");
108
+ args.store = makeStore({ fs: storeFs, s3: storeS3 });
109
+ return args;
110
+ }
@@ -4,103 +4,25 @@ import { mergeLcov } from "../lcov-merge.mts";
4
4
  import { getChangedLines } from "../diff-parser.mts";
5
5
  import { loadRules } from "../rules.mts";
6
6
  import { computePatchCoverage } from "../patch-coverage.mts";
7
- import { collapseRanges, renderFailureComment, renderPassComment } from "../report.mts";
7
+ import { collapseRanges, renderFailureComment } from "../report.mts";
8
8
  import { upsertComment } from "../github-comment.mts";
9
9
  import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mts";
10
- import { FileSystemSuiteStore } from "../suite-store.mts";
10
+ import { writeSummary } from "../step-summary.mts";
11
+ import { parseCheckArgs } from "./check-args.mts";
12
+ import type { CheckArgs } from "./check-args.mts";
13
+ import type { SuiteSource } from "../step-summary.mts";
11
14
  import type { LcovData } from "../types.mts";
12
- import type { SuiteStore } from "../suite-store.mts";
13
- import type { GhRunner } from "../github-comment.mts";
15
+ export type { CheckArgs } from "./check-args.mts";
14
16
 
15
17
  const stdout = (msg: string) => process.stdout.write(`${msg}\n`);
16
18
  const stderr = (msg: string) => process.stderr.write(`${msg}\n`);
17
19
 
18
- export type CheckArgs = {
19
- rules: string;
20
- artifacts: string;
21
- base: string;
22
- head: string;
23
- pr: number | null;
24
- repo: string;
25
- json: string | null;
26
- stripPrefixes: string[];
27
- store: SuiteStore | null;
28
- suite: string | null;
29
- gh?: GhRunner;
30
- };
31
-
32
- function parseArgs(argv: string[]): CheckArgs {
33
- const args: CheckArgs = {
34
- rules: ".coverage-rules.yml",
35
- artifacts: "./coverage-artifacts",
36
- base: "origin/main",
37
- head: "HEAD",
38
- pr: null,
39
- repo: process.env["GITHUB_REPOSITORY"] ?? "",
40
- json: null,
41
- stripPrefixes: [],
42
- store: null,
43
- suite: null,
44
- };
45
-
46
- for (let i = 0; i < argv.length; i++) {
47
- const flag = argv[i]!;
48
- const next = argv[i + 1];
49
- const val = (): string => {
50
- if (next === undefined) throw new Error(`${flag} requires a value`);
51
- i++;
52
- return next;
53
- };
54
- switch (flag) {
55
- case "--rules":
56
- args.rules = val();
57
- break;
58
- case "--artifacts":
59
- args.artifacts = val();
60
- break;
61
- case "--base":
62
- args.base = val();
63
- break;
64
- case "--head":
65
- args.head = val();
66
- break;
67
- case "--pr": {
68
- const raw = val();
69
- if (!/^\d+$/.test(raw) || raw === "0")
70
- throw new Error(`--pr must be a positive integer, got: ${JSON.stringify(raw)}`);
71
- args.pr = parseInt(raw, 10);
72
- break;
73
- }
74
- case "--repo":
75
- args.repo = val();
76
- break;
77
- case "--json":
78
- args.json = val();
79
- break;
80
- case "--strip-prefix":
81
- args.stripPrefixes.push(val());
82
- break;
83
- case "--store":
84
- args.store = new FileSystemSuiteStore(val());
85
- break;
86
- case "--suite":
87
- args.suite = val();
88
- break;
89
- default:
90
- throw new Error(`unknown flag: ${flag}`);
91
- }
92
- }
93
-
94
- return args;
95
- }
96
-
97
20
  export async function main(argv: string[]): Promise<number> {
98
21
  let args: CheckArgs;
99
22
  try {
100
- args = parseArgs(argv);
23
+ args = parseCheckArgs(argv);
101
24
  } catch (err) {
102
- /* c8 ignore next */
103
- stderr(`coverage-check: ${err instanceof Error ? err.message : err}`);
25
+ stderr(`coverage-check: ${String(err)}`);
104
26
  return 2;
105
27
  }
106
28
  return runCheck(args);
@@ -115,25 +37,37 @@ export async function runCheck(args: CheckArgs): Promise<number> {
115
37
  return 2;
116
38
  }
117
39
 
40
+ const branch = args.branch ?? "main";
118
41
  const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
119
42
  const reports: LcovData[] = [];
43
+ const suiteSources: SuiteSource[] = [];
120
44
 
121
- // Merge in suites from the store (skip the current suite — fresh artifacts take precedence)
122
45
  if (args.store !== null) {
123
46
  const suites = await args.store.list();
124
47
  for (const suite of suites) {
125
48
  if (suite === args.suite) continue;
126
- const buf = await args.store.get(suite);
49
+ const buf = await args.store.get(suite, { branch });
127
50
  if (buf !== null) {
128
- reports.push(parseLcov(buf.toString("utf8"), stripPrefixes));
51
+ const lcov = parseLcov(buf.toString("utf8"), stripPrefixes);
52
+ reports.push(lcov);
53
+ suiteSources.push({ suite, source: "store", lcov });
129
54
  }
130
55
  }
131
56
  }
132
57
 
133
- // Add current run's lcov files
134
58
  const lcovFiles = collectLcovFiles(args.artifacts);
59
+ const freshLcovs: LcovData[] = [];
135
60
  for (const f of lcovFiles) {
136
- reports.push(parseLcov(readFileSync(f, "utf8"), stripPrefixes));
61
+ const lcov = parseLcov(readFileSync(f, "utf8"), stripPrefixes);
62
+ reports.push(lcov);
63
+ freshLcovs.push(lcov);
64
+ }
65
+ if (freshLcovs.length > 0) {
66
+ suiteSources.push({
67
+ suite: args.suite ?? "(current)",
68
+ source: "fresh",
69
+ lcov: mergeLcov(freshLcovs),
70
+ });
137
71
  }
138
72
 
139
73
  if (reports.length === 0) {
@@ -167,7 +101,7 @@ export async function runCheck(args: CheckArgs): Promise<number> {
167
101
  if (!passed) {
168
102
  stdout("\ncoverage-check: FAILED\n");
169
103
  for (const bucket of buckets.filter((b) => !b.passed)) {
170
- /* c8 ignore next -- buckets always have coverable>0 by construction */
104
+ /* c8 ignore next -- bucket.coverable is always > 0 by patch-coverage.mts L36 guard */
171
105
  const pct =
172
106
  bucket.coverable > 0 ? `${((bucket.hit / bucket.coverable) * 100).toFixed(1)}%` : "—";
173
107
  stdout(
@@ -180,15 +114,28 @@ export async function runCheck(args: CheckArgs): Promise<number> {
180
114
  } else {
181
115
  stdout("\ncoverage-check: PASSED\n");
182
116
  for (const bucket of buckets) {
183
- /* c8 ignore next -- buckets always have coverable>0 by construction */
117
+ /* c8 ignore next -- bucket.coverable is always > 0 by patch-coverage.mts L36 guard */
184
118
  const pct =
185
119
  bucket.coverable > 0 ? `${((bucket.hit / bucket.coverable) * 100).toFixed(1)}%` : "—";
186
120
  stdout(` ${bucket.rule}: ${pct} ✓`);
187
121
  }
188
122
  }
189
123
 
124
+ const summaryFile =
125
+ args.summaryFile !== undefined
126
+ ? args.summaryFile
127
+ : (process.env["GITHUB_STEP_SUMMARY"] ?? null);
128
+ if (summaryFile) {
129
+ try {
130
+ writeSummary(summaryFile, suiteSources, result, runUrl, branch);
131
+ } catch (err) {
132
+ stderr(`coverage-check: failed to write step summary: ${err}`);
133
+ return 2;
134
+ }
135
+ }
136
+
190
137
  if (args.pr !== null && args.repo) {
191
- const body = passed ? renderPassComment(runUrl) : renderFailureComment(result, runUrl);
138
+ const body = passed ? "" : renderFailureComment(result, runUrl);
192
139
  try {
193
140
  await upsertComment(body, args.repo, args.pr, passed, args.gh);
194
141
  } catch (err) {