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 +141 -17
- package/dist/src/cli.mjs +3 -0
- package/dist/src/commands/check-args.d.mts +6 -0
- package/dist/src/commands/check-args.mjs +12 -0
- package/dist/src/commands/check-output.d.mts +11 -0
- package/dist/src/commands/check-output.mjs +70 -0
- package/dist/src/commands/check.mjs +25 -22
- package/dist/src/commands/merge-args.d.mts +7 -0
- package/dist/src/commands/merge-args.mjs +38 -0
- package/dist/src/commands/merge.d.mts +9 -0
- package/dist/src/commands/merge.mjs +41 -0
- package/dist/src/coverage-check.d.mts +7 -1
- package/dist/src/coverage-check.mjs +4 -0
- package/dist/src/coverage-drop.d.mts +2 -0
- package/dist/src/coverage-drop.mjs +39 -0
- package/dist/src/diff-parser.mjs +3 -0
- package/dist/src/lcov-records.d.mts +29 -0
- package/dist/src/lcov-records.mjs +155 -0
- package/dist/src/lcov-to-istanbul.mjs +33 -9
- package/dist/src/report.d.mts +1 -1
- package/dist/src/report.mjs +28 -4
- package/dist/src/rules.d.mts +6 -1
- package/dist/src/rules.mjs +23 -0
- package/dist/src/step-summary.mjs +25 -10
- package/dist/src/types.d.mts +12 -0
- package/package.json +2 -2
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
|
|
143
|
-
|
|
|
144
|
-
| `--rules`
|
|
145
|
-
| `--artifacts`
|
|
146
|
-
| `--base`
|
|
147
|
-
| `--head`
|
|
148
|
-
| `--store-fs`
|
|
149
|
-
| `--store`
|
|
150
|
-
| `--store-s3`
|
|
151
|
-
| `--branch`
|
|
152
|
-
| `--suite`
|
|
153
|
-
| `--strip-prefix`
|
|
154
|
-
| `--pr`
|
|
155
|
-
| `--repo`
|
|
156
|
-
| `--json`
|
|
157
|
-
| `--annotate-source`
|
|
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 {
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
112
|
-
const
|
|
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,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
|
|
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,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
|
+
}
|
package/dist/src/diff-parser.mjs
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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] = {
|
package/dist/src/report.d.mts
CHANGED
|
@@ -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;
|
package/dist/src/report.mjs
CHANGED
|
@@ -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 ?
|
|
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 ?
|
|
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
|
}
|
package/dist/src/rules.d.mts
CHANGED
|
@@ -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>;
|
package/dist/src/rules.mjs
CHANGED
|
@@ -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");
|
package/dist/src/types.d.mts
CHANGED
|
@@ -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.
|
|
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.
|
|
43
|
+
"oxfmt": "^0.52.0",
|
|
44
44
|
"oxlint": "^1.65.0",
|
|
45
45
|
"typescript": "^6.0.3",
|
|
46
46
|
"vitest": "^4.1.6"
|