coverage-check 0.5.0 → 0.6.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 +27 -0
- package/dist/src/commands/check-output.d.mts +6 -0
- package/dist/src/commands/check-output.mjs +54 -0
- package/dist/src/commands/check.mjs +20 -20
- package/dist/src/coverage-check.d.mts +2 -1
- package/dist/src/coverage-check.mjs +1 -0
- package/dist/src/coverage-drop.d.mts +2 -0
- package/dist/src/coverage-drop.mjs +39 -0
- package/dist/src/report.mjs +25 -1
- package/dist/src/rules.mjs +16 -0
- package/dist/src/step-summary.mjs +25 -10
- package/dist/src/types.d.mts +12 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,6 +135,33 @@ rules:
|
|
|
135
135
|
|
|
136
136
|
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
137
|
|
|
138
|
+
### `no_coverage_drop`
|
|
139
|
+
|
|
140
|
+
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.
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
rules:
|
|
144
|
+
- paths: backend/scripts/**
|
|
145
|
+
patch_coverage_min: 0 # exempt from patch gate
|
|
146
|
+
- paths: backend/**
|
|
147
|
+
patch_coverage_min: 95
|
|
148
|
+
no_coverage_drop: true # also gate overall regression
|
|
149
|
+
- paths: web/**
|
|
150
|
+
patch_coverage_min: 80
|
|
151
|
+
no_coverage_drop: true
|
|
152
|
+
max_coverage_drop: 0.5 # allow up to 0.5 percentage-point drop
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`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.
|
|
156
|
+
|
|
157
|
+
**Requirements:**
|
|
158
|
+
|
|
159
|
+
- A suite store (`--store-s3` or `--store-fs`) must be configured on the `check` command.
|
|
160
|
+
- A baseline must exist in the store (written by `store-put --sha ... --branch main` on main pushes).
|
|
161
|
+
- 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.
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
138
165
|
## CLI reference
|
|
139
166
|
|
|
140
167
|
### `coverage-check check`
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const stdout = (msg) => process.stdout.write(`${msg}\n`);
|
|
2
|
+
const stderr = (msg) => process.stderr.write(`${msg}\n`);
|
|
3
|
+
function fmtPct(n) {
|
|
4
|
+
return n === null ? "—" : `${n.toFixed(2)}%`;
|
|
5
|
+
}
|
|
6
|
+
function fmtDrop(n) {
|
|
7
|
+
/* c8 ignore next -- failing drops always have non-null drop (drop===null implies passed===true) */
|
|
8
|
+
return n === null ? "—" : `${n.toFixed(2)}pp`;
|
|
9
|
+
}
|
|
10
|
+
function lcovContributesToDiff(sourceLcov, diff) {
|
|
11
|
+
for (const [file, changedLines] of diff) {
|
|
12
|
+
const fileLines = sourceLcov.get(file);
|
|
13
|
+
if (fileLines) {
|
|
14
|
+
for (const lineNo of changedLines) {
|
|
15
|
+
if (fileLines.has(lineNo))
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
export function warnNonContributing(parsedSources, diff) {
|
|
23
|
+
if (diff.size === 0)
|
|
24
|
+
return;
|
|
25
|
+
for (const { name, lcov: sourceLcov } of parsedSources) {
|
|
26
|
+
if (!lcovContributesToDiff(sourceLcov, diff)) {
|
|
27
|
+
stderr(`coverage-check: warning: coverage from ${name} contributed 0 coverable lines to the patch result. This may indicate a path prefix mismatch.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function printDropOutput(drops) {
|
|
32
|
+
if (drops.length === 0)
|
|
33
|
+
return;
|
|
34
|
+
const skippedDrops = drops.filter((d) => d.skipped);
|
|
35
|
+
const failingDrops = drops.filter((d) => !d.passed && !d.skipped);
|
|
36
|
+
const passingDrops = drops.filter((d) => d.passed && !d.skipped);
|
|
37
|
+
if (skippedDrops.length > 0) {
|
|
38
|
+
stdout("\ncoverage-check: coverage drop check skipped (no baseline)\n");
|
|
39
|
+
for (const d of skippedDrops) {
|
|
40
|
+
stdout(` ${d.rule}: no baseline available`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (failingDrops.length > 0) {
|
|
44
|
+
stdout("\ncoverage-check: COVERAGE REGRESSION\n");
|
|
45
|
+
for (const d of failingDrops) {
|
|
46
|
+
stdout(` ${d.rule}: ${fmtPct(d.currentPct)} (was ${fmtPct(d.baselinePct)}, dropped ${fmtDrop(d.drop)}, max allowed ${d.maxDrop}pp)`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (passingDrops.length > 0) {
|
|
50
|
+
for (const d of passingDrops) {
|
|
51
|
+
stdout(` ${d.rule}: ${fmtPct(d.currentPct)} (baseline ${fmtPct(d.baselinePct)}) ✓`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -5,11 +5,13 @@ import { getChangedLines } from "../diff-parser.mjs";
|
|
|
5
5
|
import { getChangedLineContent } from "../diff-parser-content.mjs";
|
|
6
6
|
import { loadRules } 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 } 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) {
|
|
@@ -86,30 +88,27 @@ export async function runCheck(args) {
|
|
|
86
88
|
stderr(`coverage-check: git diff failed: ${err}`);
|
|
87
89
|
return 2;
|
|
88
90
|
}
|
|
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.`);
|
|
91
|
+
warnNonContributing(parsedSources, diff);
|
|
92
|
+
let baseline = null;
|
|
93
|
+
if (args.store !== null) {
|
|
94
|
+
try {
|
|
95
|
+
const suites = await args.store.list();
|
|
96
|
+
const baselineReports = (await Promise.all(suites.map(async (suite) => {
|
|
97
|
+
const buf = await args.store.get(suite, { branch });
|
|
98
|
+
return buf === null ? null : parseLcov(buf.toString("utf8"), stripPrefixes);
|
|
99
|
+
}))).filter((report) => report !== null);
|
|
100
|
+
if (baselineReports.length > 0) {
|
|
101
|
+
baseline = mergeLcov(baselineReports);
|
|
107
102
|
}
|
|
108
103
|
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
stderr(`coverage-check: warning: failed to load baseline from store: ${String(err)}`);
|
|
106
|
+
}
|
|
109
107
|
}
|
|
110
108
|
const { buckets, informational } = computePatchCoverage(diff, lcov, rules);
|
|
111
|
-
const
|
|
112
|
-
const
|
|
109
|
+
const drops = computeCoverageDrop(lcov, baseline, rules);
|
|
110
|
+
const passed = buckets.every((b) => b.passed) && drops.every((d) => d.passed || d.skipped);
|
|
111
|
+
const result = { buckets, drops, informational, passed };
|
|
113
112
|
if (args.json) {
|
|
114
113
|
writeFileSync(args.json, JSON.stringify(result, null, 2));
|
|
115
114
|
}
|
|
@@ -145,6 +144,7 @@ export async function runCheck(args) {
|
|
|
145
144
|
stdout(` ${bucket.rule}: ${pct} ✓`);
|
|
146
145
|
}
|
|
147
146
|
}
|
|
147
|
+
printDropOutput(drops);
|
|
148
148
|
const summaryFile = args.summaryFile !== undefined
|
|
149
149
|
? args.summaryFile
|
|
150
150
|
: (process.env["GITHUB_STEP_SUMMARY"] ?? null);
|
|
@@ -7,4 +7,5 @@ export type { CheckArgs } from "./commands/check.mts";
|
|
|
7
7
|
export type { StorePutArgs } from "./commands/store-put.mts";
|
|
8
8
|
export type { SuiteStore, SuiteMeta } from "./suite-store.mts";
|
|
9
9
|
export type { S3SuiteStoreOptions } from "./s3-suite-store.mts";
|
|
10
|
-
export
|
|
10
|
+
export { computeCoverageDrop } from "./coverage-drop.mts";
|
|
11
|
+
export type { CoverageCheckResult, BucketResult, DropResult, FileCoverageResult, LcovData, DiffLines, DiffLineContent, CoverageRule, } from "./types.mts";
|
|
@@ -3,3 +3,4 @@ export { runStorePut } from "./commands/store-put.mjs";
|
|
|
3
3
|
export { FileSystemSuiteStore } from "./suite-store.mjs";
|
|
4
4
|
export { S3SuiteStore } from "./s3-suite-store.mjs";
|
|
5
5
|
export { parseDiffWithContent, getChangedLineContent } from "./diff-parser-content.mjs";
|
|
6
|
+
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) {
|
|
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) {
|
|
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/report.mjs
CHANGED
|
@@ -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.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
|
}
|
|
@@ -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.6.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",
|