coverage-check 0.7.0 → 0.8.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 +119 -3
- 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/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-diagnostics.d.mts +9 -0
- package/dist/src/s3-diagnostics.mjs +60 -0
- package/dist/src/s3-suite-store-reads.d.mts +17 -0
- package/dist/src/s3-suite-store-reads.mjs +57 -0
- package/dist/src/s3-suite-store.d.mts +7 -10
- package/dist/src/s3-suite-store.mjs +68 -75
- package/dist/src/suite-store.d.mts +1 -0
- package/dist/src/suite-store.mjs +22 -2
- package/package.json +4 -3
|
@@ -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";
|
|
@@ -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) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ClientLike = {
|
|
2
|
+
send(cmd: object): Promise<unknown>;
|
|
3
|
+
};
|
|
4
|
+
export type S3OperationDetails = {
|
|
5
|
+
rawBytes?: number;
|
|
6
|
+
storedBytes?: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function createS3Client(region?: string): ClientLike;
|
|
9
|
+
export declare function sendS3(client: ClientLike, bucket: string, operation: string, key: string, command: object, details?: S3OperationDetails): Promise<unknown>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
import { NodeHttpHandler } from "@smithy/node-http-handler";
|
|
3
|
+
export function createS3Client(region) {
|
|
4
|
+
return new S3Client({
|
|
5
|
+
maxAttempts: readPositiveIntEnv("COVERAGE_CHECK_S3_MAX_ATTEMPTS", 2),
|
|
6
|
+
region,
|
|
7
|
+
requestHandler: new NodeHttpHandler({
|
|
8
|
+
connectionTimeout: readPositiveIntEnv("COVERAGE_CHECK_S3_CONNECTION_TIMEOUT_MS", 5000),
|
|
9
|
+
requestTimeout: readPositiveIntEnv("COVERAGE_CHECK_S3_REQUEST_TIMEOUT_MS", 30000),
|
|
10
|
+
}),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export async function sendS3(client, bucket, operation, key, command, details = {}) {
|
|
14
|
+
const start = performance.now();
|
|
15
|
+
try {
|
|
16
|
+
const result = await client.send(command);
|
|
17
|
+
logS3(bucket, operation, key, "ok", performance.now() - start, details);
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
logS3(bucket, operation, key, "failed", performance.now() - start, {
|
|
22
|
+
...details,
|
|
23
|
+
error: formatError(err),
|
|
24
|
+
});
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function logS3(bucket, operation, key, status, elapsedMs, details) {
|
|
29
|
+
const parts = [
|
|
30
|
+
"coverage-check s3",
|
|
31
|
+
`operation=${JSON.stringify(operation)}`,
|
|
32
|
+
`status=${status}`,
|
|
33
|
+
`bucket=${JSON.stringify(bucket)}`,
|
|
34
|
+
`key=${JSON.stringify(key)}`,
|
|
35
|
+
`elapsed_ms=${Math.round(elapsedMs)}`,
|
|
36
|
+
];
|
|
37
|
+
if (details.rawBytes !== undefined)
|
|
38
|
+
parts.push(`raw_bytes=${details.rawBytes}`);
|
|
39
|
+
if (details.storedBytes !== undefined)
|
|
40
|
+
parts.push(`stored_bytes=${details.storedBytes}`);
|
|
41
|
+
if (details.error !== undefined)
|
|
42
|
+
parts.push(`error=${JSON.stringify(details.error)}`);
|
|
43
|
+
process.stderr.write(`${parts.join(" ")}\n`);
|
|
44
|
+
}
|
|
45
|
+
function readPositiveIntEnv(name, fallback) {
|
|
46
|
+
const raw = process.env[name];
|
|
47
|
+
if (raw === undefined || raw === "")
|
|
48
|
+
return fallback;
|
|
49
|
+
const value = Number(raw);
|
|
50
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
51
|
+
process.stderr.write(`coverage-check s3 invalid ${name}=${JSON.stringify(raw)}; using ${fallback}\n`);
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
function formatError(err) {
|
|
57
|
+
if (err instanceof Error)
|
|
58
|
+
return `${err.name}: ${err.message}`;
|
|
59
|
+
return String(err);
|
|
60
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type StoredPointer = {
|
|
2
|
+
sha: string;
|
|
3
|
+
timestamp?: string;
|
|
4
|
+
payloadKey?: string;
|
|
5
|
+
encoding?: "gzip";
|
|
6
|
+
rawBytes?: number;
|
|
7
|
+
storedBytes?: number;
|
|
8
|
+
};
|
|
9
|
+
type ReadContext = {
|
|
10
|
+
bucket: string;
|
|
11
|
+
key(...parts: string[]): string;
|
|
12
|
+
sendS3(operation: string, key: string, command: object): Promise<unknown>;
|
|
13
|
+
};
|
|
14
|
+
export declare function getLegacy(ctx: ReadContext, suite: string): Promise<Buffer | null>;
|
|
15
|
+
export declare function shouldWritePointer(ctx: ReadContext, suite: string, encodedBranch: string, incomingTimestamp: string): Promise<boolean>;
|
|
16
|
+
export declare function readPointer(ctx: ReadContext, suite: string, branch: string): Promise<StoredPointer>;
|
|
17
|
+
export {};
|