coverage-check 0.7.0 → 0.8.0

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
@@ -51,12 +51,29 @@ The `--suite` flag on `check` tells the tool to use fresh `--artifacts` for the
51
51
  **S3 key layout:**
52
52
 
53
53
  ```text
54
- <prefix>/<suite>/sha/<sha>/lcov.info # payload
55
- <prefix>/<suite>/branch/<encoded-branch>/latest.json # pointer: { "sha": "...", "timestamp": "..." }
54
+ <prefix>/<suite>/sha/<sha>/lcov.info.gz # gzip payload for new versioned writes
55
+ <prefix>/<suite>/branch/<encoded-branch>/latest.json # pointer with sha, payloadKey, encoding, byte counts, timestamp
56
56
  ```
57
57
 
58
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.
59
59
 
60
+ Versioned S3 writes (`store-put --sha ... --branch ...`) gzip the LCOV payload and write pointer
61
+ metadata with `payloadKey`, `encoding`, `rawBytes`, and `storedBytes`. Existing raw
62
+ `sha/<sha>/lcov.info` payloads and legacy `<suite>/lcov.info` payloads remain readable. Legacy
63
+ writes without `--sha`/`--branch` keep the old raw `<suite>/lcov.info` layout.
64
+
65
+ Every S3 operation logs a concise diagnostic line to stderr with the operation name, bucket, key,
66
+ elapsed time, and byte counts where applicable. Use these lines to distinguish payload writes,
67
+ branch-pointer reads, and branch-pointer writes when CI storage stalls.
68
+
69
+ S3 request bounds are configurable with environment variables:
70
+
71
+ | Variable | Default | Purpose |
72
+ | ----------------------------------------- | ------- | ---------------------------------------- |
73
+ | `COVERAGE_CHECK_S3_CONNECTION_TIMEOUT_MS` | `5000` | Socket connection timeout for S3 calls |
74
+ | `COVERAGE_CHECK_S3_REQUEST_TIMEOUT_MS` | `30000` | Whole-request timeout for S3 calls |
75
+ | `COVERAGE_CHECK_S3_MAX_ATTEMPTS` | `2` | AWS SDK attempt count, including retries |
76
+
60
77
  ### Suite store with filesystem
61
78
 
62
79
  For local development or simpler deployments:
@@ -145,6 +162,22 @@ coverage-check check \
145
162
  --advisory
146
163
  ```
147
164
 
165
+ For wrappers that need machine-readable advisory results without a temp file, pass `--json -`.
166
+ This writes JSON-only output to stdout and suppresses the human report:
167
+
168
+ ```sh
169
+ coverage-check check \
170
+ --rules .coverage-rules.yml \
171
+ --artifacts ./coverage-artifacts \
172
+ --base origin/main \
173
+ --head HEAD \
174
+ --advisory \
175
+ --json -
176
+ ```
177
+
178
+ JSON output keeps the normal check result fields (`passed`, `buckets`, `drops`, `informational`) and
179
+ adds `exitCode`, `advisory`, and `skipped`.
180
+
148
181
  ### PR-scoped regression gate
149
182
 
150
183
  By default, `no_coverage_drop` applies to every rule area regardless of what changed. Pass `--drop-only-changed-areas` to restrict the drop gate to rule areas that contain at least one changed file in the PR diff. Areas with no changed files are reported as `skipped` — they pass non-blockingly:
@@ -174,6 +207,24 @@ coverage-check check \
174
207
 
175
208
  `--require-artifact` is also accepted by `coverage-check merge`.
176
209
 
210
+ ### Preparing fan-in artifacts
211
+
212
+ Use `prepare-artifacts` before a fan-in coverage check when downloaded artifacts may be either
213
+ nested as `coverage-<suite>/lcov.info` or flattened to a root-level `lcov.info`. Pass one
214
+ `--expect-suite <job>=<suite>` for each successful coverage producer. The command normalizes a
215
+ single flat LCOV into the expected suite directory and fails if any expected LCOV is missing:
216
+
217
+ ```sh
218
+ coverage-check prepare-artifacts \
219
+ --artifacts ./coverage-artifacts \
220
+ --expect-suite test-backend=backend \
221
+ --expect-suite test-web=web
222
+ ```
223
+
224
+ For local or fan-in wrappers, `check --aggregate-artifacts` treats all fresh LCOV files as one source
225
+ for diagnostics, `check --fail-on-empty` exits `1` instead of skipping when no LCOV exists, and
226
+ `check --ignore-path <glob>` prepends a zero-threshold override for CI-only paths in that run.
227
+
177
228
  ## Rules file
178
229
 
179
230
  ```yaml
@@ -218,6 +269,8 @@ First-match-wins means that if you have a more specific rule before a broader on
218
269
 
219
270
  ## CLI reference
220
271
 
272
+ Run `coverage-check --help` or `coverage-check check --help` to print the available commands and check flags.
273
+
221
274
  ### `coverage-check check`
222
275
 
223
276
  | Flag | Default | Description |
@@ -234,11 +287,14 @@ First-match-wins means that if you have a more specific rule before a broader on
234
287
  | `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
235
288
  | `--pr` | — | Pull request number for sticky comment |
236
289
  | `--repo` | `$GITHUB_REPOSITORY` | `owner/repo` for sticky comment |
237
- | `--json` | — | Write JSON result to this path |
290
+ | `--json` | — | Write JSON result to this path; use `-` for JSON-only stdout |
238
291
  | `--annotate-source` | — | Print the trimmed source text of each uncovered line in stdout (does not alter PR comment or step summary) |
239
292
  | `--advisory` | — | Exit `0` even on shortfall; still prints, writes JSON, and posts PR comment |
240
293
  | `--drop-only-changed-areas` | — | Restrict `no_coverage_drop` to rule areas that have ≥1 changed file; others are reported as skipped |
241
294
  | `--require-artifact` | — | Fail (exit `2`) if this relative path is absent under `--artifacts` (repeatable) |
295
+ | `--fail-on-empty` | — | Exit `1` when no coverage data is found instead of skipping |
296
+ | `--aggregate-artifacts` | — | Treat fresh LCOV files as one source for diagnostics and summaries |
297
+ | `--ignore-path` | — | Prepend a zero-threshold override glob for this run (repeatable) |
242
298
 
243
299
  ### `coverage-check merge`
244
300
 
@@ -251,6 +307,13 @@ First-match-wins means that if you have a more specific rule before a broader on
251
307
 
252
308
  Hit counts are summed across all inputs. Function (`FN`/`FNDA`) and branch (`BRDA`) records are preserved; summary counters (`LF/LH/FNF/FNH/BRF/BRH`) are recomputed from the merged data.
253
309
 
310
+ ### `coverage-check prepare-artifacts`
311
+
312
+ | Flag | Default | Description |
313
+ | ---------------- | ---------------------- | ---------------------------------------------- |
314
+ | `--artifacts` | `./coverage-artifacts` | Coverage artifact directory to normalize/check |
315
+ | `--expect-suite` | — | Expected producer and suite as `<job>=<suite>` |
316
+
254
317
  ### `coverage-check store-put`
255
318
 
256
319
  | Flag | Default | Description |
@@ -272,10 +335,15 @@ When `--sha` and `--branch` are both provided, `store-put` writes a SHA-addresse
272
335
 
273
336
  ```ts
274
337
  import {
338
+ checkCoverage,
339
+ evaluateCheck,
275
340
  runCheck,
276
341
  runMerge,
277
342
  runStorePut,
343
+ prepareCoverageArtifacts,
278
344
  collapseRanges,
345
+ collectLcovFiles,
346
+ zeroThresholdGlobs,
279
347
  parseLcovFull,
280
348
  mergeLcovFull,
281
349
  toLcovFull,
@@ -289,6 +357,27 @@ const fsStore = new FileSystemSuiteStore("/path/to/store");
289
357
  // S3 store (requires AWS credentials — see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html)
290
358
  const s3Store = new S3SuiteStore({ bucket: "my-bucket", prefix: "coverage" });
291
359
 
360
+ const check = await checkCoverage({
361
+ rules: ".coverage-rules.yml",
362
+ artifacts: "./coverage",
363
+ base: "origin/main",
364
+ head: "HEAD",
365
+ pr: null,
366
+ repo: "",
367
+ json: null,
368
+ stripPrefixes: [],
369
+ store: s3Store,
370
+ suite: "backend",
371
+ branch: "main",
372
+ advisory: true,
373
+ dropOnlyChangedAreas: false,
374
+ requireArtifacts: [],
375
+ });
376
+
377
+ if (!check.result?.passed) {
378
+ console.log(check.exitCode, check.advisory, check.result);
379
+ }
380
+
292
381
  await runCheck({
293
382
  rules: ".coverage-rules.yml",
294
383
  artifacts: "./coverage",
@@ -304,7 +393,34 @@ await runCheck({
304
393
  advisory: false,
305
394
  dropOnlyChangedAreas: false,
306
395
  requireArtifacts: [],
396
+ failOnEmpty: false,
397
+ aggregateArtifacts: false,
398
+ ignorePaths: [],
399
+ });
400
+
401
+ const evaluated = await evaluateCheck({
402
+ rules: ".coverage-rules.yml",
403
+ artifacts: "./coverage",
404
+ base: "origin/main",
405
+ head: "HEAD",
406
+ pr: null,
407
+ repo: "",
408
+ json: null,
409
+ stripPrefixes: [],
410
+ store: null,
411
+ suite: null,
307
412
  });
413
+ if (evaluated.result?.passed === false) {
414
+ // Integrations can render their own advisory output from the typed result.
415
+ }
416
+
417
+ prepareCoverageArtifacts({
418
+ artifacts: "./coverage-artifacts",
419
+ expectedSuites: [{ job: "test-backend", suite: "backend" }],
420
+ });
421
+
422
+ collectLcovFiles("./coverage-artifacts");
423
+ zeroThresholdGlobs(".coverage-rules.yml");
308
424
 
309
425
  await runMerge({
310
426
  artifacts: "./coverage-artifacts",
package/dist/src/cli.mjs CHANGED
@@ -1,11 +1,18 @@
1
1
  import { main as checkMain } from "./commands/check.mjs";
2
+ import { checkHelp } from "./commands/check-render.mjs";
2
3
  import { main as storePutMain } from "./commands/store-put.mjs";
3
4
  import { main as htmlMain } from "./commands/html.mjs";
4
5
  import { main as mergeMain } from "./commands/merge.mjs";
5
6
  import { main as summaryMain } from "./commands/summary.mjs";
7
+ import { main as prepareArtifactsMain } from "./commands/prepare-artifacts.mjs";
6
8
  const stderr = (msg) => process.stderr.write(`${msg}\n`);
9
+ const stdout = (msg) => process.stdout.write(`${msg}\n`);
7
10
  export async function main(argv) {
8
11
  const sub = argv[0];
12
+ if (sub === "--help" || sub === "-h") {
13
+ stdout(help());
14
+ return 0;
15
+ }
9
16
  if (!sub || sub.startsWith("-"))
10
17
  return checkMain(argv);
11
18
  if (sub === "check")
@@ -18,6 +25,24 @@ export async function main(argv) {
18
25
  return htmlMain(argv.slice(1));
19
26
  if (sub === "summary")
20
27
  return summaryMain(argv.slice(1));
28
+ if (sub === "prepare-artifacts")
29
+ return prepareArtifactsMain(argv.slice(1));
21
30
  stderr(`coverage-check: unknown subcommand: ${JSON.stringify(sub)}`);
22
31
  return 2;
23
32
  }
33
+ function help() {
34
+ return `coverage-check
35
+
36
+ Usage:
37
+ coverage-check <command> [options]
38
+ coverage-check [check options]
39
+
40
+ Commands:
41
+ check Check patch coverage (default)
42
+ store-put Store suite LCOV data
43
+ merge Merge lcov.info files
44
+ html Generate HTML coverage reports
45
+ summary Generate a coverage summary
46
+
47
+ ${checkHelp()}`;
48
+ }
@@ -24,5 +24,11 @@ export type CheckArgs = {
24
24
  dropOnlyChangedAreas?: boolean;
25
25
  /** Relative paths under --artifacts that must exist before the check runs (repeatable). */
26
26
  requireArtifacts?: string[];
27
+ /** Exit non-zero when no LCOV data is available instead of treating the check as skipped. */
28
+ failOnEmpty?: boolean;
29
+ /** Merge fresh LCOV artifacts before evaluating, so fan-in artifacts are treated as one source. */
30
+ aggregateArtifacts?: boolean;
31
+ /** Path globs to exempt by prepending zero-threshold override rules (repeatable). */
32
+ ignorePaths?: string[];
27
33
  };
28
34
  export declare function parseCheckArgs(argv: string[]): CheckArgs;
@@ -1,5 +1,5 @@
1
1
  import { makeStore } from "../store-factory.mjs";
2
- import { assertSafePathComponent } from "../suite-store.mjs";
2
+ import { assertSafePathComponent, assertValidRepo } from "../suite-store.mjs";
3
3
  export function parseCheckArgs(argv) {
4
4
  let storeFs = null;
5
5
  let storeS3 = null;
@@ -20,6 +20,9 @@ export function parseCheckArgs(argv) {
20
20
  advisory: false,
21
21
  dropOnlyChangedAreas: false,
22
22
  requireArtifacts: [],
23
+ failOnEmpty: false,
24
+ aggregateArtifacts: false,
25
+ ignorePaths: [],
23
26
  };
24
27
  for (let i = 0; i < argv.length; i++) {
25
28
  const flag = argv[i];
@@ -92,14 +95,29 @@ export function parseCheckArgs(argv) {
92
95
  case "--require-artifact":
93
96
  args.requireArtifacts.push(val());
94
97
  break;
98
+ case "--fail-on-empty":
99
+ args.failOnEmpty = true;
100
+ break;
101
+ case "--aggregate-artifacts":
102
+ args.aggregateArtifacts = true;
103
+ break;
104
+ case "--ignore-path":
105
+ args.ignorePaths.push(val());
106
+ break;
95
107
  default:
96
108
  throw new Error(`unknown flag: ${flag}`);
97
109
  }
98
110
  }
99
111
  if (storeFs && storeS3)
100
112
  throw new Error("--store-fs and --store-s3 are mutually exclusive");
101
- if (args.pr !== null && args.repo.trim() === "")
113
+ const repo = args.repo.trim();
114
+ args.repo = repo;
115
+ if (args.pr !== null && repo.length === 0) {
102
116
  throw new Error("--repo is required when --pr is set (or define GITHUB_REPOSITORY)");
117
+ }
118
+ if (repo !== "") {
119
+ args.repo = assertValidRepo(repo);
120
+ }
103
121
  args.store = makeStore({ fs: storeFs, s3: storeS3 });
104
122
  return args;
105
123
  }
@@ -4,8 +4,13 @@ import type { DropResult, DiffLines, LcovData } from "../types.mts";
4
4
  * Prints `::error::` to stderr for each missing file and returns false if any are absent.
5
5
  */
6
6
  export declare function checkRequiredArtifacts(artifactsDir: string, requireArtifacts: string[]): boolean;
7
+ export declare function missingRequiredArtifacts(artifactsDir: string, requireArtifacts: string[]): string[];
7
8
  export declare function warnNonContributing(parsedSources: {
8
9
  name: string;
9
10
  lcov: LcovData;
10
11
  }[], diff: DiffLines): void;
12
+ export declare function nonContributingWarnings(parsedSources: {
13
+ name: string;
14
+ lcov: LcovData;
15
+ }[], diff: DiffLines): string[];
11
16
  export declare function printDropOutput(drops: DropResult[]): void;
@@ -26,23 +26,28 @@ function lcovContributesToDiff(sourceLcov, diff) {
26
26
  * Prints `::error::` to stderr for each missing file and returns false if any are absent.
27
27
  */
28
28
  export function checkRequiredArtifacts(artifactsDir, requireArtifacts) {
29
- let ok = true;
30
- for (const rel of requireArtifacts) {
31
- if (!existsSync(join(artifactsDir, rel))) {
32
- stderr(`::error:: missing expected coverage artifact: ${rel}`);
33
- ok = false;
34
- }
35
- }
36
- return ok;
29
+ const missing = missingRequiredArtifacts(artifactsDir, requireArtifacts);
30
+ for (const rel of missing)
31
+ stderr(`::error:: missing expected coverage artifact: ${rel}`);
32
+ return missing.length === 0;
33
+ }
34
+ export function missingRequiredArtifacts(artifactsDir, requireArtifacts) {
35
+ return requireArtifacts.filter((rel) => !existsSync(join(artifactsDir, rel)));
37
36
  }
38
37
  export function warnNonContributing(parsedSources, diff) {
38
+ for (const warning of nonContributingWarnings(parsedSources, diff))
39
+ stderr(warning);
40
+ }
41
+ export function nonContributingWarnings(parsedSources, diff) {
39
42
  if (diff.size === 0)
40
- return;
43
+ return [];
44
+ const warnings = [];
41
45
  for (const { name, lcov: sourceLcov } of parsedSources) {
42
46
  if (!lcovContributesToDiff(sourceLcov, diff)) {
43
- stderr(`coverage-check: warning: coverage from ${name} contributed 0 coverable lines to the patch result. This may indicate a path prefix mismatch.`);
47
+ warnings.push(`coverage-check: warning: coverage from ${name} contributed 0 coverable lines to the patch result. This may indicate a path prefix mismatch.`);
44
48
  }
45
49
  }
50
+ return warnings;
46
51
  }
47
52
  export function printDropOutput(drops) {
48
53
  if (drops.length === 0)
@@ -0,0 +1 @@
1
+ export declare function checkHelp(): string;
@@ -0,0 +1,27 @@
1
+ export function checkHelp() {
2
+ return `coverage-check check
3
+
4
+ Usage:
5
+ coverage-check check [options]
6
+ coverage-check [options]
7
+
8
+ Options:
9
+ --rules <path> Path to YAML rules file (default: .coverage-rules.yml)
10
+ --artifacts <dir> Directory to scan for lcov.info files (default: ./coverage-artifacts)
11
+ --base <ref> Base git ref for git diff (default: origin/main)
12
+ --head <ref> Head git ref for git diff (default: HEAD)
13
+ --store-fs <path> Path to a filesystem suite store directory
14
+ --store <path> Alias for --store-fs
15
+ --store-s3 <bucket[/prefix]> S3 suite store
16
+ --branch <name> Branch pointer to follow when reading from the store (default: main)
17
+ --suite <name> Name of the current suite
18
+ --strip-prefix <prefix> Extra path prefix to strip from LCOV SF: lines (repeatable)
19
+ --pr <number> Pull request number for sticky comment
20
+ --repo <owner/repo> Repository for sticky comment (default: $GITHUB_REPOSITORY)
21
+ --json <path|-> Write JSON result to a path, or JSON-only stdout with -
22
+ --annotate-source Print trimmed source text for each uncovered line
23
+ --advisory Exit 0 even on coverage shortfall
24
+ --drop-only-changed-areas Restrict no_coverage_drop to changed rule areas
25
+ --require-artifact <relpath> Fail if this path is absent under --artifacts (repeatable)
26
+ -h, --help Show this help`;
27
+ }
@@ -0,0 +1,17 @@
1
+ import type { CoverageCheckResult } from "../types.mts";
2
+ export type CheckRunResult = {
3
+ result: CoverageCheckResult | null;
4
+ exitCode: 0 | 1 | 2;
5
+ advisory: boolean;
6
+ skipped: boolean;
7
+ error: string | null;
8
+ warnings: string[];
9
+ };
10
+ export type CheckJsonPayload = CoverageCheckResult & {
11
+ exitCode: 0 | 1 | 2;
12
+ advisory: boolean;
13
+ skipped: boolean;
14
+ error?: string;
15
+ };
16
+ export declare function emptyResult(passed: boolean): CoverageCheckResult;
17
+ export declare function toJsonPayload(check: CheckRunResult): CheckJsonPayload;
@@ -0,0 +1,12 @@
1
+ export function emptyResult(passed) {
2
+ return { buckets: [], drops: [], informational: [], passed };
3
+ }
4
+ export function toJsonPayload(check) {
5
+ return {
6
+ ...(check.result ?? emptyResult(false)),
7
+ exitCode: check.exitCode,
8
+ advisory: check.advisory,
9
+ skipped: check.skipped,
10
+ ...(check.error === null ? {} : { error: check.error }),
11
+ };
12
+ }
@@ -1,4 +1,25 @@
1
1
  import type { CheckArgs } from "./check-args.mts";
2
+ import type { CheckRunResult } from "./check-result.mts";
3
+ import type { SuiteSource } from "../step-summary.mts";
4
+ import type { DiffLineContent, LcovData } from "../types.mts";
5
+ import type { CoverageCheckResult } from "../types.mts";
2
6
  export type { CheckArgs } from "./check-args.mts";
7
+ export type { CheckRunResult } from "./check-result.mts";
8
+ export type EvaluatedCheck = {
9
+ exitCode: number;
10
+ result: CoverageCheckResult | null;
11
+ suiteSources: SuiteSource[];
12
+ runUrl: string;
13
+ branch: string;
14
+ diffContent: DiffLineContent | null;
15
+ skippedReason?: string;
16
+ parsedSources: {
17
+ name: string;
18
+ lcov: LcovData;
19
+ }[];
20
+ warnings: string[];
21
+ };
3
22
  export declare function main(argv: string[]): Promise<number>;
23
+ export declare function checkCoverage(args: CheckArgs): Promise<CheckRunResult>;
24
+ export declare function evaluateCheck(args: CheckArgs): Promise<EvaluatedCheck>;
4
25
  export declare function runCheck(args: CheckArgs): Promise<number>;