coverage-check
v0.7.1
Published
Patch-coverage gate: checks that newly added lines meet per-path coverage thresholds. Supports per-suite LCOV accumulation for conditional CI.
Maintainers
Readme
coverage-check
Patch-coverage gate for CI: checks that newly added lines meet per-path coverage thresholds using LCOV reports and git diff. Supports per-suite LCOV accumulation for conditional CI pipelines.
Install
npm install coverage-checkUsage
Basic (single run)
coverage-check check \
--rules .coverage-rules.yml \
--artifacts ./coverage-artifacts \
--base origin/main \
--head HEADExits 0 on pass, 1 on failure, 2 on configuration error.
Suite store with S3 (conditional CI)
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:
# After backend tests run on the main branch — store this suite's coverage
coverage-check store-put \
--suite backend \
--store-s3 my-bucket/coverage-store \
--artifacts ./coverage-artifacts \
--sha "$GITHUB_SHA" \
--branch main
# On a PR that only runs frontend tests:
coverage-check check \
--rules .coverage-rules.yml \
--artifacts ./coverage-artifacts \
--store-s3 my-bucket/coverage-store \
--suite frontend \
--branch main \
--base origin/main \
--head HEADThe --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.
S3 key layout:
<prefix>/<suite>/sha/<sha>/lcov.info.gz # gzip payload for new versioned writes
<prefix>/<suite>/branch/<encoded-branch>/latest.json # pointer with sha, payloadKey, encoding, byte counts, timestampS3-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.
Versioned S3 writes (store-put --sha ... --branch ...) gzip the LCOV payload and write pointer
metadata with payloadKey, encoding, rawBytes, and storedBytes. Existing raw
sha/<sha>/lcov.info payloads and legacy <suite>/lcov.info payloads remain readable. Legacy
writes without --sha/--branch keep the old raw <suite>/lcov.info layout.
Every S3 operation logs a concise diagnostic line to stderr with the operation name, bucket, key, elapsed time, and byte counts where applicable. Use these lines to distinguish payload writes, branch-pointer reads, and branch-pointer writes when CI storage stalls.
S3 request bounds are configurable with environment variables:
| Variable | Default | Purpose |
| ----------------------------------------- | ------- | ---------------------------------------- |
| COVERAGE_CHECK_S3_CONNECTION_TIMEOUT_MS | 5000 | Socket connection timeout for S3 calls |
| COVERAGE_CHECK_S3_REQUEST_TIMEOUT_MS | 30000 | Whole-request timeout for S3 calls |
| COVERAGE_CHECK_S3_MAX_ATTEMPTS | 2 | AWS SDK attempt count, including retries |
Suite store with filesystem
For local development or simpler deployments:
coverage-check store-put \
--suite backend \
--store-fs ./coverage-store \
--artifacts ./coverage-artifacts \
--sha "$GITHUB_SHA" \
--branch main
coverage-check check \
--rules .coverage-rules.yml \
--artifacts ./coverage-artifacts \
--store-fs ./coverage-store \
--suite frontend \
--base origin/main \
--head HEADGitHub PR sticky comment
Pass --pr and --repo to post (or update) a sticky comment on a pull request. Requires the gh CLI and GH_TOKEN/GITHUB_TOKEN.
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.
coverage-check check \
--rules .coverage-rules.yml \
--artifacts ./coverage-artifacts \
--pr "${{ github.event.pull_request.number }}" \
--repo "${{ github.repository }}"GitHub Actions step summary
When $GITHUB_STEP_SUMMARY is set, a per-suite totals and per-rule patch-coverage table is appended to the job summary automatically.
Diagnosing uncovered lines
Pass --annotate-source to print the trimmed source text of each uncovered line alongside its line number:
coverage-check: FAILED
backend/**: 80.0% (4/5) — threshold 90%
backend/foo.mts:
L42 function f(a = 1) {
L55 const { x } = optsThis makes it immediately clear which construct needs execution to satisfy V8/Istanbul line coverage. Two common sources of confusion:
- Default parameters —
function f(a = 1)is only fully covered when the function is called without that argument so the default expression executes. - Shorthand object properties —
const { x } = optsis covered whenopts.xis actually accessed during the test.
The annotation affects only the stdout failure output. The GitHub PR sticky comment and Actions step summary are unchanged.
Merging LCOV files
Use the merge subcommand to fold multiple lcov.info files into a single output that preserves function and branch records (FN, FNDA, BRDA) as well as summary counters (LF/LH/FNF/FNH/BRF/BRH):
coverage-check merge \
--artifacts ./coverage-artifacts \
--output ./coverage-merged/lcov.infoHit counts are summed across inputs (consistent with the package's internal multi-suite merge). Parent directories of --output are created automatically.
Advisory (non-blocking) mode
Pass --advisory to exit 0 even when coverage falls short. The check still runs in full — JSON is written, the PR comment is posted, and uncovered lines are printed — but the process never exits 1. Useful for pre-push hooks where you want feedback without blocking the push:
coverage-check check \
--rules .coverage-rules.yml \
--artifacts ./coverage-artifacts \
--base origin/main \
--head HEAD \
--advisoryPR-scoped regression gate
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:
coverage-check check \
--rules .coverage-rules.yml \
--artifacts ./coverage-artifacts \
--base origin/main \
--head HEAD \
--drop-only-changed-areasThis avoids false positives when a CI run only exercises a subset of areas.
Required artifacts
Pass --require-artifact <relpath> (repeatable) to fail early — exit 2 with a ::error:: annotation — if an expected lcov.info is absent from --artifacts. This distinguishes a missing coverage upload (CI job didn't run) from genuine uncovered lines:
coverage-check check \
--rules .coverage-rules.yml \
--artifacts ./coverage-artifacts \
--require-artifact coverage-backend/lcov.info \
--require-artifact coverage-frontend/lcov.info--require-artifact is also accepted by coverage-check merge.
Rules file
# .coverage-rules.yml
rules:
- paths: backend/**
patch_coverage_min: 90
- paths: web/lib/api/**
patch_coverage_min: 100
- paths: web/**
patch_coverage_min: 5Rules are matched in order; the first match wins. Files in the diff not matched by any rule are reported as informational (not gated).
no_coverage_drop
Add no_coverage_drop: true to a rule to also gate total line-coverage regression — not just patch lines. When enabled, the check fails if the overall line-coverage percentage of files matched by that rule falls below the main baseline stored in the suite store.
rules:
- paths: backend/scripts/**
patch_coverage_min: 0 # exempt from patch gate
- paths: backend/**
patch_coverage_min: 95
no_coverage_drop: true # also gate overall regression
- paths: web/**
patch_coverage_min: 80
no_coverage_drop: true
max_coverage_drop: 0.5 # allow up to 0.5 percentage-point dropmax_coverage_drop (default 0) sets the tolerance in percentage points. First-match-wins applies: backend/scripts/** files are matched by the earlier rule and are not included in the backend/** total.
Requirements:
- A suite store (
--store-s3or--store-fs) must be configured on thecheckcommand. - A baseline must exist in the store (written by
store-put --sha ... --branch mainon main pushes). - When no baseline is available (e.g. fork PRs without store access), the no-drop check is skipped non-blockingly with a warning — the patch coverage gate still runs.
First-match-wins means that if you have a more specific rule before a broader one (e.g. backend/scripts/** before backend/**), only files matched by the broader rule's first-match contribute to its total — scripts are excluded from the broader backend/** aggregate.
CLI reference
coverage-check check
| Flag | Default | Description |
| --------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| --rules | .coverage-rules.yml | Path to YAML rules file |
| --artifacts | ./coverage-artifacts | Directory to scan for lcov.info files |
| --base | origin/main | Base git ref for git diff |
| --head | HEAD | Head git ref for git diff |
| --store-fs | — | Path to a filesystem suite store directory |
| --store | — | Alias for --store-fs |
| --store-s3 | — | S3 suite store spec: <bucket>[/<prefix>] |
| --branch | "main" | Branch pointer to follow when reading from the store |
| --suite | — | Name of the current suite (no / or \\); fresh artifacts override this suite in the store |
| --strip-prefix | — | Extra path prefix to strip from LCOV SF: lines (repeatable) |
| --pr | — | Pull request number for sticky comment |
| --repo | $GITHUB_REPOSITORY | owner/repo for sticky comment |
| --json | — | Write JSON result to this path |
| --annotate-source | — | Print the trimmed source text of each uncovered line in stdout (does not alter PR comment or step summary) |
| --advisory | — | Exit 0 even on shortfall; still prints, writes JSON, and posts PR comment |
| --drop-only-changed-areas | — | Restrict no_coverage_drop to rule areas that have ≥1 changed file; others are reported as skipped |
| --require-artifact | — | Fail (exit 2) if this relative path is absent under --artifacts (repeatable) |
coverage-check merge
| Flag | Default | Description |
| -------------------- | ---------------------- | ------------------------------------------------------------- |
| --artifacts | ./coverage-artifacts | Directory to scan for lcov.info files |
| --output | required | Path to write the merged lcov.info |
| --strip-prefix | — | Extra path prefix to strip from LCOV SF: lines (repeatable) |
| --require-artifact | — | Fail (exit 2) if this relative path is absent (repeatable) |
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.
coverage-check store-put
| Flag | Default | Description |
| ---------------- | ---------------------- | ------------------------------------------------------------- |
| --suite | required | Suite name to store |
| --store-fs | required* | Path to a filesystem suite store directory |
| --store | — | Alias for --store-fs |
| --store-s3 | required* | S3 suite store spec: <bucket>[/<prefix>] |
| --sha | — | Git SHA to associate with this coverage payload |
| --branch | — | Branch name for the pointer (e.g. main or feature/foo) |
| --artifacts | ./coverage-artifacts | Directory to scan for lcov.info files |
| --strip-prefix | — | Extra path prefix to strip from LCOV SF: lines (repeatable) |
* Exactly one of --store-fs or --store-s3 is required.
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.
Programmatic API
import {
runCheck,
runMerge,
runStorePut,
collapseRanges,
parseLcovFull,
mergeLcovFull,
toLcovFull,
FileSystemSuiteStore,
S3SuiteStore,
} from "coverage-check";
// FileSystem store
const fsStore = new FileSystemSuiteStore("/path/to/store");
// S3 store (requires AWS credentials — see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html)
const s3Store = new S3SuiteStore({ bucket: "my-bucket", prefix: "coverage" });
await runCheck({
rules: ".coverage-rules.yml",
artifacts: "./coverage",
base: "origin/main",
head: "HEAD",
pr: null,
repo: "",
json: null,
stripPrefixes: [],
store: s3Store,
suite: "backend",
branch: "main",
advisory: false,
dropOnlyChangedAreas: false,
requireArtifacts: [],
});
await runMerge({
artifacts: "./coverage-artifacts",
output: "./coverage-merged/lcov.info",
stripPrefixes: [],
requireArtifacts: [],
});
await runStorePut({
suite: "backend",
store: s3Store,
artifacts: "./coverage",
stripPrefixes: [],
sha: "abc123",
branch: "main",
});
// Collapse a sorted line list into a compact range string
collapseRanges([1, 2, 3, 7, 8]); // → "L1-3, L7-8"
collapseRanges([1, 2, 3, 7, 8], ""); // → "1-3, 7-8"
// Full-fidelity LCOV (functions + branches + lines)
const full = parseLcovFull(lcovText, ["/home/runner/work/repo/repo/"]);
const merged = mergeLcovFull([full1, full2]); // hits are summed
const output = toLcovFull(merged); // includes FN/FNDA/BRDA and LF/LH/FNF/FNH/BRF/BRHYou can also implement your own SuiteStore:
import type { SuiteStore } from "coverage-check";
class MyCustomStore implements SuiteStore {
async list(): Promise<string[]> {
/* ... */
}
async get(suite: string, opts?: { sha?: string; branch?: string }): Promise<Buffer | null> {
/* ... */
}
async put(
suite: string,
lcov: Buffer,
meta?: { sha: string; branch: string; timestamp?: string },
): Promise<void> {
/* ... */
}
}