coverage-check 0.2.3 → 0.5.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 +40 -15
- package/dist/src/cli.mjs +6 -0
- package/dist/src/commands/check-args.d.mts +2 -0
- package/dist/src/commands/check-args.mjs +4 -0
- package/dist/src/commands/check.mjs +20 -2
- package/dist/src/commands/html.d.mts +14 -0
- package/dist/src/commands/html.mjs +164 -0
- package/dist/src/commands/store-put.d.mts +1 -0
- package/dist/src/commands/store-put.mjs +41 -7
- package/dist/src/commands/summary/args.d.mts +2 -0
- package/dist/src/commands/summary/args.mjs +60 -0
- package/dist/src/commands/summary/groups.d.mts +2 -0
- package/dist/src/commands/summary/groups.mjs +84 -0
- package/dist/src/commands/summary/index.d.mts +8 -0
- package/dist/src/commands/summary/index.mjs +118 -0
- package/dist/src/commands/summary/markdown.d.mts +5 -0
- package/dist/src/commands/summary/markdown.mjs +71 -0
- package/dist/src/commands/summary/types.d.mts +36 -0
- package/dist/src/commands/summary/types.mjs +1 -0
- package/dist/src/commands/summary.d.mts +2 -0
- package/dist/src/commands/summary.mjs +1 -0
- package/dist/src/coverage-check.d.mts +2 -1
- package/dist/src/coverage-check.mjs +1 -0
- package/dist/src/diff-parser-content.d.mts +11 -0
- package/dist/src/diff-parser-content.mjs +80 -0
- package/dist/src/diff-parser.d.mts +2 -0
- package/dist/src/diff-parser.mjs +23 -6
- package/dist/src/for-each-line.d.mts +7 -0
- package/dist/src/for-each-line.mjs +28 -0
- package/dist/src/lcov-merge.mjs +7 -3
- package/dist/src/lcov-parser.mjs +41 -18
- package/dist/src/lcov-to-istanbul.d.mts +30 -0
- package/dist/src/lcov-to-istanbul.mjs +155 -0
- package/dist/src/types.d.mts +2 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -96,6 +96,30 @@ coverage-check check \
|
|
|
96
96
|
|
|
97
97
|
When `$GITHUB_STEP_SUMMARY` is set, a per-suite totals and per-rule patch-coverage table is appended to the job summary automatically.
|
|
98
98
|
|
|
99
|
+
### Diagnosing uncovered lines
|
|
100
|
+
|
|
101
|
+
Pass `--annotate-source` to print the trimmed source text of each uncovered line alongside its line number:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
coverage-check: FAILED
|
|
105
|
+
|
|
106
|
+
backend/**: 80.0% (4/5) — threshold 90%
|
|
107
|
+
backend/foo.mts:
|
|
108
|
+
L42 function f(a = 1) {
|
|
109
|
+
L55 const { x } = opts
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This makes it immediately clear which construct needs execution to satisfy V8/Istanbul line coverage.
|
|
113
|
+
Two common sources of confusion:
|
|
114
|
+
|
|
115
|
+
- **Default parameters** — `function f(a = 1)` is only fully covered when the function is called
|
|
116
|
+
_without_ that argument so the default expression executes.
|
|
117
|
+
- **Shorthand object properties** — `const { x } = opts` is covered when `opts.x` is actually
|
|
118
|
+
accessed during the test.
|
|
119
|
+
|
|
120
|
+
The annotation affects only the stdout failure output. The GitHub PR sticky comment and Actions step
|
|
121
|
+
summary are unchanged.
|
|
122
|
+
|
|
99
123
|
## Rules file
|
|
100
124
|
|
|
101
125
|
```yaml
|
|
@@ -115,21 +139,22 @@ Rules are matched in order; the first match wins. Files in the diff not matched
|
|
|
115
139
|
|
|
116
140
|
### `coverage-check check`
|
|
117
141
|
|
|
118
|
-
| Flag
|
|
119
|
-
|
|
|
120
|
-
| `--rules`
|
|
121
|
-
| `--artifacts`
|
|
122
|
-
| `--base`
|
|
123
|
-
| `--head`
|
|
124
|
-
| `--store-fs`
|
|
125
|
-
| `--store`
|
|
126
|
-
| `--store-s3`
|
|
127
|
-
| `--branch`
|
|
128
|
-
| `--suite`
|
|
129
|
-
| `--strip-prefix`
|
|
130
|
-
| `--pr`
|
|
131
|
-
| `--repo`
|
|
132
|
-
| `--json`
|
|
142
|
+
| Flag | Default | Description |
|
|
143
|
+
| ------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
144
|
+
| `--rules` | `.coverage-rules.yml` | Path to YAML rules file |
|
|
145
|
+
| `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
|
|
146
|
+
| `--base` | `origin/main` | Base git ref for `git diff` |
|
|
147
|
+
| `--head` | `HEAD` | Head git ref for `git diff` |
|
|
148
|
+
| `--store-fs` | — | Path to a filesystem suite store directory |
|
|
149
|
+
| `--store` | — | Alias for `--store-fs` |
|
|
150
|
+
| `--store-s3` | — | S3 suite store spec: `<bucket>[/<prefix>]` |
|
|
151
|
+
| `--branch` | `"main"` | Branch pointer to follow when reading from the store |
|
|
152
|
+
| `--suite` | — | Name of the current suite (no `/` or `\\`); fresh artifacts override this suite in the store |
|
|
153
|
+
| `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
|
|
154
|
+
| `--pr` | — | Pull request number for sticky comment |
|
|
155
|
+
| `--repo` | `$GITHUB_REPOSITORY` | `owner/repo` for sticky comment |
|
|
156
|
+
| `--json` | — | Write JSON result to this path |
|
|
157
|
+
| `--annotate-source` | — | Print the trimmed source text of each uncovered line in stdout (does not alter PR comment or step summary) |
|
|
133
158
|
|
|
134
159
|
### `coverage-check store-put`
|
|
135
160
|
|
package/dist/src/cli.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { main as checkMain } from "./commands/check.mjs";
|
|
2
2
|
import { main as storePutMain } from "./commands/store-put.mjs";
|
|
3
|
+
import { main as htmlMain } from "./commands/html.mjs";
|
|
4
|
+
import { main as summaryMain } from "./commands/summary.mjs";
|
|
3
5
|
const stderr = (msg) => process.stderr.write(`${msg}\n`);
|
|
4
6
|
export async function main(argv) {
|
|
5
7
|
const sub = argv[0];
|
|
@@ -9,6 +11,10 @@ export async function main(argv) {
|
|
|
9
11
|
return checkMain(argv.slice(1));
|
|
10
12
|
if (sub === "store-put")
|
|
11
13
|
return storePutMain(argv.slice(1));
|
|
14
|
+
if (sub === "html")
|
|
15
|
+
return htmlMain(argv.slice(1));
|
|
16
|
+
if (sub === "summary")
|
|
17
|
+
return summaryMain(argv.slice(1));
|
|
12
18
|
stderr(`coverage-check: unknown subcommand: ${JSON.stringify(sub)}`);
|
|
13
19
|
return 2;
|
|
14
20
|
}
|
|
@@ -16,5 +16,7 @@ export type CheckArgs = {
|
|
|
16
16
|
gh?: GhRunner;
|
|
17
17
|
/** Path to append the GitHub step summary. Default: $GITHUB_STEP_SUMMARY. */
|
|
18
18
|
summaryFile?: string | null;
|
|
19
|
+
/** Annotate each uncovered line with its trimmed source text in stdout. Default: false. */
|
|
20
|
+
annotateSource?: boolean;
|
|
19
21
|
};
|
|
20
22
|
export declare function parseCheckArgs(argv: string[]): CheckArgs;
|
|
@@ -16,6 +16,7 @@ export function parseCheckArgs(argv) {
|
|
|
16
16
|
suite: null,
|
|
17
17
|
branch: "main",
|
|
18
18
|
summaryFile: process.env["GITHUB_STEP_SUMMARY"] ?? null,
|
|
19
|
+
annotateSource: false,
|
|
19
20
|
};
|
|
20
21
|
for (let i = 0; i < argv.length; i++) {
|
|
21
22
|
const flag = argv[i];
|
|
@@ -76,6 +77,9 @@ export function parseCheckArgs(argv) {
|
|
|
76
77
|
args.pr = parseInt(raw, 10);
|
|
77
78
|
break;
|
|
78
79
|
}
|
|
80
|
+
case "--annotate-source":
|
|
81
|
+
args.annotateSource = true;
|
|
82
|
+
break;
|
|
79
83
|
default:
|
|
80
84
|
throw new Error(`unknown flag: ${flag}`);
|
|
81
85
|
}
|
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { parseLcov } from "../lcov-parser.mjs";
|
|
3
3
|
import { mergeLcov } from "../lcov-merge.mjs";
|
|
4
4
|
import { getChangedLines } from "../diff-parser.mjs";
|
|
5
|
+
import { getChangedLineContent } from "../diff-parser-content.mjs";
|
|
5
6
|
import { loadRules } from "../rules.mjs";
|
|
6
7
|
import { computePatchCoverage } from "../patch-coverage.mjs";
|
|
7
8
|
import { collapseRanges, renderFailureComment } from "../report.mjs";
|
|
@@ -71,8 +72,15 @@ export async function runCheck(args) {
|
|
|
71
72
|
}
|
|
72
73
|
const lcov = mergeLcov(reports);
|
|
73
74
|
let diff;
|
|
75
|
+
let diffContent = null;
|
|
74
76
|
try {
|
|
75
|
-
|
|
77
|
+
if (args.annotateSource) {
|
|
78
|
+
diffContent = await getChangedLineContent(args.base, args.head);
|
|
79
|
+
diff = new Map([...diffContent].map(([f, m]) => [f, new Set(m.keys())]));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
diff = await getChangedLines(args.base, args.head);
|
|
83
|
+
}
|
|
76
84
|
}
|
|
77
85
|
catch (err) {
|
|
78
86
|
stderr(`coverage-check: git diff failed: ${err}`);
|
|
@@ -115,7 +123,17 @@ export async function runCheck(args) {
|
|
|
115
123
|
const pct = bucket.coverable > 0 ? `${((bucket.hit / bucket.coverable) * 100).toFixed(1)}%` : "—";
|
|
116
124
|
stdout(` ${bucket.rule}: ${pct} (${bucket.hit}/${bucket.coverable}) — threshold ${bucket.threshold}%`);
|
|
117
125
|
for (const file of bucket.files.filter((f) => f.uncoveredLines.length > 0)) {
|
|
118
|
-
|
|
126
|
+
if (diffContent !== null) {
|
|
127
|
+
stdout(` ${file.file}:`);
|
|
128
|
+
for (const lineNo of file.uncoveredLines) {
|
|
129
|
+
/* c8 ignore next -- diffContent is single-sourced from the same diff, lineNo is always present */
|
|
130
|
+
const text = diffContent.get(file.file)?.get(lineNo) ?? "";
|
|
131
|
+
stdout(` L${lineNo} ${text}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
stdout(` ${file.file}: ${collapseRanges(file.uncoveredLines)}`);
|
|
136
|
+
}
|
|
119
137
|
}
|
|
120
138
|
}
|
|
121
139
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type CoverageHtmlArgs = {
|
|
2
|
+
activeSuites: string[];
|
|
3
|
+
artifacts: string;
|
|
4
|
+
branch: string;
|
|
5
|
+
output: string;
|
|
6
|
+
storeFs: string | null;
|
|
7
|
+
storeS3: string | null;
|
|
8
|
+
stripPrefixes: string[];
|
|
9
|
+
};
|
|
10
|
+
export declare function parseCoverageHtmlArgs(argv: string[]): CoverageHtmlArgs;
|
|
11
|
+
export declare function buildCoverageHtml(args: CoverageHtmlArgs): Promise<{
|
|
12
|
+
warnings: string[];
|
|
13
|
+
}>;
|
|
14
|
+
export declare function main(argv: string[]): Promise<number>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
|
|
4
|
+
import { lcovBufferToIstanbul } from "../lcov-to-istanbul.mjs";
|
|
5
|
+
import { makeStore } from "../store-factory.mjs";
|
|
6
|
+
function requireValue(flag, value) {
|
|
7
|
+
if (value === undefined || value.startsWith("--"))
|
|
8
|
+
throw new Error(`${flag} requires a value`);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
export function parseCoverageHtmlArgs(argv) {
|
|
12
|
+
const args = {
|
|
13
|
+
activeSuites: [],
|
|
14
|
+
artifacts: "./coverage-artifacts",
|
|
15
|
+
branch: "main",
|
|
16
|
+
output: "./coverage-html",
|
|
17
|
+
storeFs: null,
|
|
18
|
+
storeS3: null,
|
|
19
|
+
stripPrefixes: [],
|
|
20
|
+
};
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const flag = argv[i];
|
|
23
|
+
const value = () => {
|
|
24
|
+
const parsed = requireValue(flag, argv[i + 1]);
|
|
25
|
+
i++;
|
|
26
|
+
return parsed;
|
|
27
|
+
};
|
|
28
|
+
switch (flag) {
|
|
29
|
+
case "--active-suite":
|
|
30
|
+
args.activeSuites.push(value());
|
|
31
|
+
break;
|
|
32
|
+
case "--artifacts":
|
|
33
|
+
args.artifacts = value();
|
|
34
|
+
break;
|
|
35
|
+
case "--branch":
|
|
36
|
+
args.branch = value();
|
|
37
|
+
break;
|
|
38
|
+
case "--output":
|
|
39
|
+
args.output = value();
|
|
40
|
+
break;
|
|
41
|
+
case "--store-fs":
|
|
42
|
+
args.storeFs = value();
|
|
43
|
+
break;
|
|
44
|
+
case "--store-s3":
|
|
45
|
+
args.storeS3 = value();
|
|
46
|
+
break;
|
|
47
|
+
case "--strip-prefix":
|
|
48
|
+
args.stripPrefixes.push(value());
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
throw new Error(`unknown flag: ${flag}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (args.storeFs && args.storeS3)
|
|
55
|
+
throw new Error("--store-fs and --store-s3 are mutually exclusive");
|
|
56
|
+
if (args.branch.length === 0)
|
|
57
|
+
throw new Error("--branch must not be empty");
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
function suiteNameFromArtifactDir(name) {
|
|
61
|
+
if (!name.startsWith("coverage-"))
|
|
62
|
+
return null;
|
|
63
|
+
const suite = name.slice("coverage-".length);
|
|
64
|
+
return suite.length > 0 ? suite : null;
|
|
65
|
+
}
|
|
66
|
+
function loadCurrentSuiteBuffers(artifacts) {
|
|
67
|
+
if (!existsSync(artifacts))
|
|
68
|
+
return [];
|
|
69
|
+
return readdirSync(artifacts, { withFileTypes: true })
|
|
70
|
+
.filter((entry) => entry.isDirectory())
|
|
71
|
+
.flatMap((entry) => {
|
|
72
|
+
const suite = suiteNameFromArtifactDir(entry.name);
|
|
73
|
+
if (!suite)
|
|
74
|
+
return [];
|
|
75
|
+
const files = collectLcovFiles(path.join(artifacts, entry.name));
|
|
76
|
+
if (files.length === 0)
|
|
77
|
+
return [];
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
suite,
|
|
81
|
+
source: "current",
|
|
82
|
+
lcov: Buffer.concat(files.flatMap((f) => [readFileSync(f), Buffer.from("\n")])),
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
})
|
|
86
|
+
.sort((a, b) => a.suite.localeCompare(b.suite));
|
|
87
|
+
}
|
|
88
|
+
async function loadHistoricalSuiteBuffers(store, branch, activeSuiteNames, currentSuiteNames) {
|
|
89
|
+
if (store === null) {
|
|
90
|
+
return {
|
|
91
|
+
suites: [],
|
|
92
|
+
warnings: [
|
|
93
|
+
"Historical main coverage store was not configured; showing current-run coverage only.",
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (activeSuiteNames.size === 0) {
|
|
98
|
+
return {
|
|
99
|
+
suites: [],
|
|
100
|
+
warnings: [
|
|
101
|
+
"Historical main coverage store was configured without an active suite manifest; showing current-run coverage only.",
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const historicalNames = [...activeSuiteNames].filter((s) => !currentSuiteNames.has(s));
|
|
107
|
+
const results = await Promise.all(historicalNames.map(async (suite) => {
|
|
108
|
+
const lcov = await store.get(suite, { branch });
|
|
109
|
+
return lcov === null ? { missingSuite: suite } : { suite, lcov };
|
|
110
|
+
}));
|
|
111
|
+
const suites = results
|
|
112
|
+
.filter((r) => "lcov" in r)
|
|
113
|
+
.map((r) => ({ suite: r.suite, source: "history", lcov: r.lcov }));
|
|
114
|
+
const missing = results
|
|
115
|
+
.filter((r) => "missingSuite" in r)
|
|
116
|
+
.map((r) => r.missingSuite);
|
|
117
|
+
return {
|
|
118
|
+
suites,
|
|
119
|
+
warnings: missing.length === 0
|
|
120
|
+
? []
|
|
121
|
+
: [
|
|
122
|
+
`Historical main coverage missing for active suites: ${missing.sort((a, b) => a.localeCompare(b)).join(", ")}.`,
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
return { suites: [], warnings: [`Historical main coverage could not be read: ${message}`] };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export async function buildCoverageHtml(args) {
|
|
132
|
+
const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
|
|
133
|
+
const currentSuites = loadCurrentSuiteBuffers(args.artifacts);
|
|
134
|
+
const historical = await loadHistoricalSuiteBuffers(makeStore({ fs: args.storeFs, s3: args.storeS3 }), args.branch, new Set(args.activeSuites), new Set(currentSuites.map((s) => s.suite)));
|
|
135
|
+
const allSuites = [...currentSuites, ...historical.suites];
|
|
136
|
+
const { CoverageReport } = await import("monocart-coverage-reports");
|
|
137
|
+
const report = new CoverageReport({
|
|
138
|
+
name: "merged coverage",
|
|
139
|
+
outputDir: args.output,
|
|
140
|
+
reports: [["html"]],
|
|
141
|
+
lcov: true,
|
|
142
|
+
});
|
|
143
|
+
for (const suite of allSuites) {
|
|
144
|
+
await report.add(lcovBufferToIstanbul(suite.lcov, stripPrefixes));
|
|
145
|
+
}
|
|
146
|
+
if (allSuites.length > 0) {
|
|
147
|
+
await report.generate();
|
|
148
|
+
}
|
|
149
|
+
return { warnings: historical.warnings };
|
|
150
|
+
}
|
|
151
|
+
export async function main(argv) {
|
|
152
|
+
let args;
|
|
153
|
+
try {
|
|
154
|
+
args = parseCoverageHtmlArgs(argv);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
process.stderr.write(`coverage-check html: ${String(err)}\n`);
|
|
158
|
+
return 2;
|
|
159
|
+
}
|
|
160
|
+
const { warnings } = await buildCoverageHtml(args);
|
|
161
|
+
for (const w of warnings)
|
|
162
|
+
process.stderr.write(`${w}\n`);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import { parseLcov } from "../lcov-parser.mjs";
|
|
3
4
|
import { mergeLcov, toLcov } from "../lcov-merge.mjs";
|
|
4
5
|
import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
|
|
@@ -11,6 +12,7 @@ function parseArgs(argv) {
|
|
|
11
12
|
let storeS3 = null;
|
|
12
13
|
const args = {
|
|
13
14
|
suite: "",
|
|
15
|
+
suitePrefix: "coverage-",
|
|
14
16
|
artifacts: "./coverage-artifacts",
|
|
15
17
|
stripPrefixes: [],
|
|
16
18
|
sha: undefined,
|
|
@@ -30,6 +32,9 @@ function parseArgs(argv) {
|
|
|
30
32
|
case "--suite":
|
|
31
33
|
args.suite = val();
|
|
32
34
|
break;
|
|
35
|
+
case "--suite-prefix":
|
|
36
|
+
args.suitePrefix = val();
|
|
37
|
+
break;
|
|
33
38
|
case "--store":
|
|
34
39
|
case "--store-fs":
|
|
35
40
|
storeFs = val();
|
|
@@ -53,8 +58,6 @@ function parseArgs(argv) {
|
|
|
53
58
|
throw new Error(`unknown flag: ${flag}`);
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
|
-
if (!args.suite)
|
|
57
|
-
throw new Error("--suite is required");
|
|
58
61
|
if (storeFs && storeS3)
|
|
59
62
|
throw new Error("--store-fs and --store-s3 are mutually exclusive");
|
|
60
63
|
if (!storeFs && !storeS3)
|
|
@@ -64,7 +67,9 @@ function parseArgs(argv) {
|
|
|
64
67
|
if (hasSha !== hasBranch) {
|
|
65
68
|
throw new Error("--sha and --branch must be provided together");
|
|
66
69
|
}
|
|
67
|
-
|
|
70
|
+
if (args.suite) {
|
|
71
|
+
assertSafePathComponent(args.suite, "suite");
|
|
72
|
+
}
|
|
68
73
|
if (args.sha !== undefined)
|
|
69
74
|
assertSafePathComponent(args.sha, "sha");
|
|
70
75
|
if (args.branch !== undefined && args.branch.length === 0) {
|
|
@@ -82,13 +87,42 @@ export async function main(argv) {
|
|
|
82
87
|
stderr(`coverage-check store-put: ${String(err)}`);
|
|
83
88
|
return 2;
|
|
84
89
|
}
|
|
85
|
-
|
|
90
|
+
if (args.suite) {
|
|
91
|
+
return runStorePut(args);
|
|
92
|
+
}
|
|
93
|
+
return runStorePutMultiSuite(args);
|
|
94
|
+
}
|
|
95
|
+
async function runStorePutMultiSuite(args) {
|
|
96
|
+
let subdirs;
|
|
97
|
+
try {
|
|
98
|
+
subdirs = readdirSync(args.artifacts, { withFileTypes: true })
|
|
99
|
+
.filter((e) => e.isDirectory() && e.name.startsWith(args.suitePrefix))
|
|
100
|
+
.map((e) => e.name);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err.code === "ENOENT") {
|
|
104
|
+
stdout(`coverage-check store-put: artifacts directory not found at ${args.artifacts}; nothing to store`);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
if (subdirs.length === 0) {
|
|
110
|
+
stdout(`coverage-check store-put: no subdirectories matching prefix "${args.suitePrefix}" under ${args.artifacts}; nothing to store`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
for (const subdirName of subdirs) {
|
|
114
|
+
const suite = subdirName.slice(args.suitePrefix.length);
|
|
115
|
+
const suiteDir = join(args.artifacts, subdirName);
|
|
116
|
+
const suiteArgs = { ...args, suite, artifacts: suiteDir };
|
|
117
|
+
await runStorePut(suiteArgs);
|
|
118
|
+
}
|
|
119
|
+
return 0;
|
|
86
120
|
}
|
|
87
121
|
export async function runStorePut(args) {
|
|
88
122
|
const lcovFiles = collectLcovFiles(args.artifacts);
|
|
89
123
|
if (lcovFiles.length === 0) {
|
|
90
|
-
|
|
91
|
-
return
|
|
124
|
+
stdout(`coverage-check store-put: no lcov.info files under ${args.artifacts}; skipping suite "${args.suite}"`);
|
|
125
|
+
return 0;
|
|
92
126
|
}
|
|
93
127
|
const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
|
|
94
128
|
const reports = lcovFiles.map((f) => parseLcov(readFileSync(f, "utf8"), stripPrefixes));
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
function requireValue(flag, value) {
|
|
2
|
+
if (value === undefined || value.startsWith("--"))
|
|
3
|
+
throw new Error(`${flag} requires a value`);
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
6
|
+
export function parseCoverageSummaryArgs(argv) {
|
|
7
|
+
const args = {
|
|
8
|
+
activeSuites: [],
|
|
9
|
+
artifacts: "./coverage-artifacts",
|
|
10
|
+
branch: "main",
|
|
11
|
+
storeFs: null,
|
|
12
|
+
storeS3: null,
|
|
13
|
+
summaryFile: process.env["GITHUB_STEP_SUMMARY"] ?? null,
|
|
14
|
+
stripPrefixes: [],
|
|
15
|
+
};
|
|
16
|
+
for (let i = 0; i < argv.length; i++) {
|
|
17
|
+
const flag = argv[i];
|
|
18
|
+
const value = () => {
|
|
19
|
+
const parsed = requireValue(flag, argv[i + 1]);
|
|
20
|
+
i++;
|
|
21
|
+
return parsed;
|
|
22
|
+
};
|
|
23
|
+
switch (flag) {
|
|
24
|
+
case "--active-suite":
|
|
25
|
+
args.activeSuites.push(value());
|
|
26
|
+
break;
|
|
27
|
+
case "--artifacts":
|
|
28
|
+
args.artifacts = value();
|
|
29
|
+
break;
|
|
30
|
+
case "--branch":
|
|
31
|
+
args.branch = value();
|
|
32
|
+
break;
|
|
33
|
+
case "--rules":
|
|
34
|
+
args.rulesFile = value();
|
|
35
|
+
break;
|
|
36
|
+
case "--store-fs":
|
|
37
|
+
args.storeFs = value();
|
|
38
|
+
break;
|
|
39
|
+
case "--store-s3":
|
|
40
|
+
args.storeS3 = value();
|
|
41
|
+
break;
|
|
42
|
+
case "--strip-prefix":
|
|
43
|
+
args.stripPrefixes.push(value());
|
|
44
|
+
break;
|
|
45
|
+
case "--summary-file":
|
|
46
|
+
args.summaryFile = value();
|
|
47
|
+
break;
|
|
48
|
+
case "--no-summary-file":
|
|
49
|
+
args.summaryFile = null;
|
|
50
|
+
break;
|
|
51
|
+
default:
|
|
52
|
+
throw new Error(`unknown flag: ${flag}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (args.storeFs && args.storeS3)
|
|
56
|
+
throw new Error("--store-fs and --store-s3 are mutually exclusive");
|
|
57
|
+
if (args.branch.length === 0)
|
|
58
|
+
throw new Error("--branch must not be empty");
|
|
59
|
+
return args;
|
|
60
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { load as loadYaml } from "js-yaml";
|
|
4
|
+
import { mergeLcov } from "../../lcov-merge.mjs";
|
|
5
|
+
const otherGroup = { folder: "other", prefix: "" };
|
|
6
|
+
function isCoverageRulesFile(value) {
|
|
7
|
+
return typeof value === "object" && value !== null;
|
|
8
|
+
}
|
|
9
|
+
function staticFolderFromGlob(pattern) {
|
|
10
|
+
const normalized = pattern.replace(/\\/g, "/");
|
|
11
|
+
const firstGlobIndex = normalized.search(/[*?[\]{}]/);
|
|
12
|
+
const staticPrefix = firstGlobIndex === -1 ? normalized : normalized.slice(0, firstGlobIndex);
|
|
13
|
+
const folder = staticPrefix.replace(/\/+$/, "");
|
|
14
|
+
return folder === "" ? null : folder;
|
|
15
|
+
}
|
|
16
|
+
function loadCoverageGroupDefinitions(rulesFile) {
|
|
17
|
+
const resolvedFile = rulesFile ?? path.join(process.cwd(), ".coverage-rules.yml");
|
|
18
|
+
if (!existsSync(resolvedFile))
|
|
19
|
+
return [];
|
|
20
|
+
const parsed = loadYaml(readFileSync(resolvedFile, "utf8"));
|
|
21
|
+
const rules = isCoverageRulesFile(parsed) && Array.isArray(parsed.rules) ? parsed.rules : [];
|
|
22
|
+
return rules.flatMap((rule) => {
|
|
23
|
+
const paths = Array.isArray(rule.paths)
|
|
24
|
+
? rule.paths
|
|
25
|
+
: rule.paths === undefined
|
|
26
|
+
? []
|
|
27
|
+
: [rule.paths];
|
|
28
|
+
return paths.flatMap((pattern) => {
|
|
29
|
+
const folder = staticFolderFromGlob(pattern);
|
|
30
|
+
return folder === null ? [] : [{ folder, prefix: `${folder}/` }];
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function groupForFile(filePath, definitions) {
|
|
35
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
36
|
+
return (definitions.find((definition) => normalizedPath === definition.folder || normalizedPath.startsWith(definition.prefix)) ?? otherGroup);
|
|
37
|
+
}
|
|
38
|
+
export function groupSuitesBySourceFolder(suites, branch, rulesFile) {
|
|
39
|
+
const definitions = loadCoverageGroupDefinitions(rulesFile);
|
|
40
|
+
const groups = new Map();
|
|
41
|
+
for (const suite of suites) {
|
|
42
|
+
const suiteReportsByGroup = new Map();
|
|
43
|
+
for (const [filePath, lines] of suite.lcov.entries()) {
|
|
44
|
+
const definition = groupForFile(filePath, definitions);
|
|
45
|
+
const group = groups.get(definition.folder) ?? {
|
|
46
|
+
branches: new Set(),
|
|
47
|
+
current: false,
|
|
48
|
+
history: false,
|
|
49
|
+
reports: [],
|
|
50
|
+
};
|
|
51
|
+
if (suite.source === "current")
|
|
52
|
+
group.current = true;
|
|
53
|
+
else {
|
|
54
|
+
group.history = true;
|
|
55
|
+
group.branches.add(suite.branch ?? branch);
|
|
56
|
+
}
|
|
57
|
+
const suiteReport = suiteReportsByGroup.get(definition.folder) ?? new Map();
|
|
58
|
+
suiteReport.set(filePath, new Map(lines));
|
|
59
|
+
suiteReportsByGroup.set(definition.folder, suiteReport);
|
|
60
|
+
groups.set(definition.folder, group);
|
|
61
|
+
}
|
|
62
|
+
for (const [folder, report] of suiteReportsByGroup.entries()) {
|
|
63
|
+
groups.get(folder)?.reports.push(report);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const coverageGroups = [];
|
|
67
|
+
for (const definition of [...definitions, otherGroup]) {
|
|
68
|
+
const group = groups.get(definition.folder);
|
|
69
|
+
if (!group)
|
|
70
|
+
continue;
|
|
71
|
+
const coverageGroup = {
|
|
72
|
+
folder: definition.folder,
|
|
73
|
+
source: group.current && group.history ? "mixed" : group.current ? "current" : "history",
|
|
74
|
+
lcov: mergeLcov(group.reports),
|
|
75
|
+
};
|
|
76
|
+
if (group.branches.size > 0) {
|
|
77
|
+
coverageGroup.branchesLabel = [...group.branches]
|
|
78
|
+
.sort((a, b) => a.localeCompare(b))
|
|
79
|
+
.join(", ");
|
|
80
|
+
}
|
|
81
|
+
coverageGroups.push(coverageGroup);
|
|
82
|
+
}
|
|
83
|
+
return coverageGroups;
|
|
84
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { parseCoverageSummaryArgs } from "./args.mts";
|
|
2
|
+
import { groupSuitesBySourceFolder } from "./groups.mts";
|
|
3
|
+
import { renderCoverageSummaryMarkdown, suiteTotals } from "./markdown.mts";
|
|
4
|
+
import type { CoverageSummary, CoverageSummaryArgs, SuiteCoverage } from "./types.mts";
|
|
5
|
+
export type { CoverageSummary, CoverageSummaryArgs, SuiteCoverage };
|
|
6
|
+
export { groupSuitesBySourceFolder, parseCoverageSummaryArgs, renderCoverageSummaryMarkdown, suiteTotals, };
|
|
7
|
+
export declare function buildCoverageSummary(args: CoverageSummaryArgs): Promise<CoverageSummary>;
|
|
8
|
+
export declare function main(argv: string[]): Promise<number>;
|