coverage-check 0.5.0 → 0.7.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
@@ -120,6 +120,60 @@ Two common sources of confusion:
120
120
  The annotation affects only the stdout failure output. The GitHub PR sticky comment and Actions step
121
121
  summary are unchanged.
122
122
 
123
+ ### Merging LCOV files
124
+
125
+ 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`):
126
+
127
+ ```sh
128
+ coverage-check merge \
129
+ --artifacts ./coverage-artifacts \
130
+ --output ./coverage-merged/lcov.info
131
+ ```
132
+
133
+ Hit counts are **summed** across inputs (consistent with the package's internal multi-suite merge). Parent directories of `--output` are created automatically.
134
+
135
+ ### Advisory (non-blocking) mode
136
+
137
+ 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:
138
+
139
+ ```sh
140
+ coverage-check check \
141
+ --rules .coverage-rules.yml \
142
+ --artifacts ./coverage-artifacts \
143
+ --base origin/main \
144
+ --head HEAD \
145
+ --advisory
146
+ ```
147
+
148
+ ### PR-scoped regression gate
149
+
150
+ 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:
151
+
152
+ ```sh
153
+ coverage-check check \
154
+ --rules .coverage-rules.yml \
155
+ --artifacts ./coverage-artifacts \
156
+ --base origin/main \
157
+ --head HEAD \
158
+ --drop-only-changed-areas
159
+ ```
160
+
161
+ This avoids false positives when a CI run only exercises a subset of areas.
162
+
163
+ ### Required artifacts
164
+
165
+ 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:
166
+
167
+ ```sh
168
+ coverage-check check \
169
+ --rules .coverage-rules.yml \
170
+ --artifacts ./coverage-artifacts \
171
+ --require-artifact coverage-backend/lcov.info \
172
+ --require-artifact coverage-frontend/lcov.info
173
+ ```
174
+
175
+ `--require-artifact` is also accepted by `coverage-check merge`.
176
+
123
177
  ## Rules file
124
178
 
125
179
  ```yaml
@@ -135,26 +189,67 @@ rules:
135
189
 
136
190
  Rules are matched in order; the first match wins. Files in the diff not matched by any rule are reported as informational (not gated).
137
191
 
192
+ ### `no_coverage_drop`
193
+
194
+ 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.
195
+
196
+ ```yaml
197
+ rules:
198
+ - paths: backend/scripts/**
199
+ patch_coverage_min: 0 # exempt from patch gate
200
+ - paths: backend/**
201
+ patch_coverage_min: 95
202
+ no_coverage_drop: true # also gate overall regression
203
+ - paths: web/**
204
+ patch_coverage_min: 80
205
+ no_coverage_drop: true
206
+ max_coverage_drop: 0.5 # allow up to 0.5 percentage-point drop
207
+ ```
208
+
209
+ `max_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.
210
+
211
+ **Requirements:**
212
+
213
+ - A suite store (`--store-s3` or `--store-fs`) must be configured on the `check` command.
214
+ - A baseline must exist in the store (written by `store-put --sha ... --branch main` on main pushes).
215
+ - 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.
216
+
217
+ 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.
218
+
138
219
  ## CLI reference
139
220
 
140
221
  ### `coverage-check check`
141
222
 
142
- | Flag | Default | Description |
143
- | ------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- |
144
- | `--rules` | `.coverage-rules.yml` | Path to YAML rules file |
145
- | `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
146
- | `--base` | `origin/main` | Base git ref for `git diff` |
147
- | `--head` | `HEAD` | Head git ref for `git diff` |
148
- | `--store-fs` | — | Path to a filesystem suite store directory |
149
- | `--store` | — | Alias for `--store-fs` |
150
- | `--store-s3` | — | S3 suite store spec: `<bucket>[/<prefix>]` |
151
- | `--branch` | `"main"` | Branch pointer to follow when reading from the store |
152
- | `--suite` | — | Name of the current suite (no `/` or `\\`); fresh artifacts override this suite in the store |
153
- | `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
154
- | `--pr` | — | Pull request number for sticky comment |
155
- | `--repo` | `$GITHUB_REPOSITORY` | `owner/repo` for sticky comment |
156
- | `--json` | — | Write JSON result to this path |
157
- | `--annotate-source` | — | Print the trimmed source text of each uncovered line in stdout (does not alter PR comment or step summary) |
223
+ | Flag | Default | Description |
224
+ | --------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- |
225
+ | `--rules` | `.coverage-rules.yml` | Path to YAML rules file |
226
+ | `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
227
+ | `--base` | `origin/main` | Base git ref for `git diff` |
228
+ | `--head` | `HEAD` | Head git ref for `git diff` |
229
+ | `--store-fs` | — | Path to a filesystem suite store directory |
230
+ | `--store` | — | Alias for `--store-fs` |
231
+ | `--store-s3` | — | S3 suite store spec: `<bucket>[/<prefix>]` |
232
+ | `--branch` | `"main"` | Branch pointer to follow when reading from the store |
233
+ | `--suite` | — | Name of the current suite (no `/` or `\\`); fresh artifacts override this suite in the store |
234
+ | `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
235
+ | `--pr` | — | Pull request number for sticky comment |
236
+ | `--repo` | `$GITHUB_REPOSITORY` | `owner/repo` for sticky comment |
237
+ | `--json` | — | Write JSON result to this path |
238
+ | `--annotate-source` | — | Print the trimmed source text of each uncovered line in stdout (does not alter PR comment or step summary) |
239
+ | `--advisory` | — | Exit `0` even on shortfall; still prints, writes JSON, and posts PR comment |
240
+ | `--drop-only-changed-areas` | — | Restrict `no_coverage_drop` to rule areas that have ≥1 changed file; others are reported as skipped |
241
+ | `--require-artifact` | — | Fail (exit `2`) if this relative path is absent under `--artifacts` (repeatable) |
242
+
243
+ ### `coverage-check merge`
244
+
245
+ | Flag | Default | Description |
246
+ | -------------------- | ---------------------- | ------------------------------------------------------------- |
247
+ | `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
248
+ | `--output` | required | Path to write the merged `lcov.info` |
249
+ | `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
250
+ | `--require-artifact` | — | Fail (exit `2`) if this relative path is absent (repeatable) |
251
+
252
+ 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.
158
253
 
159
254
  ### `coverage-check store-put`
160
255
 
@@ -176,7 +271,17 @@ When `--sha` and `--branch` are both provided, `store-put` writes a SHA-addresse
176
271
  ## Programmatic API
177
272
 
178
273
  ```ts
179
- import { runCheck, runStorePut, FileSystemSuiteStore, S3SuiteStore } from "coverage-check";
274
+ import {
275
+ runCheck,
276
+ runMerge,
277
+ runStorePut,
278
+ collapseRanges,
279
+ parseLcovFull,
280
+ mergeLcovFull,
281
+ toLcovFull,
282
+ FileSystemSuiteStore,
283
+ S3SuiteStore,
284
+ } from "coverage-check";
180
285
 
181
286
  // FileSystem store
182
287
  const fsStore = new FileSystemSuiteStore("/path/to/store");
@@ -196,6 +301,16 @@ await runCheck({
196
301
  store: s3Store,
197
302
  suite: "backend",
198
303
  branch: "main",
304
+ advisory: false,
305
+ dropOnlyChangedAreas: false,
306
+ requireArtifacts: [],
307
+ });
308
+
309
+ await runMerge({
310
+ artifacts: "./coverage-artifacts",
311
+ output: "./coverage-merged/lcov.info",
312
+ stripPrefixes: [],
313
+ requireArtifacts: [],
199
314
  });
200
315
 
201
316
  await runStorePut({
@@ -206,6 +321,15 @@ await runStorePut({
206
321
  sha: "abc123",
207
322
  branch: "main",
208
323
  });
324
+
325
+ // Collapse a sorted line list into a compact range string
326
+ collapseRanges([1, 2, 3, 7, 8]); // → "L1-3, L7-8"
327
+ collapseRanges([1, 2, 3, 7, 8], ""); // → "1-3, 7-8"
328
+
329
+ // Full-fidelity LCOV (functions + branches + lines)
330
+ const full = parseLcovFull(lcovText, ["/home/runner/work/repo/repo/"]);
331
+ const merged = mergeLcovFull([full1, full2]); // hits are summed
332
+ const output = toLcovFull(merged); // includes FN/FNDA/BRDA and LF/LH/FNF/FNH/BRF/BRH
209
333
  ```
210
334
 
211
335
  You can also implement your own `SuiteStore`:
package/dist/src/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { main as checkMain } from "./commands/check.mjs";
2
2
  import { main as storePutMain } from "./commands/store-put.mjs";
3
3
  import { main as htmlMain } from "./commands/html.mjs";
4
+ import { main as mergeMain } from "./commands/merge.mjs";
4
5
  import { main as summaryMain } from "./commands/summary.mjs";
5
6
  const stderr = (msg) => process.stderr.write(`${msg}\n`);
6
7
  export async function main(argv) {
@@ -11,6 +12,8 @@ export async function main(argv) {
11
12
  return checkMain(argv.slice(1));
12
13
  if (sub === "store-put")
13
14
  return storePutMain(argv.slice(1));
15
+ if (sub === "merge")
16
+ return mergeMain(argv.slice(1));
14
17
  if (sub === "html")
15
18
  return htmlMain(argv.slice(1));
16
19
  if (sub === "summary")
@@ -18,5 +18,11 @@ export type CheckArgs = {
18
18
  summaryFile?: string | null;
19
19
  /** Annotate each uncovered line with its trimmed source text in stdout. Default: false. */
20
20
  annotateSource?: boolean;
21
+ /** Exit 0 even on coverage shortfall — compute and print, but never block. Default: false. */
22
+ advisory?: boolean;
23
+ /** Apply no_coverage_drop only to rules whose area has changed files in the diff. Default: false. */
24
+ dropOnlyChangedAreas?: boolean;
25
+ /** Relative paths under --artifacts that must exist before the check runs (repeatable). */
26
+ requireArtifacts?: string[];
21
27
  };
22
28
  export declare function parseCheckArgs(argv: string[]): CheckArgs;
@@ -17,6 +17,9 @@ export function parseCheckArgs(argv) {
17
17
  branch: "main",
18
18
  summaryFile: process.env["GITHUB_STEP_SUMMARY"] ?? null,
19
19
  annotateSource: false,
20
+ advisory: false,
21
+ dropOnlyChangedAreas: false,
22
+ requireArtifacts: [],
20
23
  };
21
24
  for (let i = 0; i < argv.length; i++) {
22
25
  const flag = argv[i];
@@ -80,6 +83,15 @@ export function parseCheckArgs(argv) {
80
83
  case "--annotate-source":
81
84
  args.annotateSource = true;
82
85
  break;
86
+ case "--advisory":
87
+ args.advisory = true;
88
+ break;
89
+ case "--drop-only-changed-areas":
90
+ args.dropOnlyChangedAreas = true;
91
+ break;
92
+ case "--require-artifact":
93
+ args.requireArtifacts.push(val());
94
+ break;
83
95
  default:
84
96
  throw new Error(`unknown flag: ${flag}`);
85
97
  }
@@ -0,0 +1,11 @@
1
+ import type { DropResult, DiffLines, LcovData } from "../types.mts";
2
+ /**
3
+ * Verifies that each required artifact path exists under artifactsDir.
4
+ * Prints `::error::` to stderr for each missing file and returns false if any are absent.
5
+ */
6
+ export declare function checkRequiredArtifacts(artifactsDir: string, requireArtifacts: string[]): boolean;
7
+ export declare function warnNonContributing(parsedSources: {
8
+ name: string;
9
+ lcov: LcovData;
10
+ }[], diff: DiffLines): void;
11
+ export declare function printDropOutput(drops: DropResult[]): void;
@@ -0,0 +1,70 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const stdout = (msg) => process.stdout.write(`${msg}\n`);
4
+ const stderr = (msg) => process.stderr.write(`${msg}\n`);
5
+ function fmtPct(n) {
6
+ return n === null ? "—" : `${n.toFixed(2)}%`;
7
+ }
8
+ function fmtDrop(n) {
9
+ /* c8 ignore next -- failing drops always have non-null drop (drop===null implies passed===true) */
10
+ return n === null ? "—" : `${n.toFixed(2)}pp`;
11
+ }
12
+ function lcovContributesToDiff(sourceLcov, diff) {
13
+ for (const [file, changedLines] of diff) {
14
+ const fileLines = sourceLcov.get(file);
15
+ if (fileLines) {
16
+ for (const lineNo of changedLines) {
17
+ if (fileLines.has(lineNo))
18
+ return true;
19
+ }
20
+ }
21
+ }
22
+ return false;
23
+ }
24
+ /**
25
+ * Verifies that each required artifact path exists under artifactsDir.
26
+ * Prints `::error::` to stderr for each missing file and returns false if any are absent.
27
+ */
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;
37
+ }
38
+ export function warnNonContributing(parsedSources, diff) {
39
+ if (diff.size === 0)
40
+ return;
41
+ for (const { name, lcov: sourceLcov } of parsedSources) {
42
+ 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.`);
44
+ }
45
+ }
46
+ }
47
+ export function printDropOutput(drops) {
48
+ if (drops.length === 0)
49
+ return;
50
+ const skippedDrops = drops.filter((d) => d.skipped);
51
+ const failingDrops = drops.filter((d) => !d.passed && !d.skipped);
52
+ const passingDrops = drops.filter((d) => d.passed && !d.skipped);
53
+ if (skippedDrops.length > 0) {
54
+ stdout("\ncoverage-check: coverage drop check skipped (no baseline)\n");
55
+ for (const d of skippedDrops) {
56
+ stdout(` ${d.rule}: no baseline available`);
57
+ }
58
+ }
59
+ if (failingDrops.length > 0) {
60
+ stdout("\ncoverage-check: COVERAGE REGRESSION\n");
61
+ for (const d of failingDrops) {
62
+ stdout(` ${d.rule}: ${fmtPct(d.currentPct)} (was ${fmtPct(d.baselinePct)}, dropped ${fmtDrop(d.drop)}, max allowed ${d.maxDrop}pp)`);
63
+ }
64
+ }
65
+ if (passingDrops.length > 0) {
66
+ for (const d of passingDrops) {
67
+ stdout(` ${d.rule}: ${fmtPct(d.currentPct)} (baseline ${fmtPct(d.baselinePct)}) ✓`);
68
+ }
69
+ }
70
+ }
@@ -3,13 +3,15 @@ import { parseLcov } from "../lcov-parser.mjs";
3
3
  import { mergeLcov } from "../lcov-merge.mjs";
4
4
  import { getChangedLines } from "../diff-parser.mjs";
5
5
  import { getChangedLineContent } from "../diff-parser-content.mjs";
6
- import { loadRules } from "../rules.mjs";
6
+ import { loadRules, buildChangedRules } from "../rules.mjs";
7
7
  import { computePatchCoverage } from "../patch-coverage.mjs";
8
+ import { computeCoverageDrop } from "../coverage-drop.mjs";
8
9
  import { collapseRanges, renderFailureComment } from "../report.mjs";
9
10
  import { upsertComment } from "../github-comment.mjs";
10
11
  import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
11
12
  import { writeSummary } from "../step-summary.mjs";
12
13
  import { parseCheckArgs } from "./check-args.mjs";
14
+ import { warnNonContributing, printDropOutput, checkRequiredArtifacts } from "./check-output.mjs";
13
15
  const stdout = (msg) => process.stdout.write(`${msg}\n`);
14
16
  const stderr = (msg) => process.stderr.write(`${msg}\n`);
15
17
  export async function main(argv) {
@@ -32,6 +34,8 @@ export async function runCheck(args) {
32
34
  stderr(`coverage-check: failed to load rules: ${err}`);
33
35
  return 2;
34
36
  }
37
+ if (!checkRequiredArtifacts(args.artifacts, args.requireArtifacts ?? []))
38
+ return 2;
35
39
  const branch = args.branch ?? "main";
36
40
  const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
37
41
  const reports = [];
@@ -86,30 +90,28 @@ export async function runCheck(args) {
86
90
  stderr(`coverage-check: git diff failed: ${err}`);
87
91
  return 2;
88
92
  }
89
- if (diff.size > 0) {
90
- for (const { name, lcov: sourceLcov } of parsedSources) {
91
- let contributes = false;
92
- for (const [file, changedLines] of diff) {
93
- const fileLines = sourceLcov.get(file);
94
- if (fileLines) {
95
- for (const lineNo of changedLines) {
96
- if (fileLines.has(lineNo)) {
97
- contributes = true;
98
- break;
99
- }
100
- }
101
- }
102
- if (contributes)
103
- break;
104
- }
105
- if (!contributes) {
106
- stderr(`coverage-check: warning: coverage from ${name} contributed 0 coverable lines to the patch result. This may indicate a path prefix mismatch.`);
93
+ warnNonContributing(parsedSources, diff);
94
+ let baseline = null;
95
+ if (args.store !== null) {
96
+ try {
97
+ const suites = await args.store.list();
98
+ const baselineReports = (await Promise.all(suites.map(async (suite) => {
99
+ const buf = await args.store.get(suite, { branch });
100
+ return buf === null ? null : parseLcov(buf.toString("utf8"), stripPrefixes);
101
+ }))).filter((report) => report !== null);
102
+ if (baselineReports.length > 0) {
103
+ baseline = mergeLcov(baselineReports);
107
104
  }
108
105
  }
106
+ catch (err) {
107
+ stderr(`coverage-check: warning: failed to load baseline from store: ${String(err)}`);
108
+ }
109
109
  }
110
110
  const { buckets, informational } = computePatchCoverage(diff, lcov, rules);
111
- const passed = buckets.every((b) => b.passed);
112
- const result = { buckets, informational, passed };
111
+ const changedRules = (args.dropOnlyChangedAreas ?? false) ? buildChangedRules(diff, rules) : undefined;
112
+ const drops = computeCoverageDrop(lcov, baseline, rules, changedRules);
113
+ const passed = buckets.every((b) => b.passed) && drops.every((d) => d.passed || d.skipped);
114
+ const result = { buckets, drops, informational, passed };
113
115
  if (args.json) {
114
116
  writeFileSync(args.json, JSON.stringify(result, null, 2));
115
117
  }
@@ -145,6 +147,7 @@ export async function runCheck(args) {
145
147
  stdout(` ${bucket.rule}: ${pct} ✓`);
146
148
  }
147
149
  }
150
+ printDropOutput(drops);
148
151
  const summaryFile = args.summaryFile !== undefined
149
152
  ? args.summaryFile
150
153
  : (process.env["GITHUB_STEP_SUMMARY"] ?? null);
@@ -166,5 +169,5 @@ export async function runCheck(args) {
166
169
  stderr(`coverage-check: failed to post PR comment: ${err}`);
167
170
  }
168
171
  }
169
- return passed ? 0 : 1;
172
+ return (args.advisory ?? false) || passed ? 0 : 1;
170
173
  }
@@ -0,0 +1,7 @@
1
+ export type MergeArgs = {
2
+ artifacts: string;
3
+ output: string;
4
+ stripPrefixes: string[];
5
+ requireArtifacts: string[];
6
+ };
7
+ export declare function parseMergeArgs(argv: string[]): MergeArgs;
@@ -0,0 +1,38 @@
1
+ export function parseMergeArgs(argv) {
2
+ const args = {
3
+ artifacts: "./coverage-artifacts",
4
+ output: "",
5
+ stripPrefixes: [],
6
+ requireArtifacts: [],
7
+ };
8
+ for (let i = 0; i < argv.length; i++) {
9
+ const flag = argv[i];
10
+ const next = argv[i + 1];
11
+ const val = () => {
12
+ if (next === undefined || next.startsWith("--")) {
13
+ throw new Error(`${flag} requires a value`);
14
+ }
15
+ i++;
16
+ return next;
17
+ };
18
+ switch (flag) {
19
+ case "--artifacts":
20
+ args.artifacts = val();
21
+ break;
22
+ case "--output":
23
+ args.output = val();
24
+ break;
25
+ case "--strip-prefix":
26
+ args.stripPrefixes.push(val());
27
+ break;
28
+ case "--require-artifact":
29
+ args.requireArtifacts.push(val());
30
+ break;
31
+ default:
32
+ throw new Error(`unknown flag: ${flag}`);
33
+ }
34
+ }
35
+ if (!args.output)
36
+ throw new Error("--output is required");
37
+ return args;
38
+ }
@@ -0,0 +1,9 @@
1
+ import type { MergeArgs } from "./merge-args.mts";
2
+ export type { MergeArgs } from "./merge-args.mts";
3
+ export declare function main(argv: string[]): Promise<number>;
4
+ /**
5
+ * Merges all lcov.info files found under args.artifacts into a single full-fidelity LCOV file.
6
+ * Preserves FN/FNDA/BRDA records and recomputes summary counters (FNF/FNH/BRF/BRH/LF/LH).
7
+ * Hit counts are summed across reports (lines, function hits, branch hits).
8
+ */
9
+ export declare function runMerge(args: MergeArgs): number;
@@ -0,0 +1,41 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
4
+ import { parseLcovFull, mergeLcovFull, toLcovFull } from "../lcov-records.mjs";
5
+ import { checkRequiredArtifacts } from "./check-output.mjs";
6
+ import { parseMergeArgs } from "./merge-args.mjs";
7
+ const stdout = (msg) => process.stdout.write(`${msg}\n`);
8
+ const stderr = (msg) => process.stderr.write(`${msg}\n`);
9
+ export async function main(argv) {
10
+ let args;
11
+ try {
12
+ args = parseMergeArgs(argv);
13
+ }
14
+ catch (err) {
15
+ stderr(`coverage-check merge: ${String(err)}`);
16
+ return 2;
17
+ }
18
+ return runMerge(args);
19
+ }
20
+ /**
21
+ * Merges all lcov.info files found under args.artifacts into a single full-fidelity LCOV file.
22
+ * Preserves FN/FNDA/BRDA records and recomputes summary counters (FNF/FNH/BRF/BRH/LF/LH).
23
+ * Hit counts are summed across reports (lines, function hits, branch hits).
24
+ */
25
+ export function runMerge(args) {
26
+ if (!checkRequiredArtifacts(args.artifacts, args.requireArtifacts))
27
+ return 2;
28
+ const lcovFiles = collectLcovFiles(args.artifacts);
29
+ if (lcovFiles.length === 0) {
30
+ stderr(`coverage-check merge: no lcov.info files found under ${args.artifacts}`);
31
+ return 1;
32
+ }
33
+ const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
34
+ const reports = lcovFiles.map((f) => parseLcovFull(readFileSync(f, "utf8"), stripPrefixes));
35
+ const merged = mergeLcovFull(reports);
36
+ const lcovText = toLcovFull(merged);
37
+ mkdirSync(dirname(args.output), { recursive: true });
38
+ writeFileSync(args.output, lcovText, "utf8");
39
+ stdout(`coverage-check merge: merged ${lcovFiles.length} file(s) → ${args.output}`);
40
+ return 0;
41
+ }
@@ -1,10 +1,16 @@
1
1
  export { runCheck } from "./commands/check.mts";
2
2
  export { runStorePut } from "./commands/store-put.mts";
3
+ export { runMerge } from "./commands/merge.mts";
3
4
  export { FileSystemSuiteStore } from "./suite-store.mts";
4
5
  export { S3SuiteStore } from "./s3-suite-store.mts";
5
6
  export { parseDiffWithContent, getChangedLineContent } from "./diff-parser-content.mts";
7
+ export { collapseRanges } from "./report.mts";
8
+ export { parseLcovFull, mergeLcovFull, toLcovFull } from "./lcov-records.mts";
6
9
  export type { CheckArgs } from "./commands/check.mts";
7
10
  export type { StorePutArgs } from "./commands/store-put.mts";
11
+ export type { MergeArgs } from "./commands/merge.mts";
8
12
  export type { SuiteStore, SuiteMeta } from "./suite-store.mts";
9
13
  export type { S3SuiteStoreOptions } from "./s3-suite-store.mts";
10
- export type { CoverageCheckResult, BucketResult, FileCoverageResult, LcovData, DiffLines, DiffLineContent, CoverageRule, } from "./types.mts";
14
+ export { computeCoverageDrop } from "./coverage-drop.mts";
15
+ export type { FullLcovData, FullFileCoverage } from "./lcov-records.mts";
16
+ export type { CoverageCheckResult, BucketResult, DropResult, FileCoverageResult, LcovData, DiffLines, DiffLineContent, CoverageRule, } from "./types.mts";
@@ -1,5 +1,9 @@
1
1
  export { runCheck } from "./commands/check.mjs";
2
2
  export { runStorePut } from "./commands/store-put.mjs";
3
+ export { runMerge } from "./commands/merge.mjs";
3
4
  export { FileSystemSuiteStore } from "./suite-store.mjs";
4
5
  export { S3SuiteStore } from "./s3-suite-store.mjs";
5
6
  export { parseDiffWithContent, getChangedLineContent } from "./diff-parser-content.mjs";
7
+ export { collapseRanges } from "./report.mjs";
8
+ export { parseLcovFull, mergeLcovFull, toLcovFull } from "./lcov-records.mjs";
9
+ export { computeCoverageDrop } from "./coverage-drop.mjs";
@@ -0,0 +1,2 @@
1
+ import type { CoverageRule, DropResult, LcovData } from "./types.mts";
2
+ export declare function computeCoverageDrop(current: LcovData, baseline: LcovData | null, rules: CoverageRule[], changedRules?: Set<CoverageRule>): DropResult[];
@@ -0,0 +1,39 @@
1
+ import { matchRule } from "./rules.mjs";
2
+ function fileTotals(lcov, targetRule, allRules) {
3
+ let hit = 0;
4
+ let total = 0;
5
+ for (const [file, lines] of lcov) {
6
+ if (matchRule(file, allRules) !== targetRule)
7
+ continue;
8
+ for (const count of lines.values()) {
9
+ total++;
10
+ if (count > 0)
11
+ hit++;
12
+ }
13
+ }
14
+ return { hit, total };
15
+ }
16
+ export function computeCoverageDrop(current, baseline, rules, changedRules) {
17
+ const dropRules = rules.filter((r) => r.no_coverage_drop);
18
+ return dropRules.map((rule) => {
19
+ const maxDrop = rule.max_coverage_drop ?? 0;
20
+ if (baseline === null || (changedRules !== undefined && !changedRules.has(rule))) {
21
+ return {
22
+ rule: rule.paths,
23
+ currentPct: null,
24
+ baselinePct: null,
25
+ drop: null,
26
+ maxDrop,
27
+ passed: true,
28
+ skipped: true,
29
+ };
30
+ }
31
+ const cur = fileTotals(current, rule, rules);
32
+ const base = fileTotals(baseline, rule, rules);
33
+ const currentPct = cur.total === 0 ? null : (cur.hit / cur.total) * 100;
34
+ const baselinePct = base.total === 0 ? null : (base.hit / base.total) * 100;
35
+ const drop = currentPct !== null && baselinePct !== null ? baselinePct - currentPct : null;
36
+ const passed = drop === null || drop <= maxDrop + 1e-9;
37
+ return { rule: rule.paths, currentPct, baselinePct, drop, maxDrop, passed, skipped: false };
38
+ });
39
+ }
@@ -114,6 +114,9 @@ export function parseDiff(text) {
114
114
  }
115
115
  /** Runs git merge-base + git diff and returns the raw diff text. Internal shared helper. */
116
116
  export async function runGitDiff(baseRef, headRef) {
117
+ if (baseRef.startsWith("-") || headRef.startsWith("-")) {
118
+ throw new Error("Git reference cannot start with a hyphen (prevents argument injection)");
119
+ }
117
120
  const { spawn } = await import("node:child_process");
118
121
  const spawnProcess = (cmd, args) => new Promise((resolve, reject) => {
119
122
  const chunks = [];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Full-fidelity LCOV data for one source file.
3
+ * Preserves function, branch, and line coverage records.
4
+ */
5
+ export type FullFileCoverage = {
6
+ /** FN: definitions keyed by function name. */
7
+ functions: Map<string, {
8
+ line: number;
9
+ name: string;
10
+ }>;
11
+ /** FNDA: hit counts keyed by function name. */
12
+ functionHits: Map<string, number>;
13
+ /** BRDA: hit counts keyed by "lineNo,blockNo,branchNo". */
14
+ branches: Map<string, number>;
15
+ /** DA: hit counts keyed by line number. */
16
+ lines: Map<number, number>;
17
+ };
18
+ /** Map from normalized file path → full LCOV coverage data. */
19
+ export type FullLcovData = Map<string, FullFileCoverage>;
20
+ /**
21
+ * Parses full-fidelity LCOV, preserving FN/FNDA/BRDA/DA records.
22
+ * Hits from repeated records for the same line/function/branch are summed.
23
+ * Summary records (TN/LF/LH/FNF/FNH/BRF/BRH) are skipped and recomputed by toLcovFull.
24
+ */
25
+ export declare function parseLcovFull(text: string, stripPrefixes?: string[]): FullLcovData;
26
+ /** Merges multiple FullLcovData maps by summing all hit counts per file/line/function/branch. */
27
+ export declare function mergeLcovFull(reports: FullLcovData[]): FullLcovData;
28
+ /** Serializes FullLcovData to LCOV text with recomputed FNF/FNH/BRF/BRH/LF/LH summaries. */
29
+ export declare function toLcovFull(lcov: FullLcovData): string;
@@ -0,0 +1,155 @@
1
+ function normalizeSfPath(rawPath, stripPrefixes) {
2
+ let path = rawPath.replace(/\\/g, "/").replace(/^\.\//, "");
3
+ for (const prefix of stripPrefixes) {
4
+ if (path.startsWith(prefix))
5
+ return path.slice(prefix.length);
6
+ }
7
+ if (path.startsWith("/") || /^[A-Z]:\//i.test(path)) {
8
+ const match = path.match(/^.*?\/_?work\/([^/]+)\/\1\//);
9
+ if (match)
10
+ return path.slice(match[0].length);
11
+ }
12
+ return path;
13
+ }
14
+ function getOrCreate(data, file) {
15
+ let cov = data.get(file);
16
+ if (cov === undefined) {
17
+ cov = { branches: new Map(), functionHits: new Map(), functions: new Map(), lines: new Map() };
18
+ data.set(file, cov);
19
+ }
20
+ return cov;
21
+ }
22
+ function applyRecord(line, cov) {
23
+ if (line.startsWith("FN:")) {
24
+ const comma = line.indexOf(",", 3);
25
+ if (comma === -1)
26
+ return;
27
+ const lineNum = parseInt(line.slice(3, comma), 10);
28
+ const name = line.slice(comma + 1);
29
+ if (Number.isFinite(lineNum) && name)
30
+ cov.functions.set(name, { line: lineNum, name });
31
+ }
32
+ else if (line.startsWith("FNDA:")) {
33
+ const comma = line.indexOf(",", 5);
34
+ if (comma === -1)
35
+ return;
36
+ const hits = parseInt(line.slice(5, comma), 10);
37
+ const name = line.slice(comma + 1);
38
+ if (Number.isFinite(hits) && name)
39
+ cov.functionHits.set(name, (cov.functionHits.get(name) ?? 0) + hits);
40
+ }
41
+ else if (line.startsWith("BRDA:")) {
42
+ const parts = line.slice(5).split(",");
43
+ if (parts.length !== 4)
44
+ return;
45
+ const key = `${parts[0]},${parts[1]},${parts[2]}`;
46
+ const raw = parts[3];
47
+ const hits = raw === "-" ? 0 : parseInt(raw, 10);
48
+ if (Number.isFinite(hits))
49
+ cov.branches.set(key, (cov.branches.get(key) ?? 0) + hits);
50
+ }
51
+ else if (line.startsWith("DA:")) {
52
+ const comma = line.indexOf(",", 3);
53
+ if (comma === -1)
54
+ return;
55
+ const lineNum = parseInt(line.slice(3, comma), 10);
56
+ const hits = parseInt(line.slice(comma + 1), 10);
57
+ if (Number.isFinite(lineNum) && Number.isFinite(hits))
58
+ cov.lines.set(lineNum, (cov.lines.get(lineNum) ?? 0) + hits);
59
+ }
60
+ // TN:, LF:, LH:, FNF:, FNH:, BRF:, BRH: are summary lines — recomputed by toLcovFull
61
+ }
62
+ /**
63
+ * Parses full-fidelity LCOV, preserving FN/FNDA/BRDA/DA records.
64
+ * Hits from repeated records for the same line/function/branch are summed.
65
+ * Summary records (TN/LF/LH/FNF/FNH/BRF/BRH) are skipped and recomputed by toLcovFull.
66
+ */
67
+ export function parseLcovFull(text, stripPrefixes = []) {
68
+ const result = new Map();
69
+ let current = null;
70
+ let start = 0;
71
+ while (start < text.length) {
72
+ let end = text.indexOf("\n", start);
73
+ if (end === -1)
74
+ end = text.length;
75
+ let lineEnd = end;
76
+ while (lineEnd > start) {
77
+ const c = text.charCodeAt(lineEnd - 1);
78
+ if (c === 32 || c === 9 || c === 13) {
79
+ lineEnd--;
80
+ continue;
81
+ }
82
+ break;
83
+ }
84
+ if (text.startsWith("SF:", start)) {
85
+ current = getOrCreate(result, normalizeSfPath(text.slice(start + 3, lineEnd), stripPrefixes));
86
+ }
87
+ else if (lineEnd - start === 13 && text.startsWith("end_of_record", start)) {
88
+ current = null;
89
+ }
90
+ else if (current !== null) {
91
+ applyRecord(text.slice(start, lineEnd), current);
92
+ }
93
+ start = end + 1;
94
+ }
95
+ return result;
96
+ }
97
+ /** Merges multiple FullLcovData maps by summing all hit counts per file/line/function/branch. */
98
+ export function mergeLcovFull(reports) {
99
+ const merged = new Map();
100
+ for (const report of reports) {
101
+ for (const [file, cov] of report) {
102
+ const target = merged.get(file);
103
+ if (target === undefined) {
104
+ merged.set(file, {
105
+ functions: new Map(cov.functions),
106
+ functionHits: new Map(cov.functionHits),
107
+ branches: new Map(cov.branches),
108
+ lines: new Map(cov.lines),
109
+ });
110
+ }
111
+ else {
112
+ for (const [name, def] of cov.functions) {
113
+ if (!target.functions.has(name))
114
+ target.functions.set(name, def);
115
+ }
116
+ for (const [name, hits] of cov.functionHits)
117
+ target.functionHits.set(name, (target.functionHits.get(name) ?? 0) + hits);
118
+ for (const [key, hits] of cov.branches)
119
+ target.branches.set(key, (target.branches.get(key) ?? 0) + hits);
120
+ for (const [lineNum, hits] of cov.lines)
121
+ target.lines.set(lineNum, (target.lines.get(lineNum) ?? 0) + hits);
122
+ }
123
+ }
124
+ }
125
+ return merged;
126
+ }
127
+ /** Serializes FullLcovData to LCOV text with recomputed FNF/FNH/BRF/BRH/LF/LH summaries. */
128
+ export function toLcovFull(lcov) {
129
+ if (lcov.size === 0)
130
+ return "";
131
+ const out = [];
132
+ for (const [file, cov] of [...lcov.entries()].sort(([a], [b]) => a.localeCompare(b))) {
133
+ out.push("TN:", `SF:${file}`);
134
+ const fns = [...cov.functions.values()].sort((a, b) => a.line - b.line || a.name.localeCompare(b.name));
135
+ for (const fn of fns)
136
+ out.push(`FN:${fn.line},${fn.name}`);
137
+ const fnHits = [...cov.functionHits.entries()].sort(([a], [b]) => a.localeCompare(b));
138
+ for (const [name, hits] of fnHits)
139
+ out.push(`FNDA:${hits},${name}`);
140
+ out.push(`FNF:${cov.functions.size}`);
141
+ out.push(`FNH:${[...cov.functionHits.values()].filter((h) => h > 0).length}`);
142
+ const brs = [...cov.branches.entries()].sort(([a], [b]) => a.localeCompare(b));
143
+ for (const [key, hits] of brs)
144
+ out.push(`BRDA:${key},${hits}`);
145
+ out.push(`BRF:${cov.branches.size}`);
146
+ out.push(`BRH:${[...cov.branches.values()].filter((h) => h > 0).length}`);
147
+ const lines = [...cov.lines.entries()].sort(([a], [b]) => a - b);
148
+ for (const [lineNum, hits] of lines)
149
+ out.push(`DA:${lineNum},${hits}`);
150
+ out.push(`LF:${cov.lines.size}`);
151
+ out.push(`LH:${[...cov.lines.values()].filter((h) => h > 0).length}`);
152
+ out.push("end_of_record");
153
+ }
154
+ return `${out.join("\n")}\n`;
155
+ }
@@ -64,9 +64,16 @@ export function lcovBufferToIstanbul(lcov, stripPrefixes) {
64
64
  continue;
65
65
  const fileCov = coverage[filePath]; // filePath was validated against coverage when SF: was processed
66
66
  if (line.startsWith("DA:")) {
67
- const [lineNo, hits] = line.slice(3).split(",", 2);
68
- const l = Number.parseInt(lineNo, 10);
69
- const h = Number.parseInt(hits ?? "", 10);
67
+ // Optimization: Instead of using `split(",")`, we use `indexOf` and `slice`
68
+ // to avoid allocating intermediate arrays, reducing garbage collection pauses.
69
+ const rest = line.slice(3);
70
+ const commaIdx = rest.indexOf(",");
71
+ if (commaIdx === -1)
72
+ continue;
73
+ const lineNoStr = rest.slice(0, commaIdx);
74
+ const hitsStr = rest.slice(commaIdx + 1);
75
+ const l = Number.parseInt(lineNoStr, 10);
76
+ const h = Number.parseInt(hitsStr, 10);
70
77
  if (!Number.isInteger(l) || !Number.isInteger(h))
71
78
  continue;
72
79
  const key = String(l);
@@ -112,11 +119,25 @@ export function lcovBufferToIstanbul(lcov, stripPrefixes) {
112
119
  }
113
120
  }
114
121
  else if (line.startsWith("BRDA:")) {
115
- const parts = line.slice(5).split(",", 4);
116
- const lineNo = Number.parseInt(parts[0], 10);
117
- const blockId = parts[1] ?? "";
118
- const branchId = parts[2] ?? "";
119
- const taken = parts[3] === "-" ? 0 : Number.parseInt(parts[3] ?? "", 10);
122
+ // Optimization: Instead of using `split(",")`, we use `indexOf` and `slice`
123
+ // to avoid allocating intermediate arrays, reducing garbage collection pauses.
124
+ const rest = line.slice(5);
125
+ let comma1 = rest.indexOf(",");
126
+ if (comma1 === -1)
127
+ continue;
128
+ let comma2 = rest.indexOf(",", comma1 + 1);
129
+ if (comma2 === -1)
130
+ continue;
131
+ let comma3 = rest.indexOf(",", comma2 + 1);
132
+ if (comma3 === -1)
133
+ continue;
134
+ const lineNoStr = rest.slice(0, comma1);
135
+ const blockId = rest.slice(comma1 + 1, comma2);
136
+ const branchId = rest.slice(comma2 + 1, comma3);
137
+ const comma4 = rest.indexOf(",", comma3 + 1);
138
+ const takenStr = comma4 === -1 ? rest.slice(comma3 + 1) : rest.slice(comma3 + 1, comma4);
139
+ const lineNo = Number.parseInt(lineNoStr, 10);
140
+ const taken = takenStr === "-" ? 0 : Number.parseInt(takenStr, 10);
120
141
  if (!Number.isInteger(lineNo) || !blockId || !branchId || !Number.isInteger(taken))
121
142
  continue;
122
143
  const blockKey = `${lineNo}-${blockId}`;
@@ -140,7 +161,10 @@ export function lcovBufferToIstanbul(lcov, stripPrefixes) {
140
161
  for (const [fp, blocks] of fileBranches) {
141
162
  const fileCov = coverage[fp]; // fp was validated against coverage when added to fileBranches
142
163
  for (const [blockKey, branches] of blocks) {
143
- const lineNo = Number.parseInt(blockKey.split("-")[0], 10); // blockKey is always "N-blockId"
164
+ // blockKey is always "N-blockId". Optimization: avoid split("-")
165
+ const dashIdx = blockKey.indexOf("-");
166
+ const lineNoStr = blockKey.slice(0, dashIdx);
167
+ const lineNo = Number.parseInt(lineNoStr, 10);
144
168
  const branchLoc = loc(lineNo);
145
169
  const sorted = [...branches.entries()].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }));
146
170
  fileCov.branchMap[blockKey] = {
@@ -1,4 +1,4 @@
1
1
  import type { CoverageCheckResult } from "./types.mts";
2
2
  export declare const COMMENT_MARKER = "<!-- coverage-check -->";
3
- export declare function collapseRanges(lines: number[]): string;
3
+ export declare function collapseRanges(lines: number[], prefix?: string): string;
4
4
  export declare function renderFailureComment(result: CoverageCheckResult, runUrl: string, now?: string): string;
@@ -1,5 +1,5 @@
1
1
  export const COMMENT_MARKER = "<!-- coverage-check -->";
2
- export function collapseRanges(lines) {
2
+ export function collapseRanges(lines, prefix = "L") {
3
3
  if (lines.length === 0)
4
4
  return "";
5
5
  const sorted = [...lines].sort((a, b) => a - b);
@@ -12,12 +12,12 @@ export function collapseRanges(lines) {
12
12
  end = n;
13
13
  }
14
14
  else {
15
- ranges.push(start === end ? `L${start}` : `L${start}-${end}`);
15
+ ranges.push(start === end ? `${prefix}${start}` : `${prefix}${start}-${end}`);
16
16
  start = n;
17
17
  end = n;
18
18
  }
19
19
  }
20
- ranges.push(start === end ? `L${start}` : `L${start}-${end}`);
20
+ ranges.push(start === end ? `${prefix}${start}` : `${prefix}${start}-${end}`);
21
21
  return ranges.join(", ");
22
22
  }
23
23
  function pct(bucket) {
@@ -31,6 +31,29 @@ function renderFileList(files) {
31
31
  .map((f) => `- \`${f.file}\`: ${collapseRanges(f.uncoveredLines)}`)
32
32
  .join("\n");
33
33
  }
34
+ function pctOrDash(n) {
35
+ /* c8 ignore next -- failing drops always have non-null currentPct/baselinePct */
36
+ return n === null ? "—" : `${n.toFixed(2)}%`;
37
+ }
38
+ function dropOrDash(n) {
39
+ /* c8 ignore next -- failing drops always have non-null drop */
40
+ return n === null ? "—" : `${n.toFixed(2)}pp`;
41
+ }
42
+ function renderRegressionSection(drops) {
43
+ const failingDrops = drops.filter((d) => !d.passed && !d.skipped);
44
+ if (failingDrops.length === 0)
45
+ return "";
46
+ const rows = failingDrops.map((d) => {
47
+ const safeRule = d.rule.replaceAll("|", "\\|").replaceAll("`", "\\`");
48
+ return `| \`${safeRule}\` | ${pctOrDash(d.currentPct)} | ${pctOrDash(d.baselinePct)} | ${dropOrDash(d.drop)} | ${d.maxDrop}pp |`;
49
+ });
50
+ const table = [
51
+ "| Rule | Current | Baseline | Drop | Max allowed |",
52
+ "|---|---|---|---|---|",
53
+ ...rows,
54
+ ].join("\n");
55
+ return `\n### Coverage regression\n\n${table}`;
56
+ }
34
57
  export function renderFailureComment(result, runUrl, now = new Date().toISOString()) {
35
58
  const failingBuckets = result.buckets.filter((b) => !b.passed);
36
59
  const table = [
@@ -51,6 +74,7 @@ export function renderFailureComment(result, runUrl, now = new Date().toISOStrin
51
74
  const informationalSection = informationalLines.length > 0
52
75
  ? `\n<details><summary>Informational (no rule)</summary>\n\n${informationalLines}\n</details>`
53
76
  : "";
77
+ const regressionSection = renderRegressionSection(result.drops);
54
78
  return `${COMMENT_MARKER}
55
79
  ## Patch coverage gate failed
56
80
 
@@ -59,7 +83,7 @@ ${table}
59
83
  ### Uncovered lines
60
84
 
61
85
  ${sections}
62
- ${informationalSection}
86
+ ${informationalSection}${regressionSection}
63
87
 
64
88
  _Last updated: ${now} · [Workflow run](${runUrl})_`;
65
89
  }
@@ -1,4 +1,9 @@
1
- import type { CoverageRule } from "./types.mts";
1
+ import type { CoverageRule, DiffLines } from "./types.mts";
2
2
  export declare function loadRules(rulesPath: string): CoverageRule[];
3
3
  /** Returns the first matching rule for a repo-root-relative file path, or null. */
4
4
  export declare function matchRule(file: string, rules: CoverageRule[]): CoverageRule | null;
5
+ /**
6
+ * Returns the set of rules that have at least one changed file in the diff.
7
+ * Used by `--drop-only-changed-areas` to scope the coverage-drop gate.
8
+ */
9
+ export declare function buildChangedRules(diff: DiffLines, rules: CoverageRule[]): Set<CoverageRule>;
@@ -2,6 +2,21 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { matchesGlob } from "node:path";
4
4
  import yaml from "js-yaml";
5
+ function validateDropRuleFields(rule, i, rulesPath) {
6
+ const noDrop = rule.no_coverage_drop;
7
+ if (noDrop !== undefined && typeof noDrop !== "boolean") {
8
+ throw new Error(`${rulesPath}: rule[${i}].no_coverage_drop must be a boolean`);
9
+ }
10
+ const maxDrop = rule.max_coverage_drop;
11
+ if (maxDrop !== undefined) {
12
+ if (!Number.isFinite(maxDrop) || maxDrop < 0) {
13
+ throw new Error(`${rulesPath}: rule[${i}].max_coverage_drop must be a non-negative number`);
14
+ }
15
+ if (!noDrop) {
16
+ throw new Error(`${rulesPath}: rule[${i}].max_coverage_drop requires no_coverage_drop: true`);
17
+ }
18
+ }
19
+ }
5
20
  export function loadRules(rulesPath) {
6
21
  const text = readFileSync(rulesPath, "utf8");
7
22
  const parsed = yaml.load(text);
@@ -17,6 +32,7 @@ export function loadRules(rulesPath) {
17
32
  if (!Number.isFinite(min) || min < 0 || min > 100) {
18
33
  throw new Error(`${rulesPath}: rule[${i}].patch_coverage_min must be a number between 0 and 100`);
19
34
  }
35
+ validateDropRuleFields(rule, i, rulesPath);
20
36
  }
21
37
  return parsed.rules;
22
38
  }
@@ -28,3 +44,10 @@ export function matchRule(file, rules) {
28
44
  }
29
45
  return null;
30
46
  }
47
+ /**
48
+ * Returns the set of rules that have at least one changed file in the diff.
49
+ * Used by `--drop-only-changed-areas` to scope the coverage-drop gate.
50
+ */
51
+ export function buildChangedRules(diff, rules) {
52
+ return new Set([...diff.keys()].map((f) => matchRule(f, rules)).filter((r) => r !== null));
53
+ }
@@ -52,18 +52,33 @@ export function buildSummaryMarkdown(suiteSources, result, runUrl, branch = "mai
52
52
  "|---|---|---|---|",
53
53
  ruleRows || "| — | — | — | — |",
54
54
  ].join("\n");
55
+ let dropSection = "";
56
+ if (result.drops.length > 0) {
57
+ const dropRows = result.drops
58
+ .map((d) => {
59
+ let status;
60
+ if (d.skipped)
61
+ status = "⏭️";
62
+ else if (d.passed)
63
+ status = "✅";
64
+ else
65
+ status = "❌";
66
+ const baselinePct = d.baselinePct === null ? "—" : `${d.baselinePct.toFixed(2)}%`;
67
+ const currentPct = d.currentPct === null ? "—" : `${d.currentPct.toFixed(2)}%`;
68
+ const drop = d.drop === null ? "—" : `${d.drop.toFixed(2)}pp`;
69
+ return `| ${codeSpan(d.rule)} | ${d.maxDrop}pp | ${baselinePct} | ${currentPct} | ${drop} | ${status} |`;
70
+ })
71
+ .join("\n");
72
+ const dropTable = [
73
+ "| Rule | Max drop | Baseline | Current | Drop | Status |",
74
+ "|---|---|---|---|---|---|",
75
+ dropRows,
76
+ ].join("\n");
77
+ dropSection = `\n### Coverage drop\n\n${dropTable}\n`;
78
+ }
55
79
  const overall = result.passed ? "✅ passed" : "❌ failed";
56
80
  const runLink = runUrl !== "N/A" ? `\n\n_[View run](${runUrl})_` : "";
57
- return `## Coverage summary — ${overall}
58
-
59
- ### Suite totals
60
-
61
- ${suiteTable}
62
-
63
- ### Patch coverage
64
-
65
- ${ruleTable}${runLink}
66
- `;
81
+ return `## Coverage summary — ${overall}\n\n### Suite totals\n\n${suiteTable}\n\n### Patch coverage\n\n${ruleTable}${dropSection}${runLink}\n`;
67
82
  }
68
83
  export function writeSummary(summaryFile, suiteSources, result, runUrl, branch) {
69
84
  appendFileSync(summaryFile, buildSummaryMarkdown(suiteSources, result, runUrl, branch), "utf8");
@@ -1,6 +1,8 @@
1
1
  export type CoverageRule = {
2
2
  paths: string;
3
3
  patch_coverage_min: number;
4
+ no_coverage_drop?: boolean;
5
+ max_coverage_drop?: number;
4
6
  };
5
7
  /** Map from repo-root-relative file path to map of added line number → trimmed source text. */
6
8
  export type DiffLineContent = Map<string, Map<number, string>>;
@@ -26,8 +28,18 @@ export type BucketResult = {
26
28
  files: FileCoverageResult[];
27
29
  passed: boolean;
28
30
  };
31
+ export type DropResult = {
32
+ rule: string;
33
+ currentPct: number | null;
34
+ baselinePct: number | null;
35
+ drop: number | null;
36
+ maxDrop: number;
37
+ passed: boolean;
38
+ skipped: boolean;
39
+ };
29
40
  export type CoverageCheckResult = {
30
41
  buckets: BucketResult[];
42
+ drops: DropResult[];
31
43
  informational: FileCoverageResult[];
32
44
  passed: boolean;
33
45
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coverage-check",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
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",
@@ -40,7 +40,7 @@
40
40
  "@types/node": "^25.8.0",
41
41
  "@vitest/coverage-v8": "^4.1.6",
42
42
  "husky": "^9.1.7",
43
- "oxfmt": "^0.50.0",
43
+ "oxfmt": "^0.52.0",
44
44
  "oxlint": "^1.65.0",
45
45
  "typescript": "^6.0.3",
46
46
  "vitest": "^4.1.6"