coverage-check 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -1
- package/dist/src/cli.mjs +25 -0
- package/dist/src/commands/check-args.d.mts +6 -0
- package/dist/src/commands/check-args.mjs +20 -2
- package/dist/src/commands/check-output.d.mts +5 -0
- package/dist/src/commands/check-output.mjs +15 -10
- package/dist/src/commands/check-render.d.mts +1 -0
- package/dist/src/commands/check-render.mjs +27 -0
- package/dist/src/commands/check-result.d.mts +17 -0
- package/dist/src/commands/check-result.mjs +12 -0
- package/dist/src/commands/check.d.mts +21 -0
- package/dist/src/commands/check.mjs +150 -49
- package/dist/src/commands/prepare-artifacts.d.mts +18 -0
- package/dist/src/commands/prepare-artifacts.mjs +105 -0
- package/dist/src/coverage-check.d.mts +6 -2
- package/dist/src/coverage-check.mjs +4 -1
- package/dist/src/diff-parser.d.mts +1 -1
- package/dist/src/diff-parser.mjs +4 -2
- package/dist/src/github-comment.mjs +6 -4
- package/dist/src/lcov-records.mjs +13 -4
- package/dist/src/rules.d.mts +4 -0
- package/dist/src/rules.mjs +10 -0
- package/dist/src/s3-suite-store-reads.d.mts +1 -1
- package/dist/src/s3-suite-store-reads.mjs +2 -2
- package/dist/src/s3-suite-store.d.mts +0 -1
- package/dist/src/s3-suite-store.mjs +2 -1
- package/dist/src/suite-store.d.mts +1 -0
- package/dist/src/suite-store.mjs +22 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -137,6 +137,16 @@ Two common sources of confusion:
|
|
|
137
137
|
The annotation affects only the stdout failure output. The GitHub PR sticky comment and Actions step
|
|
138
138
|
summary are unchanged.
|
|
139
139
|
|
|
140
|
+
### Renamed and relocated files
|
|
141
|
+
|
|
142
|
+
`coverage-check` asks Git to detect renames with no rename-attempt limit before parsing patch lines.
|
|
143
|
+
Pure file moves do not create patch-coverage obligations: unchanged relocated lines are not treated
|
|
144
|
+
as newly added lines. If a moved file also changes content, Git still emits normal hunks for the
|
|
145
|
+
edited new-side lines, and those lines must satisfy the matching patch-coverage rule.
|
|
146
|
+
|
|
147
|
+
Large rewrites that fall below Git's rename similarity threshold may still appear as delete/add
|
|
148
|
+
pairs. In that case the destination file's executable lines are checked as new patch lines.
|
|
149
|
+
|
|
140
150
|
### Merging LCOV files
|
|
141
151
|
|
|
142
152
|
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`):
|
|
@@ -162,6 +172,22 @@ coverage-check check \
|
|
|
162
172
|
--advisory
|
|
163
173
|
```
|
|
164
174
|
|
|
175
|
+
For wrappers that need machine-readable advisory results without a temp file, pass `--json -`.
|
|
176
|
+
This writes JSON-only output to stdout and suppresses the human report:
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
coverage-check check \
|
|
180
|
+
--rules .coverage-rules.yml \
|
|
181
|
+
--artifacts ./coverage-artifacts \
|
|
182
|
+
--base origin/main \
|
|
183
|
+
--head HEAD \
|
|
184
|
+
--advisory \
|
|
185
|
+
--json -
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
JSON output keeps the normal check result fields (`passed`, `buckets`, `drops`, `informational`) and
|
|
189
|
+
adds `exitCode`, `advisory`, and `skipped`.
|
|
190
|
+
|
|
165
191
|
### PR-scoped regression gate
|
|
166
192
|
|
|
167
193
|
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:
|
|
@@ -191,6 +217,24 @@ coverage-check check \
|
|
|
191
217
|
|
|
192
218
|
`--require-artifact` is also accepted by `coverage-check merge`.
|
|
193
219
|
|
|
220
|
+
### Preparing fan-in artifacts
|
|
221
|
+
|
|
222
|
+
Use `prepare-artifacts` before a fan-in coverage check when downloaded artifacts may be either
|
|
223
|
+
nested as `coverage-<suite>/lcov.info` or flattened to a root-level `lcov.info`. Pass one
|
|
224
|
+
`--expect-suite <job>=<suite>` for each successful coverage producer. The command normalizes a
|
|
225
|
+
single flat LCOV into the expected suite directory and fails if any expected LCOV is missing:
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
coverage-check prepare-artifacts \
|
|
229
|
+
--artifacts ./coverage-artifacts \
|
|
230
|
+
--expect-suite test-backend=backend \
|
|
231
|
+
--expect-suite test-web=web
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
For local or fan-in wrappers, `check --aggregate-artifacts` treats all fresh LCOV files as one source
|
|
235
|
+
for diagnostics, `check --fail-on-empty` exits `1` instead of skipping when no LCOV exists, and
|
|
236
|
+
`check --ignore-path <glob>` prepends a zero-threshold override for CI-only paths in that run.
|
|
237
|
+
|
|
194
238
|
## Rules file
|
|
195
239
|
|
|
196
240
|
```yaml
|
|
@@ -235,6 +279,8 @@ First-match-wins means that if you have a more specific rule before a broader on
|
|
|
235
279
|
|
|
236
280
|
## CLI reference
|
|
237
281
|
|
|
282
|
+
Run `coverage-check --help` or `coverage-check check --help` to print the available commands and check flags.
|
|
283
|
+
|
|
238
284
|
### `coverage-check check`
|
|
239
285
|
|
|
240
286
|
| Flag | Default | Description |
|
|
@@ -251,11 +297,14 @@ First-match-wins means that if you have a more specific rule before a broader on
|
|
|
251
297
|
| `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
|
|
252
298
|
| `--pr` | — | Pull request number for sticky comment |
|
|
253
299
|
| `--repo` | `$GITHUB_REPOSITORY` | `owner/repo` for sticky comment |
|
|
254
|
-
| `--json` | — | Write JSON result to this path
|
|
300
|
+
| `--json` | — | Write JSON result to this path; use `-` for JSON-only stdout |
|
|
255
301
|
| `--annotate-source` | — | Print the trimmed source text of each uncovered line in stdout (does not alter PR comment or step summary) |
|
|
256
302
|
| `--advisory` | — | Exit `0` even on shortfall; still prints, writes JSON, and posts PR comment |
|
|
257
303
|
| `--drop-only-changed-areas` | — | Restrict `no_coverage_drop` to rule areas that have ≥1 changed file; others are reported as skipped |
|
|
258
304
|
| `--require-artifact` | — | Fail (exit `2`) if this relative path is absent under `--artifacts` (repeatable) |
|
|
305
|
+
| `--fail-on-empty` | — | Exit `1` when no coverage data is found instead of skipping |
|
|
306
|
+
| `--aggregate-artifacts` | — | Treat fresh LCOV files as one source for diagnostics and summaries |
|
|
307
|
+
| `--ignore-path` | — | Prepend a zero-threshold override glob for this run (repeatable) |
|
|
259
308
|
|
|
260
309
|
### `coverage-check merge`
|
|
261
310
|
|
|
@@ -268,6 +317,13 @@ First-match-wins means that if you have a more specific rule before a broader on
|
|
|
268
317
|
|
|
269
318
|
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.
|
|
270
319
|
|
|
320
|
+
### `coverage-check prepare-artifacts`
|
|
321
|
+
|
|
322
|
+
| Flag | Default | Description |
|
|
323
|
+
| ---------------- | ---------------------- | ---------------------------------------------- |
|
|
324
|
+
| `--artifacts` | `./coverage-artifacts` | Coverage artifact directory to normalize/check |
|
|
325
|
+
| `--expect-suite` | — | Expected producer and suite as `<job>=<suite>` |
|
|
326
|
+
|
|
271
327
|
### `coverage-check store-put`
|
|
272
328
|
|
|
273
329
|
| Flag | Default | Description |
|
|
@@ -289,10 +345,15 @@ When `--sha` and `--branch` are both provided, `store-put` writes a SHA-addresse
|
|
|
289
345
|
|
|
290
346
|
```ts
|
|
291
347
|
import {
|
|
348
|
+
checkCoverage,
|
|
349
|
+
evaluateCheck,
|
|
292
350
|
runCheck,
|
|
293
351
|
runMerge,
|
|
294
352
|
runStorePut,
|
|
353
|
+
prepareCoverageArtifacts,
|
|
295
354
|
collapseRanges,
|
|
355
|
+
collectLcovFiles,
|
|
356
|
+
zeroThresholdGlobs,
|
|
296
357
|
parseLcovFull,
|
|
297
358
|
mergeLcovFull,
|
|
298
359
|
toLcovFull,
|
|
@@ -306,6 +367,27 @@ const fsStore = new FileSystemSuiteStore("/path/to/store");
|
|
|
306
367
|
// S3 store (requires AWS credentials — see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html)
|
|
307
368
|
const s3Store = new S3SuiteStore({ bucket: "my-bucket", prefix: "coverage" });
|
|
308
369
|
|
|
370
|
+
const check = await checkCoverage({
|
|
371
|
+
rules: ".coverage-rules.yml",
|
|
372
|
+
artifacts: "./coverage",
|
|
373
|
+
base: "origin/main",
|
|
374
|
+
head: "HEAD",
|
|
375
|
+
pr: null,
|
|
376
|
+
repo: "",
|
|
377
|
+
json: null,
|
|
378
|
+
stripPrefixes: [],
|
|
379
|
+
store: s3Store,
|
|
380
|
+
suite: "backend",
|
|
381
|
+
branch: "main",
|
|
382
|
+
advisory: true,
|
|
383
|
+
dropOnlyChangedAreas: false,
|
|
384
|
+
requireArtifacts: [],
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
if (!check.result?.passed) {
|
|
388
|
+
console.log(check.exitCode, check.advisory, check.result);
|
|
389
|
+
}
|
|
390
|
+
|
|
309
391
|
await runCheck({
|
|
310
392
|
rules: ".coverage-rules.yml",
|
|
311
393
|
artifacts: "./coverage",
|
|
@@ -321,8 +403,35 @@ await runCheck({
|
|
|
321
403
|
advisory: false,
|
|
322
404
|
dropOnlyChangedAreas: false,
|
|
323
405
|
requireArtifacts: [],
|
|
406
|
+
failOnEmpty: false,
|
|
407
|
+
aggregateArtifacts: false,
|
|
408
|
+
ignorePaths: [],
|
|
324
409
|
});
|
|
325
410
|
|
|
411
|
+
const evaluated = await evaluateCheck({
|
|
412
|
+
rules: ".coverage-rules.yml",
|
|
413
|
+
artifacts: "./coverage",
|
|
414
|
+
base: "origin/main",
|
|
415
|
+
head: "HEAD",
|
|
416
|
+
pr: null,
|
|
417
|
+
repo: "",
|
|
418
|
+
json: null,
|
|
419
|
+
stripPrefixes: [],
|
|
420
|
+
store: null,
|
|
421
|
+
suite: null,
|
|
422
|
+
});
|
|
423
|
+
if (evaluated.result?.passed === false) {
|
|
424
|
+
// Integrations can render their own advisory output from the typed result.
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
prepareCoverageArtifacts({
|
|
428
|
+
artifacts: "./coverage-artifacts",
|
|
429
|
+
expectedSuites: [{ job: "test-backend", suite: "backend" }],
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
collectLcovFiles("./coverage-artifacts");
|
|
433
|
+
zeroThresholdGlobs(".coverage-rules.yml");
|
|
434
|
+
|
|
326
435
|
await runMerge({
|
|
327
436
|
artifacts: "./coverage-artifacts",
|
|
328
437
|
output: "./coverage-merged/lcov.info",
|
package/dist/src/cli.mjs
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { main as checkMain } from "./commands/check.mjs";
|
|
2
|
+
import { checkHelp } from "./commands/check-render.mjs";
|
|
2
3
|
import { main as storePutMain } from "./commands/store-put.mjs";
|
|
3
4
|
import { main as htmlMain } from "./commands/html.mjs";
|
|
4
5
|
import { main as mergeMain } from "./commands/merge.mjs";
|
|
5
6
|
import { main as summaryMain } from "./commands/summary.mjs";
|
|
7
|
+
import { main as prepareArtifactsMain } from "./commands/prepare-artifacts.mjs";
|
|
6
8
|
const stderr = (msg) => process.stderr.write(`${msg}\n`);
|
|
9
|
+
const stdout = (msg) => process.stdout.write(`${msg}\n`);
|
|
7
10
|
export async function main(argv) {
|
|
8
11
|
const sub = argv[0];
|
|
12
|
+
if (sub === "--help" || sub === "-h") {
|
|
13
|
+
stdout(help());
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
9
16
|
if (!sub || sub.startsWith("-"))
|
|
10
17
|
return checkMain(argv);
|
|
11
18
|
if (sub === "check")
|
|
@@ -18,6 +25,24 @@ export async function main(argv) {
|
|
|
18
25
|
return htmlMain(argv.slice(1));
|
|
19
26
|
if (sub === "summary")
|
|
20
27
|
return summaryMain(argv.slice(1));
|
|
28
|
+
if (sub === "prepare-artifacts")
|
|
29
|
+
return prepareArtifactsMain(argv.slice(1));
|
|
21
30
|
stderr(`coverage-check: unknown subcommand: ${JSON.stringify(sub)}`);
|
|
22
31
|
return 2;
|
|
23
32
|
}
|
|
33
|
+
function help() {
|
|
34
|
+
return `coverage-check
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
coverage-check <command> [options]
|
|
38
|
+
coverage-check [check options]
|
|
39
|
+
|
|
40
|
+
Commands:
|
|
41
|
+
check Check patch coverage (default)
|
|
42
|
+
store-put Store suite LCOV data
|
|
43
|
+
merge Merge lcov.info files
|
|
44
|
+
html Generate HTML coverage reports
|
|
45
|
+
summary Generate a coverage summary
|
|
46
|
+
|
|
47
|
+
${checkHelp()}`;
|
|
48
|
+
}
|
|
@@ -24,5 +24,11 @@ export type CheckArgs = {
|
|
|
24
24
|
dropOnlyChangedAreas?: boolean;
|
|
25
25
|
/** Relative paths under --artifacts that must exist before the check runs (repeatable). */
|
|
26
26
|
requireArtifacts?: string[];
|
|
27
|
+
/** Exit non-zero when no LCOV data is available instead of treating the check as skipped. */
|
|
28
|
+
failOnEmpty?: boolean;
|
|
29
|
+
/** Merge fresh LCOV artifacts before evaluating, so fan-in artifacts are treated as one source. */
|
|
30
|
+
aggregateArtifacts?: boolean;
|
|
31
|
+
/** Path globs to exempt by prepending zero-threshold override rules (repeatable). */
|
|
32
|
+
ignorePaths?: string[];
|
|
27
33
|
};
|
|
28
34
|
export declare function parseCheckArgs(argv: string[]): CheckArgs;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { makeStore } from "../store-factory.mjs";
|
|
2
|
-
import { assertSafePathComponent } from "../suite-store.mjs";
|
|
2
|
+
import { assertSafePathComponent, assertValidRepo } from "../suite-store.mjs";
|
|
3
3
|
export function parseCheckArgs(argv) {
|
|
4
4
|
let storeFs = null;
|
|
5
5
|
let storeS3 = null;
|
|
@@ -20,6 +20,9 @@ export function parseCheckArgs(argv) {
|
|
|
20
20
|
advisory: false,
|
|
21
21
|
dropOnlyChangedAreas: false,
|
|
22
22
|
requireArtifacts: [],
|
|
23
|
+
failOnEmpty: false,
|
|
24
|
+
aggregateArtifacts: false,
|
|
25
|
+
ignorePaths: [],
|
|
23
26
|
};
|
|
24
27
|
for (let i = 0; i < argv.length; i++) {
|
|
25
28
|
const flag = argv[i];
|
|
@@ -92,14 +95,29 @@ export function parseCheckArgs(argv) {
|
|
|
92
95
|
case "--require-artifact":
|
|
93
96
|
args.requireArtifacts.push(val());
|
|
94
97
|
break;
|
|
98
|
+
case "--fail-on-empty":
|
|
99
|
+
args.failOnEmpty = true;
|
|
100
|
+
break;
|
|
101
|
+
case "--aggregate-artifacts":
|
|
102
|
+
args.aggregateArtifacts = true;
|
|
103
|
+
break;
|
|
104
|
+
case "--ignore-path":
|
|
105
|
+
args.ignorePaths.push(val());
|
|
106
|
+
break;
|
|
95
107
|
default:
|
|
96
108
|
throw new Error(`unknown flag: ${flag}`);
|
|
97
109
|
}
|
|
98
110
|
}
|
|
99
111
|
if (storeFs && storeS3)
|
|
100
112
|
throw new Error("--store-fs and --store-s3 are mutually exclusive");
|
|
101
|
-
|
|
113
|
+
const repo = args.repo.trim();
|
|
114
|
+
args.repo = repo;
|
|
115
|
+
if (args.pr !== null && repo.length === 0) {
|
|
102
116
|
throw new Error("--repo is required when --pr is set (or define GITHUB_REPOSITORY)");
|
|
117
|
+
}
|
|
118
|
+
if (repo !== "") {
|
|
119
|
+
args.repo = assertValidRepo(repo);
|
|
120
|
+
}
|
|
103
121
|
args.store = makeStore({ fs: storeFs, s3: storeS3 });
|
|
104
122
|
return args;
|
|
105
123
|
}
|
|
@@ -4,8 +4,13 @@ import type { DropResult, DiffLines, LcovData } from "../types.mts";
|
|
|
4
4
|
* Prints `::error::` to stderr for each missing file and returns false if any are absent.
|
|
5
5
|
*/
|
|
6
6
|
export declare function checkRequiredArtifacts(artifactsDir: string, requireArtifacts: string[]): boolean;
|
|
7
|
+
export declare function missingRequiredArtifacts(artifactsDir: string, requireArtifacts: string[]): string[];
|
|
7
8
|
export declare function warnNonContributing(parsedSources: {
|
|
8
9
|
name: string;
|
|
9
10
|
lcov: LcovData;
|
|
10
11
|
}[], diff: DiffLines): void;
|
|
12
|
+
export declare function nonContributingWarnings(parsedSources: {
|
|
13
|
+
name: string;
|
|
14
|
+
lcov: LcovData;
|
|
15
|
+
}[], diff: DiffLines): string[];
|
|
11
16
|
export declare function printDropOutput(drops: DropResult[]): void;
|
|
@@ -26,23 +26,28 @@ function lcovContributesToDiff(sourceLcov, diff) {
|
|
|
26
26
|
* Prints `::error::` to stderr for each missing file and returns false if any are absent.
|
|
27
27
|
*/
|
|
28
28
|
export function checkRequiredArtifacts(artifactsDir, requireArtifacts) {
|
|
29
|
-
|
|
30
|
-
for (const rel of
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return ok;
|
|
29
|
+
const missing = missingRequiredArtifacts(artifactsDir, requireArtifacts);
|
|
30
|
+
for (const rel of missing)
|
|
31
|
+
stderr(`::error:: missing expected coverage artifact: ${rel}`);
|
|
32
|
+
return missing.length === 0;
|
|
33
|
+
}
|
|
34
|
+
export function missingRequiredArtifacts(artifactsDir, requireArtifacts) {
|
|
35
|
+
return requireArtifacts.filter((rel) => !existsSync(join(artifactsDir, rel)));
|
|
37
36
|
}
|
|
38
37
|
export function warnNonContributing(parsedSources, diff) {
|
|
38
|
+
for (const warning of nonContributingWarnings(parsedSources, diff))
|
|
39
|
+
stderr(warning);
|
|
40
|
+
}
|
|
41
|
+
export function nonContributingWarnings(parsedSources, diff) {
|
|
39
42
|
if (diff.size === 0)
|
|
40
|
-
return;
|
|
43
|
+
return [];
|
|
44
|
+
const warnings = [];
|
|
41
45
|
for (const { name, lcov: sourceLcov } of parsedSources) {
|
|
42
46
|
if (!lcovContributesToDiff(sourceLcov, diff)) {
|
|
43
|
-
|
|
47
|
+
warnings.push(`coverage-check: warning: coverage from ${name} contributed 0 coverable lines to the patch result. This may indicate a path prefix mismatch.`);
|
|
44
48
|
}
|
|
45
49
|
}
|
|
50
|
+
return warnings;
|
|
46
51
|
}
|
|
47
52
|
export function printDropOutput(drops) {
|
|
48
53
|
if (drops.length === 0)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function checkHelp(): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function checkHelp() {
|
|
2
|
+
return `coverage-check check
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
coverage-check check [options]
|
|
6
|
+
coverage-check [options]
|
|
7
|
+
|
|
8
|
+
Options:
|
|
9
|
+
--rules <path> Path to YAML rules file (default: .coverage-rules.yml)
|
|
10
|
+
--artifacts <dir> Directory to scan for lcov.info files (default: ./coverage-artifacts)
|
|
11
|
+
--base <ref> Base git ref for git diff (default: origin/main)
|
|
12
|
+
--head <ref> Head git ref for git diff (default: HEAD)
|
|
13
|
+
--store-fs <path> Path to a filesystem suite store directory
|
|
14
|
+
--store <path> Alias for --store-fs
|
|
15
|
+
--store-s3 <bucket[/prefix]> S3 suite store
|
|
16
|
+
--branch <name> Branch pointer to follow when reading from the store (default: main)
|
|
17
|
+
--suite <name> Name of the current suite
|
|
18
|
+
--strip-prefix <prefix> Extra path prefix to strip from LCOV SF: lines (repeatable)
|
|
19
|
+
--pr <number> Pull request number for sticky comment
|
|
20
|
+
--repo <owner/repo> Repository for sticky comment (default: $GITHUB_REPOSITORY)
|
|
21
|
+
--json <path|-> Write JSON result to a path, or JSON-only stdout with -
|
|
22
|
+
--annotate-source Print trimmed source text for each uncovered line
|
|
23
|
+
--advisory Exit 0 even on coverage shortfall
|
|
24
|
+
--drop-only-changed-areas Restrict no_coverage_drop to changed rule areas
|
|
25
|
+
--require-artifact <relpath> Fail if this path is absent under --artifacts (repeatable)
|
|
26
|
+
-h, --help Show this help`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CoverageCheckResult } from "../types.mts";
|
|
2
|
+
export type CheckRunResult = {
|
|
3
|
+
result: CoverageCheckResult | null;
|
|
4
|
+
exitCode: 0 | 1 | 2;
|
|
5
|
+
advisory: boolean;
|
|
6
|
+
skipped: boolean;
|
|
7
|
+
error: string | null;
|
|
8
|
+
warnings: string[];
|
|
9
|
+
};
|
|
10
|
+
export type CheckJsonPayload = CoverageCheckResult & {
|
|
11
|
+
exitCode: 0 | 1 | 2;
|
|
12
|
+
advisory: boolean;
|
|
13
|
+
skipped: boolean;
|
|
14
|
+
error?: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function emptyResult(passed: boolean): CoverageCheckResult;
|
|
17
|
+
export declare function toJsonPayload(check: CheckRunResult): CheckJsonPayload;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function emptyResult(passed) {
|
|
2
|
+
return { buckets: [], drops: [], informational: [], passed };
|
|
3
|
+
}
|
|
4
|
+
export function toJsonPayload(check) {
|
|
5
|
+
return {
|
|
6
|
+
...(check.result ?? emptyResult(false)),
|
|
7
|
+
exitCode: check.exitCode,
|
|
8
|
+
advisory: check.advisory,
|
|
9
|
+
skipped: check.skipped,
|
|
10
|
+
...(check.error === null ? {} : { error: check.error }),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
import type { CheckArgs } from "./check-args.mts";
|
|
2
|
+
import type { CheckRunResult } from "./check-result.mts";
|
|
3
|
+
import type { SuiteSource } from "../step-summary.mts";
|
|
4
|
+
import type { DiffLineContent, LcovData } from "../types.mts";
|
|
5
|
+
import type { CoverageCheckResult } from "../types.mts";
|
|
2
6
|
export type { CheckArgs } from "./check-args.mts";
|
|
7
|
+
export type { CheckRunResult } from "./check-result.mts";
|
|
8
|
+
export type EvaluatedCheck = {
|
|
9
|
+
exitCode: number;
|
|
10
|
+
result: CoverageCheckResult | null;
|
|
11
|
+
suiteSources: SuiteSource[];
|
|
12
|
+
runUrl: string;
|
|
13
|
+
branch: string;
|
|
14
|
+
diffContent: DiffLineContent | null;
|
|
15
|
+
skippedReason?: string;
|
|
16
|
+
parsedSources: {
|
|
17
|
+
name: string;
|
|
18
|
+
lcov: LcovData;
|
|
19
|
+
}[];
|
|
20
|
+
warnings: string[];
|
|
21
|
+
};
|
|
3
22
|
export declare function main(argv: string[]): Promise<number>;
|
|
23
|
+
export declare function checkCoverage(args: CheckArgs): Promise<CheckRunResult>;
|
|
24
|
+
export declare function evaluateCheck(args: CheckArgs): Promise<EvaluatedCheck>;
|
|
4
25
|
export declare function runCheck(args: CheckArgs): Promise<number>;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
// oxlint-disable max-lines -- check evaluation and CLI rendering share one pipeline
|
|
1
2
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { parseLcov } from "../lcov-parser.mjs";
|
|
3
4
|
import { mergeLcov } from "../lcov-merge.mjs";
|
|
4
5
|
import { getChangedLines } from "../diff-parser.mjs";
|
|
5
6
|
import { getChangedLineContent } from "../diff-parser-content.mjs";
|
|
6
|
-
import { loadRules, buildChangedRules } from "../rules.mjs";
|
|
7
|
+
import { loadRules, buildChangedRules, withIgnoredPaths } from "../rules.mjs";
|
|
7
8
|
import { computePatchCoverage } from "../patch-coverage.mjs";
|
|
8
9
|
import { computeCoverageDrop } from "../coverage-drop.mjs";
|
|
9
10
|
import { collapseRanges, renderFailureComment } from "../report.mjs";
|
|
@@ -11,10 +12,16 @@ import { upsertComment } from "../github-comment.mjs";
|
|
|
11
12
|
import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
|
|
12
13
|
import { writeSummary } from "../step-summary.mjs";
|
|
13
14
|
import { parseCheckArgs } from "./check-args.mjs";
|
|
14
|
-
import {
|
|
15
|
+
import { checkHelp } from "./check-render.mjs";
|
|
16
|
+
import { emptyResult, toJsonPayload } from "./check-result.mjs";
|
|
17
|
+
import { missingRequiredArtifacts, nonContributingWarnings, printDropOutput, checkRequiredArtifacts, } from "./check-output.mjs";
|
|
15
18
|
const stdout = (msg) => process.stdout.write(`${msg}\n`);
|
|
16
19
|
const stderr = (msg) => process.stderr.write(`${msg}\n`);
|
|
17
20
|
export async function main(argv) {
|
|
21
|
+
if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) {
|
|
22
|
+
stdout(checkHelp());
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
18
25
|
let args;
|
|
19
26
|
try {
|
|
20
27
|
args = parseCheckArgs(argv);
|
|
@@ -25,18 +32,50 @@ export async function main(argv) {
|
|
|
25
32
|
}
|
|
26
33
|
return runCheck(args);
|
|
27
34
|
}
|
|
28
|
-
export async function
|
|
35
|
+
export async function checkCoverage(args) {
|
|
36
|
+
return toCheckRunResult(args, await evaluateCheck(args));
|
|
37
|
+
}
|
|
38
|
+
function toCheckRunResult(args, evaluated) {
|
|
39
|
+
const skippedReason = evaluated.skippedReason ?? null;
|
|
40
|
+
const skipped = evaluated.result === null &&
|
|
41
|
+
skippedReason !== null &&
|
|
42
|
+
skippedReason.startsWith("no coverage data found") &&
|
|
43
|
+
!(args.failOnEmpty ?? false);
|
|
44
|
+
return {
|
|
45
|
+
result: evaluated.result ?? (skipped ? emptyResult(true) : null),
|
|
46
|
+
exitCode: evaluated.exitCode,
|
|
47
|
+
advisory: args.advisory ?? false,
|
|
48
|
+
skipped,
|
|
49
|
+
error: skipped ? null : skippedReason,
|
|
50
|
+
warnings: skipped ? [`coverage-check: ${skippedReason}`] : evaluated.warnings,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export async function evaluateCheck(args) {
|
|
54
|
+
const branch = args.branch ?? "main";
|
|
55
|
+
const runUrl = process.env["GITHUB_SERVER_URL"] && process.env["GITHUB_RUN_ID"]
|
|
56
|
+
? `${process.env["GITHUB_SERVER_URL"]}/${args.repo}/actions/runs/${process.env["GITHUB_RUN_ID"]}`
|
|
57
|
+
: "N/A";
|
|
58
|
+
const emptyEvaluation = (exitCode, skippedReason) => ({
|
|
59
|
+
exitCode,
|
|
60
|
+
result: null,
|
|
61
|
+
suiteSources: [],
|
|
62
|
+
runUrl,
|
|
63
|
+
branch,
|
|
64
|
+
diffContent: null,
|
|
65
|
+
skippedReason,
|
|
66
|
+
parsedSources: [],
|
|
67
|
+
warnings: [],
|
|
68
|
+
});
|
|
29
69
|
let rules;
|
|
30
70
|
try {
|
|
31
|
-
rules = loadRules(args.rules);
|
|
71
|
+
rules = withIgnoredPaths(loadRules(args.rules), args.ignorePaths);
|
|
32
72
|
}
|
|
33
73
|
catch (err) {
|
|
34
|
-
|
|
35
|
-
|
|
74
|
+
return emptyEvaluation(2, `failed to load rules: ${err}`);
|
|
75
|
+
}
|
|
76
|
+
if (missingRequiredArtifacts(args.artifacts, args.requireArtifacts ?? []).length > 0) {
|
|
77
|
+
return emptyEvaluation(2, "missing required coverage artifact");
|
|
36
78
|
}
|
|
37
|
-
if (!checkRequiredArtifacts(args.artifacts, args.requireArtifacts ?? []))
|
|
38
|
-
return 2;
|
|
39
|
-
const branch = args.branch ?? "main";
|
|
40
79
|
const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
|
|
41
80
|
const reports = [];
|
|
42
81
|
const suiteSources = [];
|
|
@@ -59,20 +98,31 @@ export async function runCheck(args) {
|
|
|
59
98
|
const freshLcovs = [];
|
|
60
99
|
for (const f of lcovFiles) {
|
|
61
100
|
const lcov = parseLcov(readFileSync(f, "utf8"), stripPrefixes);
|
|
62
|
-
reports.push(lcov);
|
|
63
101
|
freshLcovs.push(lcov);
|
|
64
|
-
|
|
102
|
+
if (!(args.aggregateArtifacts ?? false)) {
|
|
103
|
+
reports.push(lcov);
|
|
104
|
+
parsedSources.push({ name: `file '${f}'`, lcov });
|
|
105
|
+
}
|
|
65
106
|
}
|
|
66
107
|
if (freshLcovs.length > 0) {
|
|
108
|
+
const freshMerged = mergeLcov(freshLcovs);
|
|
109
|
+
if (args.aggregateArtifacts ?? false) {
|
|
110
|
+
reports.push(freshMerged);
|
|
111
|
+
parsedSources.push({
|
|
112
|
+
name: `aggregated artifacts under '${args.artifacts}'`,
|
|
113
|
+
lcov: freshMerged,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
67
116
|
suiteSources.push({
|
|
68
117
|
suite: args.suite ?? "(current)",
|
|
69
118
|
source: "fresh",
|
|
70
|
-
lcov:
|
|
119
|
+
lcov: freshMerged,
|
|
71
120
|
});
|
|
72
121
|
}
|
|
73
122
|
if (reports.length === 0) {
|
|
74
|
-
|
|
75
|
-
|
|
123
|
+
return emptyEvaluation(args.failOnEmpty ? 1 : 0, args.failOnEmpty
|
|
124
|
+
? `no coverage data found under ${args.artifacts}`
|
|
125
|
+
: "no coverage data found — skipping");
|
|
76
126
|
}
|
|
77
127
|
const lcov = mergeLcov(reports);
|
|
78
128
|
let diff;
|
|
@@ -87,10 +137,19 @@ export async function runCheck(args) {
|
|
|
87
137
|
}
|
|
88
138
|
}
|
|
89
139
|
catch (err) {
|
|
90
|
-
|
|
91
|
-
|
|
140
|
+
return {
|
|
141
|
+
exitCode: 2,
|
|
142
|
+
result: null,
|
|
143
|
+
suiteSources,
|
|
144
|
+
runUrl,
|
|
145
|
+
branch,
|
|
146
|
+
diffContent: null,
|
|
147
|
+
skippedReason: `git diff failed: ${err}`,
|
|
148
|
+
parsedSources,
|
|
149
|
+
warnings: [],
|
|
150
|
+
};
|
|
92
151
|
}
|
|
93
|
-
|
|
152
|
+
const warnings = nonContributingWarnings(parsedSources, diff);
|
|
94
153
|
let baseline = null;
|
|
95
154
|
if (args.store !== null) {
|
|
96
155
|
try {
|
|
@@ -104,7 +163,7 @@ export async function runCheck(args) {
|
|
|
104
163
|
}
|
|
105
164
|
}
|
|
106
165
|
catch (err) {
|
|
107
|
-
|
|
166
|
+
warnings.push(`coverage-check: warning: failed to load baseline from store: ${String(err)}`);
|
|
108
167
|
}
|
|
109
168
|
}
|
|
110
169
|
const { buckets, informational } = computePatchCoverage(diff, lcov, rules);
|
|
@@ -112,15 +171,79 @@ export async function runCheck(args) {
|
|
|
112
171
|
const drops = computeCoverageDrop(lcov, baseline, rules, changedRules);
|
|
113
172
|
const passed = buckets.every((b) => b.passed) && drops.every((d) => d.passed || d.skipped);
|
|
114
173
|
const result = { buckets, drops, informational, passed };
|
|
115
|
-
|
|
116
|
-
|
|
174
|
+
return {
|
|
175
|
+
exitCode: (args.advisory ?? false) || passed ? 0 : 1,
|
|
176
|
+
result,
|
|
177
|
+
suiteSources,
|
|
178
|
+
runUrl,
|
|
179
|
+
branch,
|
|
180
|
+
diffContent,
|
|
181
|
+
parsedSources,
|
|
182
|
+
warnings,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
export async function runCheck(args) {
|
|
186
|
+
const evaluated = await evaluateCheck(args);
|
|
187
|
+
const check = toCheckRunResult(args, evaluated);
|
|
188
|
+
const jsonToStdout = args.json === "-";
|
|
189
|
+
if (evaluated.result === null) {
|
|
190
|
+
if (evaluated.skippedReason === "missing required coverage artifact") {
|
|
191
|
+
if (args.json)
|
|
192
|
+
writeJson(args.json, check);
|
|
193
|
+
checkRequiredArtifacts(args.artifacts, args.requireArtifacts);
|
|
194
|
+
return evaluated.exitCode;
|
|
195
|
+
}
|
|
196
|
+
if (args.json)
|
|
197
|
+
writeJson(args.json, check);
|
|
198
|
+
if (check.error !== null)
|
|
199
|
+
stderr(`coverage-check: ${check.error}`);
|
|
200
|
+
for (const warning of check.warnings)
|
|
201
|
+
stderr(warning);
|
|
202
|
+
return evaluated.exitCode;
|
|
117
203
|
}
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
204
|
+
for (const warning of evaluated.warnings)
|
|
205
|
+
stderr(warning);
|
|
206
|
+
const result = evaluated.result;
|
|
207
|
+
const diffContent = evaluated.diffContent;
|
|
208
|
+
const passed = result.passed;
|
|
209
|
+
if (args.json)
|
|
210
|
+
writeJson(args.json, check);
|
|
211
|
+
if (!jsonToStdout)
|
|
212
|
+
printHumanResult(result, diffContent);
|
|
213
|
+
const summaryFile = args.summaryFile !== undefined
|
|
214
|
+
? args.summaryFile
|
|
215
|
+
: (process.env["GITHUB_STEP_SUMMARY"] ?? null);
|
|
216
|
+
if (summaryFile) {
|
|
217
|
+
try {
|
|
218
|
+
writeSummary(summaryFile, evaluated.suiteSources, result, evaluated.runUrl, evaluated.branch);
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
stderr(`coverage-check: failed to write step summary: ${err}`);
|
|
222
|
+
return 2;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (args.pr !== null && args.repo) {
|
|
226
|
+
const body = passed ? "" : renderFailureComment(result, evaluated.runUrl);
|
|
227
|
+
try {
|
|
228
|
+
await upsertComment(body, args.repo, args.pr, passed, args.gh);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
stderr(`coverage-check: failed to post PR comment: ${err}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return evaluated.exitCode;
|
|
235
|
+
}
|
|
236
|
+
function writeJson(path, check) {
|
|
237
|
+
const json = `${JSON.stringify(toJsonPayload(check), null, 2)}\n`;
|
|
238
|
+
if (path === "-")
|
|
239
|
+
process.stdout.write(json);
|
|
240
|
+
else
|
|
241
|
+
writeFileSync(path, json);
|
|
242
|
+
}
|
|
243
|
+
function printHumanResult(result, diffContent) {
|
|
244
|
+
if (!result.passed) {
|
|
122
245
|
stdout("\ncoverage-check: FAILED\n");
|
|
123
|
-
for (const bucket of buckets.filter((b) => !b.passed)) {
|
|
246
|
+
for (const bucket of result.buckets.filter((b) => !b.passed)) {
|
|
124
247
|
/* c8 ignore next -- bucket.coverable is always > 0 by patch-coverage.mts L36 guard */
|
|
125
248
|
const pct = bucket.coverable > 0 ? `${((bucket.hit / bucket.coverable) * 100).toFixed(1)}%` : "—";
|
|
126
249
|
stdout(` ${bucket.rule}: ${pct} (${bucket.hit}/${bucket.coverable}) — threshold ${bucket.threshold}%`);
|
|
@@ -141,33 +264,11 @@ export async function runCheck(args) {
|
|
|
141
264
|
}
|
|
142
265
|
else {
|
|
143
266
|
stdout("\ncoverage-check: PASSED\n");
|
|
144
|
-
for (const bucket of buckets) {
|
|
267
|
+
for (const bucket of result.buckets) {
|
|
145
268
|
/* c8 ignore next -- bucket.coverable is always > 0 by patch-coverage.mts L36 guard */
|
|
146
269
|
const pct = bucket.coverable > 0 ? `${((bucket.hit / bucket.coverable) * 100).toFixed(1)}%` : "—";
|
|
147
270
|
stdout(` ${bucket.rule}: ${pct} ✓`);
|
|
148
271
|
}
|
|
149
272
|
}
|
|
150
|
-
printDropOutput(drops);
|
|
151
|
-
const summaryFile = args.summaryFile !== undefined
|
|
152
|
-
? args.summaryFile
|
|
153
|
-
: (process.env["GITHUB_STEP_SUMMARY"] ?? null);
|
|
154
|
-
if (summaryFile) {
|
|
155
|
-
try {
|
|
156
|
-
writeSummary(summaryFile, suiteSources, result, runUrl, branch);
|
|
157
|
-
}
|
|
158
|
-
catch (err) {
|
|
159
|
-
stderr(`coverage-check: failed to write step summary: ${err}`);
|
|
160
|
-
return 2;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (args.pr !== null && args.repo) {
|
|
164
|
-
const body = passed ? "" : renderFailureComment(result, runUrl);
|
|
165
|
-
try {
|
|
166
|
-
await upsertComment(body, args.repo, args.pr, passed, args.gh);
|
|
167
|
-
}
|
|
168
|
-
catch (err) {
|
|
169
|
-
stderr(`coverage-check: failed to post PR comment: ${err}`);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return (args.advisory ?? false) || passed ? 0 : 1;
|
|
273
|
+
printDropOutput(result.drops);
|
|
173
274
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type ExpectedSuite = {
|
|
2
|
+
job: string;
|
|
3
|
+
suite: string;
|
|
4
|
+
};
|
|
5
|
+
export type PrepareArtifactsArgs = {
|
|
6
|
+
artifacts: string;
|
|
7
|
+
expectedSuites: ExpectedSuite[];
|
|
8
|
+
};
|
|
9
|
+
export type PrepareArtifactsResult = {
|
|
10
|
+
message: string;
|
|
11
|
+
missing: string[];
|
|
12
|
+
};
|
|
13
|
+
export declare function parsePrepareArtifactsArgs(argv: string[]): PrepareArtifactsArgs;
|
|
14
|
+
export declare function missingCoverageArtifacts(artifactsDir: string, expectedSuites: ExpectedSuite[]): string[];
|
|
15
|
+
export declare function normalizeCoverageArtifacts(artifactsDir: string, expectedSuites: ExpectedSuite[]): string;
|
|
16
|
+
export declare function prepareCoverageArtifacts(args: PrepareArtifactsArgs): PrepareArtifactsResult;
|
|
17
|
+
export declare function runPrepareArtifacts(args: PrepareArtifactsArgs): number;
|
|
18
|
+
export declare function main(argv: string[]): number;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync, unlinkSync } 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
|
+
export function parsePrepareArtifactsArgs(argv) {
|
|
6
|
+
const args = {
|
|
7
|
+
artifacts: "./coverage-artifacts",
|
|
8
|
+
expectedSuites: [],
|
|
9
|
+
};
|
|
10
|
+
for (let i = 0; i < argv.length; i++) {
|
|
11
|
+
const flag = argv[i];
|
|
12
|
+
const next = argv[i + 1];
|
|
13
|
+
const val = () => {
|
|
14
|
+
if (next === undefined || next.startsWith("--")) {
|
|
15
|
+
throw new Error(`${flag} requires a value`);
|
|
16
|
+
}
|
|
17
|
+
i++;
|
|
18
|
+
return next;
|
|
19
|
+
};
|
|
20
|
+
switch (flag) {
|
|
21
|
+
case "--artifacts":
|
|
22
|
+
args.artifacts = val();
|
|
23
|
+
break;
|
|
24
|
+
case "--expect-suite": {
|
|
25
|
+
const raw = val();
|
|
26
|
+
const eq = raw.indexOf("=");
|
|
27
|
+
if (eq <= 0 || eq === raw.length - 1) {
|
|
28
|
+
throw new Error(`--expect-suite must be formatted as <job>=<suite>, got ${JSON.stringify(raw)}`);
|
|
29
|
+
}
|
|
30
|
+
args.expectedSuites.push({ job: raw.slice(0, eq), suite: raw.slice(eq + 1) });
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
default:
|
|
34
|
+
throw new Error(`unknown flag: ${flag}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
function expectedLcovPath(artifactsDir, suite) {
|
|
40
|
+
return join(artifactsDir, `coverage-${suite}`, "lcov.info");
|
|
41
|
+
}
|
|
42
|
+
export function missingCoverageArtifacts(artifactsDir, expectedSuites) {
|
|
43
|
+
const errors = [];
|
|
44
|
+
for (const { job, suite } of expectedSuites) {
|
|
45
|
+
if (!existsSync(expectedLcovPath(artifactsDir, suite))) {
|
|
46
|
+
errors.push(`Missing coverage artifact for ${job}: coverage-${suite}/lcov.info`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return errors;
|
|
50
|
+
}
|
|
51
|
+
export function normalizeCoverageArtifacts(artifactsDir, expectedSuites) {
|
|
52
|
+
const rootLcovPath = join(artifactsDir, "lcov.info");
|
|
53
|
+
if (!existsSync(rootLcovPath))
|
|
54
|
+
return "Coverage artifact layout already uses named directories.";
|
|
55
|
+
if (expectedSuites.length === 0) {
|
|
56
|
+
return "No expected coverage suites configured; leaving root-level lcov.info unchanged.";
|
|
57
|
+
}
|
|
58
|
+
if (expectedSuites.length !== 1) {
|
|
59
|
+
const missingNamedSuites = missingCoverageArtifacts(artifactsDir, expectedSuites);
|
|
60
|
+
if (missingNamedSuites.length === 0) {
|
|
61
|
+
unlinkSync(rootLcovPath);
|
|
62
|
+
return "Removed duplicate root-level lcov.info (all expected named coverage artifacts already exist).";
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Root-level lcov.info is only valid for exactly one expected coverage suite; got ${expectedSuites.length}.`);
|
|
65
|
+
}
|
|
66
|
+
const suite = expectedSuites[0].suite;
|
|
67
|
+
const targetDir = join(artifactsDir, `coverage-${suite}`);
|
|
68
|
+
const targetPath = join(targetDir, "lcov.info");
|
|
69
|
+
if (existsSync(targetPath)) {
|
|
70
|
+
unlinkSync(rootLcovPath);
|
|
71
|
+
return `Removed duplicate root-level lcov.info (coverage-${suite}/lcov.info already present).`;
|
|
72
|
+
}
|
|
73
|
+
mkdirSync(targetDir, { recursive: true });
|
|
74
|
+
renameSync(rootLcovPath, targetPath);
|
|
75
|
+
return `Normalized root-level lcov.info to coverage-${suite}/lcov.info.`;
|
|
76
|
+
}
|
|
77
|
+
export function prepareCoverageArtifacts(args) {
|
|
78
|
+
const message = normalizeCoverageArtifacts(args.artifacts, args.expectedSuites);
|
|
79
|
+
return {
|
|
80
|
+
message,
|
|
81
|
+
missing: missingCoverageArtifacts(args.artifacts, args.expectedSuites),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function runPrepareArtifacts(args) {
|
|
85
|
+
const result = prepareCoverageArtifacts(args);
|
|
86
|
+
stdout(result.message);
|
|
87
|
+
if (result.missing.length === 0) {
|
|
88
|
+
stdout("Coverage artifacts are complete.");
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
for (const error of result.missing)
|
|
92
|
+
stderr(`::error::${error}`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
export function main(argv) {
|
|
96
|
+
try {
|
|
97
|
+
return runPrepareArtifacts(parsePrepareArtifactsArgs(argv));
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
/* c8 ignore next -- defensive fallback; local command errors throw Error instances */
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
stderr(`coverage-check prepare-artifacts: ${message}`);
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
export { runCheck } from "./commands/check.mts";
|
|
1
|
+
export { checkCoverage, evaluateCheck, runCheck } from "./commands/check.mts";
|
|
2
2
|
export { runStorePut } from "./commands/store-put.mts";
|
|
3
3
|
export { runMerge } from "./commands/merge.mts";
|
|
4
|
+
export { missingCoverageArtifacts, normalizeCoverageArtifacts, prepareCoverageArtifacts, runPrepareArtifacts, } from "./commands/prepare-artifacts.mts";
|
|
4
5
|
export { FileSystemSuiteStore } from "./suite-store.mts";
|
|
5
6
|
export { S3SuiteStore } from "./s3-suite-store.mts";
|
|
6
7
|
export { parseDiffWithContent, getChangedLineContent } from "./diff-parser-content.mts";
|
|
7
8
|
export { collapseRanges } from "./report.mts";
|
|
8
9
|
export { parseLcovFull, mergeLcovFull, toLcovFull } from "./lcov-records.mts";
|
|
9
|
-
export
|
|
10
|
+
export { collectLcovFiles } from "./load-artifacts.mts";
|
|
11
|
+
export { loadRules, zeroThresholdGlobs } from "./rules.mts";
|
|
12
|
+
export type { CheckArgs, CheckRunResult, EvaluatedCheck } from "./commands/check.mts";
|
|
10
13
|
export type { StorePutArgs } from "./commands/store-put.mts";
|
|
11
14
|
export type { MergeArgs } from "./commands/merge.mts";
|
|
15
|
+
export type { ExpectedSuite, PrepareArtifactsArgs, PrepareArtifactsResult, } from "./commands/prepare-artifacts.mts";
|
|
12
16
|
export type { SuiteStore, SuiteMeta } from "./suite-store.mts";
|
|
13
17
|
export type { S3SuiteStoreOptions } from "./s3-suite-store.mts";
|
|
14
18
|
export { computeCoverageDrop } from "./coverage-drop.mts";
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
export { runCheck } from "./commands/check.mjs";
|
|
1
|
+
export { checkCoverage, evaluateCheck, runCheck } from "./commands/check.mjs";
|
|
2
2
|
export { runStorePut } from "./commands/store-put.mjs";
|
|
3
3
|
export { runMerge } from "./commands/merge.mjs";
|
|
4
|
+
export { missingCoverageArtifacts, normalizeCoverageArtifacts, prepareCoverageArtifacts, runPrepareArtifacts, } from "./commands/prepare-artifacts.mjs";
|
|
4
5
|
export { FileSystemSuiteStore } from "./suite-store.mjs";
|
|
5
6
|
export { S3SuiteStore } from "./s3-suite-store.mjs";
|
|
6
7
|
export { parseDiffWithContent, getChangedLineContent } from "./diff-parser-content.mjs";
|
|
7
8
|
export { collapseRanges } from "./report.mjs";
|
|
8
9
|
export { parseLcovFull, mergeLcovFull, toLcovFull } from "./lcov-records.mjs";
|
|
10
|
+
export { collectLcovFiles } from "./load-artifacts.mjs";
|
|
11
|
+
export { loadRules, zeroThresholdGlobs } from "./rules.mjs";
|
|
9
12
|
export { computeCoverageDrop } from "./coverage-drop.mjs";
|
|
@@ -14,6 +14,6 @@ export declare function decodeGitCString(s: string): string;
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function parseDiff(text: string): DiffLines;
|
|
16
16
|
/** Runs git merge-base + git diff and returns the raw diff text. Internal shared helper. */
|
|
17
|
-
export declare function runGitDiff(baseRef: string, headRef: string): Promise<string>;
|
|
17
|
+
export declare function runGitDiff(baseRef: string, headRef: string, cwd?: string): Promise<string>;
|
|
18
18
|
/** Runs git diff and returns the parsed result. */
|
|
19
19
|
export declare function getChangedLines(baseRef: string, headRef: string): Promise<DiffLines>;
|
package/dist/src/diff-parser.mjs
CHANGED
|
@@ -113,14 +113,14 @@ export function parseDiff(text) {
|
|
|
113
113
|
return result;
|
|
114
114
|
}
|
|
115
115
|
/** Runs git merge-base + git diff and returns the raw diff text. Internal shared helper. */
|
|
116
|
-
export async function runGitDiff(baseRef, headRef) {
|
|
116
|
+
export async function runGitDiff(baseRef, headRef, cwd) {
|
|
117
117
|
if (baseRef.startsWith("-") || headRef.startsWith("-")) {
|
|
118
118
|
throw new Error("Git reference cannot start with a hyphen (prevents argument injection)");
|
|
119
119
|
}
|
|
120
120
|
const { spawn } = await import("node:child_process");
|
|
121
121
|
const spawnProcess = (cmd, args) => new Promise((resolve, reject) => {
|
|
122
122
|
const chunks = [];
|
|
123
|
-
const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "inherit"] });
|
|
123
|
+
const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "inherit"], cwd });
|
|
124
124
|
proc.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
125
125
|
proc.on("error", reject);
|
|
126
126
|
proc.on("close", (code) => code === 0
|
|
@@ -132,6 +132,8 @@ export async function runGitDiff(baseRef, headRef) {
|
|
|
132
132
|
// --src-prefix/--dst-prefix override diff.noprefix and diff.mnemonicPrefix git config
|
|
133
133
|
return spawnProcess("git", [
|
|
134
134
|
"diff",
|
|
135
|
+
"-M",
|
|
136
|
+
"-l0",
|
|
135
137
|
"--unified=0",
|
|
136
138
|
"--inter-hunk-context=0",
|
|
137
139
|
"--no-color",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { COMMENT_MARKER } from "./report.mjs";
|
|
2
|
+
import { assertValidRepo } from "./suite-store.mjs";
|
|
2
3
|
/* c8 ignore start */
|
|
3
4
|
async function defaultGhRunner(args) {
|
|
4
5
|
const { spawn } = await import("node:child_process");
|
|
@@ -43,17 +44,18 @@ async function findExistingComment(repo, pr, gh) {
|
|
|
43
44
|
* - On pass with no prior comment: stays silent.
|
|
44
45
|
*/
|
|
45
46
|
export async function upsertComment(body, repo, pr, passed, gh = defaultGhRunner) {
|
|
46
|
-
const
|
|
47
|
+
const normalizedRepo = assertValidRepo(repo);
|
|
48
|
+
const existingId = await findExistingComment(normalizedRepo, pr, gh);
|
|
47
49
|
if (passed && existingId === null)
|
|
48
50
|
return;
|
|
49
51
|
if (passed && existingId !== null) {
|
|
50
|
-
await gh(["api", `repos/${
|
|
52
|
+
await gh(["api", `repos/${normalizedRepo}/issues/comments/${existingId}`, "-X", "DELETE"]);
|
|
51
53
|
return;
|
|
52
54
|
}
|
|
53
55
|
if (existingId !== null) {
|
|
54
56
|
await gh([
|
|
55
57
|
"api",
|
|
56
|
-
`repos/${
|
|
58
|
+
`repos/${normalizedRepo}/issues/comments/${existingId}`,
|
|
57
59
|
"-X",
|
|
58
60
|
"PATCH",
|
|
59
61
|
"-f",
|
|
@@ -61,6 +63,6 @@ export async function upsertComment(body, repo, pr, passed, gh = defaultGhRunner
|
|
|
61
63
|
]);
|
|
62
64
|
}
|
|
63
65
|
else {
|
|
64
|
-
await gh(["api", `repos/${
|
|
66
|
+
await gh(["api", `repos/${normalizedRepo}/issues/${pr}/comments`, "-f", `body=${body}`]);
|
|
65
67
|
}
|
|
66
68
|
}
|
|
@@ -39,11 +39,20 @@ function applyRecord(line, cov) {
|
|
|
39
39
|
cov.functionHits.set(name, (cov.functionHits.get(name) ?? 0) + hits);
|
|
40
40
|
}
|
|
41
41
|
else if (line.startsWith("BRDA:")) {
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Optimization: Instead of using `split(",")` which allocates an array, manually find commas
|
|
43
|
+
const c1 = line.indexOf(",", 5);
|
|
44
|
+
if (c1 === -1)
|
|
44
45
|
return;
|
|
45
|
-
const
|
|
46
|
-
|
|
46
|
+
const c2 = line.indexOf(",", c1 + 1);
|
|
47
|
+
if (c2 === -1)
|
|
48
|
+
return;
|
|
49
|
+
const c3 = line.indexOf(",", c2 + 1);
|
|
50
|
+
if (c3 === -1)
|
|
51
|
+
return;
|
|
52
|
+
if (line.indexOf(",", c3 + 1) !== -1)
|
|
53
|
+
return;
|
|
54
|
+
const key = line.slice(5, c3);
|
|
55
|
+
const raw = line.slice(c3 + 1);
|
|
47
56
|
const hits = raw === "-" ? 0 : parseInt(raw, 10);
|
|
48
57
|
if (Number.isFinite(hits))
|
|
49
58
|
cov.branches.set(key, (cov.branches.get(key) ?? 0) + hits);
|
package/dist/src/rules.d.mts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { CoverageRule, DiffLines } from "./types.mts";
|
|
2
2
|
export declare function loadRules(rulesPath: string): CoverageRule[];
|
|
3
|
+
/** Returns path globs whose patch coverage threshold is exactly zero. */
|
|
4
|
+
export declare function zeroThresholdGlobs(rulesPath: string): string[];
|
|
5
|
+
/** Prepends zero-threshold rules for paths that should be ignored by this run. */
|
|
6
|
+
export declare function withIgnoredPaths(rules: CoverageRule[], ignorePaths?: string[]): CoverageRule[];
|
|
3
7
|
/** Returns the first matching rule for a repo-root-relative file path, or null. */
|
|
4
8
|
export declare function matchRule(file: string, rules: CoverageRule[]): CoverageRule | null;
|
|
5
9
|
/**
|
package/dist/src/rules.mjs
CHANGED
|
@@ -36,6 +36,16 @@ export function loadRules(rulesPath) {
|
|
|
36
36
|
}
|
|
37
37
|
return parsed.rules;
|
|
38
38
|
}
|
|
39
|
+
/** Returns path globs whose patch coverage threshold is exactly zero. */
|
|
40
|
+
export function zeroThresholdGlobs(rulesPath) {
|
|
41
|
+
return loadRules(rulesPath).flatMap((rule) => rule.patch_coverage_min === 0 ? [rule.paths] : []);
|
|
42
|
+
}
|
|
43
|
+
/** Prepends zero-threshold rules for paths that should be ignored by this run. */
|
|
44
|
+
export function withIgnoredPaths(rules, ignorePaths = []) {
|
|
45
|
+
if (ignorePaths.length === 0)
|
|
46
|
+
return rules;
|
|
47
|
+
return [...ignorePaths.map((paths) => ({ paths, patch_coverage_min: 0 })), ...rules];
|
|
48
|
+
}
|
|
39
49
|
/** Returns the first matching rule for a repo-root-relative file path, or null. */
|
|
40
50
|
export function matchRule(file, rules) {
|
|
41
51
|
for (const rule of rules) {
|
|
@@ -12,6 +12,6 @@ type ReadContext = {
|
|
|
12
12
|
sendS3(operation: string, key: string, command: object): Promise<unknown>;
|
|
13
13
|
};
|
|
14
14
|
export declare function getLegacy(ctx: ReadContext, suite: string): Promise<Buffer | null>;
|
|
15
|
-
export declare function shouldWritePointer(ctx: ReadContext, suite: string,
|
|
15
|
+
export declare function shouldWritePointer(ctx: ReadContext, suite: string, encodedBranch: string, incomingTimestamp: string): Promise<boolean>;
|
|
16
16
|
export declare function readPointer(ctx: ReadContext, suite: string, branch: string): Promise<StoredPointer>;
|
|
17
17
|
export {};
|
|
@@ -16,8 +16,8 @@ export async function getLegacy(ctx, suite) {
|
|
|
16
16
|
throw err;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
export async function shouldWritePointer(ctx, suite,
|
|
20
|
-
const key = ctx.key(suite, "branch",
|
|
19
|
+
export async function shouldWritePointer(ctx, suite, encodedBranch, incomingTimestamp) {
|
|
20
|
+
const key = ctx.key(suite, "branch", encodedBranch, "latest.json");
|
|
21
21
|
try {
|
|
22
22
|
const resp = (await ctx.sendS3("get branch pointer for compare", key, new GetObjectCommand({
|
|
23
23
|
Bucket: ctx.bucket,
|
|
@@ -63,6 +63,7 @@ export class S3SuiteStore {
|
|
|
63
63
|
}
|
|
64
64
|
const { sha, branch } = meta;
|
|
65
65
|
assertSafePathComponent(sha, "sha");
|
|
66
|
+
const encodedBranch = encodeBranchName(branch);
|
|
66
67
|
const ts = meta.timestamp ?? new Date().toISOString();
|
|
67
68
|
assertValidTimestamp(ts);
|
|
68
69
|
const payload = gzipSync(lcov);
|
|
@@ -74,7 +75,7 @@ export class S3SuiteStore {
|
|
|
74
75
|
ContentEncoding: "gzip",
|
|
75
76
|
ContentType: "text/plain",
|
|
76
77
|
}), { rawBytes: lcov.byteLength, storedBytes: payload.byteLength });
|
|
77
|
-
if (!(await shouldWritePointer(this, suite,
|
|
78
|
+
if (!(await shouldWritePointer(this, suite, encodedBranch, ts)))
|
|
78
79
|
return;
|
|
79
80
|
await this.putPointer(suite, branch, sha, ts, payloadKey, lcov.byteLength, payload.byteLength);
|
|
80
81
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SuiteMeta } from "./types.mts";
|
|
2
2
|
export declare function assertSafePathComponent(value: string, label: string): void;
|
|
3
|
+
export declare function assertValidRepo(repo: string): string;
|
|
3
4
|
export type { SuiteMeta };
|
|
4
5
|
export type SuitePutMeta = {
|
|
5
6
|
sha: string;
|
package/dist/src/suite-store.mjs
CHANGED
|
@@ -10,8 +10,27 @@ export function assertSafePathComponent(value, label) {
|
|
|
10
10
|
throw new Error(`invalid ${label}: ${JSON.stringify(value)}`);
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
export function assertValidRepo(repo) {
|
|
14
|
+
const trimmed = typeof repo === "string" ? repo.trim() : "";
|
|
15
|
+
if (trimmed.length === 0) {
|
|
16
|
+
throw new Error(`Invalid repository format: ${repo}. Expected owner/repo.`);
|
|
17
|
+
}
|
|
18
|
+
const parts = trimmed.split("/");
|
|
19
|
+
if (parts.length !== 2 ||
|
|
20
|
+
!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(parts[0]) ||
|
|
21
|
+
!/^[A-Za-z0-9_.-]+$/.test(parts[1]) ||
|
|
22
|
+
parts[1] === "." ||
|
|
23
|
+
parts[1] === "..") {
|
|
24
|
+
throw new Error(`Invalid repository format: ${repo}. Expected owner/repo.`);
|
|
25
|
+
}
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
13
28
|
export function encodeBranchName(branch) {
|
|
14
|
-
if (typeof branch !== "string" ||
|
|
29
|
+
if (typeof branch !== "string" ||
|
|
30
|
+
branch.length === 0 ||
|
|
31
|
+
branch === "." ||
|
|
32
|
+
branch.includes("..") ||
|
|
33
|
+
branch.includes("\\")) {
|
|
15
34
|
throw new Error(`invalid branch: ${JSON.stringify(branch)}`);
|
|
16
35
|
}
|
|
17
36
|
return Buffer.from(branch, "utf8").toString("base64url");
|
|
@@ -91,10 +110,11 @@ export class FileSystemSuiteStore {
|
|
|
91
110
|
}
|
|
92
111
|
const { sha, branch } = meta;
|
|
93
112
|
assertSafePathComponent(sha, "sha");
|
|
113
|
+
const encodedBranch = encodeBranchName(branch);
|
|
94
114
|
const shaDir = join(this.root, suite, "sha", sha);
|
|
95
115
|
mkdirSync(shaDir, { recursive: true });
|
|
96
116
|
writeFileSync(join(shaDir, "lcov.info"), lcov);
|
|
97
|
-
const branchDir = join(this.root, suite, "branch",
|
|
117
|
+
const branchDir = join(this.root, suite, "branch", encodedBranch);
|
|
98
118
|
mkdirSync(branchDir, { recursive: true });
|
|
99
119
|
const pointerPath = join(branchDir, "latest.json");
|
|
100
120
|
const timestamp = meta.timestamp ?? new Date().toISOString();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coverage-check",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Patch-coverage gate: checks that newly added lines meet per-path coverage thresholds. Supports per-suite LCOV accumulation for conditional CI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
@@ -31,17 +31,17 @@
|
|
|
31
31
|
"node": ">=22.0.0"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@aws-sdk/client-s3": "
|
|
34
|
+
"@aws-sdk/client-s3": "3.1075.0",
|
|
35
35
|
"@smithy/node-http-handler": "^4.7.7",
|
|
36
36
|
"js-yaml": "^4.1.1",
|
|
37
37
|
"monocart-coverage-reports": "^2.12.11"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/js-yaml": "^4.0.9",
|
|
41
|
-
"@types/node": "^
|
|
41
|
+
"@types/node": "^26.0.1",
|
|
42
42
|
"@vitest/coverage-v8": "^4.1.6",
|
|
43
43
|
"husky": "^9.1.7",
|
|
44
|
-
"oxfmt": "^0.
|
|
44
|
+
"oxfmt": "^0.56.0",
|
|
45
45
|
"oxlint": "^1.65.0",
|
|
46
46
|
"typescript": "^6.0.3",
|
|
47
47
|
"vitest": "^4.1.6"
|