coverage-check 0.6.0 → 0.7.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 +133 -19
- package/dist/src/cli.mjs +3 -0
- package/dist/src/commands/check-args.d.mts +6 -0
- package/dist/src/commands/check-args.mjs +12 -0
- package/dist/src/commands/check-output.d.mts +5 -0
- package/dist/src/commands/check-output.mjs +16 -0
- package/dist/src/commands/check.mjs +7 -4
- package/dist/src/commands/merge-args.d.mts +7 -0
- package/dist/src/commands/merge-args.mjs +38 -0
- package/dist/src/commands/merge.d.mts +9 -0
- package/dist/src/commands/merge.mjs +41 -0
- package/dist/src/coverage-check.d.mts +5 -0
- package/dist/src/coverage-check.mjs +3 -0
- package/dist/src/coverage-drop.d.mts +1 -1
- package/dist/src/coverage-drop.mjs +2 -2
- package/dist/src/diff-parser.mjs +3 -0
- package/dist/src/lcov-records.d.mts +29 -0
- package/dist/src/lcov-records.mjs +155 -0
- package/dist/src/lcov-to-istanbul.mjs +33 -9
- package/dist/src/report.d.mts +1 -1
- package/dist/src/report.mjs +3 -3
- package/dist/src/rules.d.mts +6 -1
- package/dist/src/rules.mjs +7 -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 -9
- package/dist/src/s3-suite-store.mjs +67 -75
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -51,12 +51,29 @@ The `--suite` flag on `check` tells the tool to use fresh `--artifacts` for the
|
|
|
51
51
|
**S3 key layout:**
|
|
52
52
|
|
|
53
53
|
```text
|
|
54
|
-
<prefix>/<suite>/sha/<sha>/lcov.info
|
|
55
|
-
<prefix>/<suite>/branch/<encoded-branch>/latest.json # pointer
|
|
54
|
+
<prefix>/<suite>/sha/<sha>/lcov.info.gz # gzip payload for new versioned writes
|
|
55
|
+
<prefix>/<suite>/branch/<encoded-branch>/latest.json # pointer with sha, payloadKey, encoding, byte counts, timestamp
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
S3-backed stores need `s3:PutObject` for writes and `s3:GetObject` for reading branch pointers and baselines. The pointer reader also checks the previous unencoded pointer key (for example `branch/main/latest.json`) so stores written before branch-name encoding remain readable.
|
|
59
59
|
|
|
60
|
+
Versioned S3 writes (`store-put --sha ... --branch ...`) gzip the LCOV payload and write pointer
|
|
61
|
+
metadata with `payloadKey`, `encoding`, `rawBytes`, and `storedBytes`. Existing raw
|
|
62
|
+
`sha/<sha>/lcov.info` payloads and legacy `<suite>/lcov.info` payloads remain readable. Legacy
|
|
63
|
+
writes without `--sha`/`--branch` keep the old raw `<suite>/lcov.info` layout.
|
|
64
|
+
|
|
65
|
+
Every S3 operation logs a concise diagnostic line to stderr with the operation name, bucket, key,
|
|
66
|
+
elapsed time, and byte counts where applicable. Use these lines to distinguish payload writes,
|
|
67
|
+
branch-pointer reads, and branch-pointer writes when CI storage stalls.
|
|
68
|
+
|
|
69
|
+
S3 request bounds are configurable with environment variables:
|
|
70
|
+
|
|
71
|
+
| Variable | Default | Purpose |
|
|
72
|
+
| ----------------------------------------- | ------- | ---------------------------------------- |
|
|
73
|
+
| `COVERAGE_CHECK_S3_CONNECTION_TIMEOUT_MS` | `5000` | Socket connection timeout for S3 calls |
|
|
74
|
+
| `COVERAGE_CHECK_S3_REQUEST_TIMEOUT_MS` | `30000` | Whole-request timeout for S3 calls |
|
|
75
|
+
| `COVERAGE_CHECK_S3_MAX_ATTEMPTS` | `2` | AWS SDK attempt count, including retries |
|
|
76
|
+
|
|
60
77
|
### Suite store with filesystem
|
|
61
78
|
|
|
62
79
|
For local development or simpler deployments:
|
|
@@ -120,6 +137,60 @@ Two common sources of confusion:
|
|
|
120
137
|
The annotation affects only the stdout failure output. The GitHub PR sticky comment and Actions step
|
|
121
138
|
summary are unchanged.
|
|
122
139
|
|
|
140
|
+
### Merging LCOV files
|
|
141
|
+
|
|
142
|
+
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`):
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
coverage-check merge \
|
|
146
|
+
--artifacts ./coverage-artifacts \
|
|
147
|
+
--output ./coverage-merged/lcov.info
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Hit counts are **summed** across inputs (consistent with the package's internal multi-suite merge). Parent directories of `--output` are created automatically.
|
|
151
|
+
|
|
152
|
+
### Advisory (non-blocking) mode
|
|
153
|
+
|
|
154
|
+
Pass `--advisory` to exit `0` even when coverage falls short. The check still runs in full — JSON is written, the PR comment is posted, and uncovered lines are printed — but the process never exits `1`. Useful for pre-push hooks where you want feedback without blocking the push:
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
coverage-check check \
|
|
158
|
+
--rules .coverage-rules.yml \
|
|
159
|
+
--artifacts ./coverage-artifacts \
|
|
160
|
+
--base origin/main \
|
|
161
|
+
--head HEAD \
|
|
162
|
+
--advisory
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### PR-scoped regression gate
|
|
166
|
+
|
|
167
|
+
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:
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
coverage-check check \
|
|
171
|
+
--rules .coverage-rules.yml \
|
|
172
|
+
--artifacts ./coverage-artifacts \
|
|
173
|
+
--base origin/main \
|
|
174
|
+
--head HEAD \
|
|
175
|
+
--drop-only-changed-areas
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
This avoids false positives when a CI run only exercises a subset of areas.
|
|
179
|
+
|
|
180
|
+
### Required artifacts
|
|
181
|
+
|
|
182
|
+
Pass `--require-artifact <relpath>` (repeatable) to fail early — exit `2` with a `::error::` annotation — if an expected `lcov.info` is absent from `--artifacts`. This distinguishes a missing coverage upload (CI job didn't run) from genuine uncovered lines:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
coverage-check check \
|
|
186
|
+
--rules .coverage-rules.yml \
|
|
187
|
+
--artifacts ./coverage-artifacts \
|
|
188
|
+
--require-artifact coverage-backend/lcov.info \
|
|
189
|
+
--require-artifact coverage-frontend/lcov.info
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`--require-artifact` is also accepted by `coverage-check merge`.
|
|
193
|
+
|
|
123
194
|
## Rules file
|
|
124
195
|
|
|
125
196
|
```yaml
|
|
@@ -166,22 +237,36 @@ First-match-wins means that if you have a more specific rule before a broader on
|
|
|
166
237
|
|
|
167
238
|
### `coverage-check check`
|
|
168
239
|
|
|
169
|
-
| Flag
|
|
170
|
-
|
|
|
171
|
-
| `--rules`
|
|
172
|
-
| `--artifacts`
|
|
173
|
-
| `--base`
|
|
174
|
-
| `--head`
|
|
175
|
-
| `--store-fs`
|
|
176
|
-
| `--store`
|
|
177
|
-
| `--store-s3`
|
|
178
|
-
| `--branch`
|
|
179
|
-
| `--suite`
|
|
180
|
-
| `--strip-prefix`
|
|
181
|
-
| `--pr`
|
|
182
|
-
| `--repo`
|
|
183
|
-
| `--json`
|
|
184
|
-
| `--annotate-source`
|
|
240
|
+
| Flag | Default | Description |
|
|
241
|
+
| --------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
242
|
+
| `--rules` | `.coverage-rules.yml` | Path to YAML rules file |
|
|
243
|
+
| `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
|
|
244
|
+
| `--base` | `origin/main` | Base git ref for `git diff` |
|
|
245
|
+
| `--head` | `HEAD` | Head git ref for `git diff` |
|
|
246
|
+
| `--store-fs` | — | Path to a filesystem suite store directory |
|
|
247
|
+
| `--store` | — | Alias for `--store-fs` |
|
|
248
|
+
| `--store-s3` | — | S3 suite store spec: `<bucket>[/<prefix>]` |
|
|
249
|
+
| `--branch` | `"main"` | Branch pointer to follow when reading from the store |
|
|
250
|
+
| `--suite` | — | Name of the current suite (no `/` or `\\`); fresh artifacts override this suite in the store |
|
|
251
|
+
| `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
|
|
252
|
+
| `--pr` | — | Pull request number for sticky comment |
|
|
253
|
+
| `--repo` | `$GITHUB_REPOSITORY` | `owner/repo` for sticky comment |
|
|
254
|
+
| `--json` | — | Write JSON result to this path |
|
|
255
|
+
| `--annotate-source` | — | Print the trimmed source text of each uncovered line in stdout (does not alter PR comment or step summary) |
|
|
256
|
+
| `--advisory` | — | Exit `0` even on shortfall; still prints, writes JSON, and posts PR comment |
|
|
257
|
+
| `--drop-only-changed-areas` | — | Restrict `no_coverage_drop` to rule areas that have ≥1 changed file; others are reported as skipped |
|
|
258
|
+
| `--require-artifact` | — | Fail (exit `2`) if this relative path is absent under `--artifacts` (repeatable) |
|
|
259
|
+
|
|
260
|
+
### `coverage-check merge`
|
|
261
|
+
|
|
262
|
+
| Flag | Default | Description |
|
|
263
|
+
| -------------------- | ---------------------- | ------------------------------------------------------------- |
|
|
264
|
+
| `--artifacts` | `./coverage-artifacts` | Directory to scan for `lcov.info` files |
|
|
265
|
+
| `--output` | required | Path to write the merged `lcov.info` |
|
|
266
|
+
| `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
|
|
267
|
+
| `--require-artifact` | — | Fail (exit `2`) if this relative path is absent (repeatable) |
|
|
268
|
+
|
|
269
|
+
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.
|
|
185
270
|
|
|
186
271
|
### `coverage-check store-put`
|
|
187
272
|
|
|
@@ -203,7 +288,17 @@ When `--sha` and `--branch` are both provided, `store-put` writes a SHA-addresse
|
|
|
203
288
|
## Programmatic API
|
|
204
289
|
|
|
205
290
|
```ts
|
|
206
|
-
import {
|
|
291
|
+
import {
|
|
292
|
+
runCheck,
|
|
293
|
+
runMerge,
|
|
294
|
+
runStorePut,
|
|
295
|
+
collapseRanges,
|
|
296
|
+
parseLcovFull,
|
|
297
|
+
mergeLcovFull,
|
|
298
|
+
toLcovFull,
|
|
299
|
+
FileSystemSuiteStore,
|
|
300
|
+
S3SuiteStore,
|
|
301
|
+
} from "coverage-check";
|
|
207
302
|
|
|
208
303
|
// FileSystem store
|
|
209
304
|
const fsStore = new FileSystemSuiteStore("/path/to/store");
|
|
@@ -223,6 +318,16 @@ await runCheck({
|
|
|
223
318
|
store: s3Store,
|
|
224
319
|
suite: "backend",
|
|
225
320
|
branch: "main",
|
|
321
|
+
advisory: false,
|
|
322
|
+
dropOnlyChangedAreas: false,
|
|
323
|
+
requireArtifacts: [],
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await runMerge({
|
|
327
|
+
artifacts: "./coverage-artifacts",
|
|
328
|
+
output: "./coverage-merged/lcov.info",
|
|
329
|
+
stripPrefixes: [],
|
|
330
|
+
requireArtifacts: [],
|
|
226
331
|
});
|
|
227
332
|
|
|
228
333
|
await runStorePut({
|
|
@@ -233,6 +338,15 @@ await runStorePut({
|
|
|
233
338
|
sha: "abc123",
|
|
234
339
|
branch: "main",
|
|
235
340
|
});
|
|
341
|
+
|
|
342
|
+
// Collapse a sorted line list into a compact range string
|
|
343
|
+
collapseRanges([1, 2, 3, 7, 8]); // → "L1-3, L7-8"
|
|
344
|
+
collapseRanges([1, 2, 3, 7, 8], ""); // → "1-3, 7-8"
|
|
345
|
+
|
|
346
|
+
// Full-fidelity LCOV (functions + branches + lines)
|
|
347
|
+
const full = parseLcovFull(lcovText, ["/home/runner/work/repo/repo/"]);
|
|
348
|
+
const merged = mergeLcovFull([full1, full2]); // hits are summed
|
|
349
|
+
const output = toLcovFull(merged); // includes FN/FNDA/BRDA and LF/LH/FNF/FNH/BRF/BRH
|
|
236
350
|
```
|
|
237
351
|
|
|
238
352
|
You can also implement your own `SuiteStore`:
|
package/dist/src/cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { main as checkMain } from "./commands/check.mjs";
|
|
2
2
|
import { main as storePutMain } from "./commands/store-put.mjs";
|
|
3
3
|
import { main as htmlMain } from "./commands/html.mjs";
|
|
4
|
+
import { main as mergeMain } from "./commands/merge.mjs";
|
|
4
5
|
import { main as summaryMain } from "./commands/summary.mjs";
|
|
5
6
|
const stderr = (msg) => process.stderr.write(`${msg}\n`);
|
|
6
7
|
export async function main(argv) {
|
|
@@ -11,6 +12,8 @@ export async function main(argv) {
|
|
|
11
12
|
return checkMain(argv.slice(1));
|
|
12
13
|
if (sub === "store-put")
|
|
13
14
|
return storePutMain(argv.slice(1));
|
|
15
|
+
if (sub === "merge")
|
|
16
|
+
return mergeMain(argv.slice(1));
|
|
14
17
|
if (sub === "html")
|
|
15
18
|
return htmlMain(argv.slice(1));
|
|
16
19
|
if (sub === "summary")
|
|
@@ -18,5 +18,11 @@ export type CheckArgs = {
|
|
|
18
18
|
summaryFile?: string | null;
|
|
19
19
|
/** Annotate each uncovered line with its trimmed source text in stdout. Default: false. */
|
|
20
20
|
annotateSource?: boolean;
|
|
21
|
+
/** Exit 0 even on coverage shortfall — compute and print, but never block. Default: false. */
|
|
22
|
+
advisory?: boolean;
|
|
23
|
+
/** Apply no_coverage_drop only to rules whose area has changed files in the diff. Default: false. */
|
|
24
|
+
dropOnlyChangedAreas?: boolean;
|
|
25
|
+
/** Relative paths under --artifacts that must exist before the check runs (repeatable). */
|
|
26
|
+
requireArtifacts?: string[];
|
|
21
27
|
};
|
|
22
28
|
export declare function parseCheckArgs(argv: string[]): CheckArgs;
|
|
@@ -17,6 +17,9 @@ export function parseCheckArgs(argv) {
|
|
|
17
17
|
branch: "main",
|
|
18
18
|
summaryFile: process.env["GITHUB_STEP_SUMMARY"] ?? null,
|
|
19
19
|
annotateSource: false,
|
|
20
|
+
advisory: false,
|
|
21
|
+
dropOnlyChangedAreas: false,
|
|
22
|
+
requireArtifacts: [],
|
|
20
23
|
};
|
|
21
24
|
for (let i = 0; i < argv.length; i++) {
|
|
22
25
|
const flag = argv[i];
|
|
@@ -80,6 +83,15 @@ export function parseCheckArgs(argv) {
|
|
|
80
83
|
case "--annotate-source":
|
|
81
84
|
args.annotateSource = true;
|
|
82
85
|
break;
|
|
86
|
+
case "--advisory":
|
|
87
|
+
args.advisory = true;
|
|
88
|
+
break;
|
|
89
|
+
case "--drop-only-changed-areas":
|
|
90
|
+
args.dropOnlyChangedAreas = true;
|
|
91
|
+
break;
|
|
92
|
+
case "--require-artifact":
|
|
93
|
+
args.requireArtifacts.push(val());
|
|
94
|
+
break;
|
|
83
95
|
default:
|
|
84
96
|
throw new Error(`unknown flag: ${flag}`);
|
|
85
97
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { DropResult, DiffLines, LcovData } from "../types.mts";
|
|
2
|
+
/**
|
|
3
|
+
* Verifies that each required artifact path exists under artifactsDir.
|
|
4
|
+
* Prints `::error::` to stderr for each missing file and returns false if any are absent.
|
|
5
|
+
*/
|
|
6
|
+
export declare function checkRequiredArtifacts(artifactsDir: string, requireArtifacts: string[]): boolean;
|
|
2
7
|
export declare function warnNonContributing(parsedSources: {
|
|
3
8
|
name: string;
|
|
4
9
|
lcov: LcovData;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
const stdout = (msg) => process.stdout.write(`${msg}\n`);
|
|
2
4
|
const stderr = (msg) => process.stderr.write(`${msg}\n`);
|
|
3
5
|
function fmtPct(n) {
|
|
@@ -19,6 +21,20 @@ function lcovContributesToDiff(sourceLcov, diff) {
|
|
|
19
21
|
}
|
|
20
22
|
return false;
|
|
21
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Verifies that each required artifact path exists under artifactsDir.
|
|
26
|
+
* Prints `::error::` to stderr for each missing file and returns false if any are absent.
|
|
27
|
+
*/
|
|
28
|
+
export function checkRequiredArtifacts(artifactsDir, requireArtifacts) {
|
|
29
|
+
let ok = true;
|
|
30
|
+
for (const rel of requireArtifacts) {
|
|
31
|
+
if (!existsSync(join(artifactsDir, rel))) {
|
|
32
|
+
stderr(`::error:: missing expected coverage artifact: ${rel}`);
|
|
33
|
+
ok = false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return ok;
|
|
37
|
+
}
|
|
22
38
|
export function warnNonContributing(parsedSources, diff) {
|
|
23
39
|
if (diff.size === 0)
|
|
24
40
|
return;
|
|
@@ -3,7 +3,7 @@ import { parseLcov } from "../lcov-parser.mjs";
|
|
|
3
3
|
import { mergeLcov } from "../lcov-merge.mjs";
|
|
4
4
|
import { getChangedLines } from "../diff-parser.mjs";
|
|
5
5
|
import { getChangedLineContent } from "../diff-parser-content.mjs";
|
|
6
|
-
import { loadRules } from "../rules.mjs";
|
|
6
|
+
import { loadRules, buildChangedRules } from "../rules.mjs";
|
|
7
7
|
import { computePatchCoverage } from "../patch-coverage.mjs";
|
|
8
8
|
import { computeCoverageDrop } from "../coverage-drop.mjs";
|
|
9
9
|
import { collapseRanges, renderFailureComment } from "../report.mjs";
|
|
@@ -11,7 +11,7 @@ import { upsertComment } from "../github-comment.mjs";
|
|
|
11
11
|
import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
|
|
12
12
|
import { writeSummary } from "../step-summary.mjs";
|
|
13
13
|
import { parseCheckArgs } from "./check-args.mjs";
|
|
14
|
-
import { warnNonContributing, printDropOutput } from "./check-output.mjs";
|
|
14
|
+
import { warnNonContributing, printDropOutput, checkRequiredArtifacts } from "./check-output.mjs";
|
|
15
15
|
const stdout = (msg) => process.stdout.write(`${msg}\n`);
|
|
16
16
|
const stderr = (msg) => process.stderr.write(`${msg}\n`);
|
|
17
17
|
export async function main(argv) {
|
|
@@ -34,6 +34,8 @@ export async function runCheck(args) {
|
|
|
34
34
|
stderr(`coverage-check: failed to load rules: ${err}`);
|
|
35
35
|
return 2;
|
|
36
36
|
}
|
|
37
|
+
if (!checkRequiredArtifacts(args.artifacts, args.requireArtifacts ?? []))
|
|
38
|
+
return 2;
|
|
37
39
|
const branch = args.branch ?? "main";
|
|
38
40
|
const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
|
|
39
41
|
const reports = [];
|
|
@@ -106,7 +108,8 @@ export async function runCheck(args) {
|
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
const { buckets, informational } = computePatchCoverage(diff, lcov, rules);
|
|
109
|
-
const
|
|
111
|
+
const changedRules = (args.dropOnlyChangedAreas ?? false) ? buildChangedRules(diff, rules) : undefined;
|
|
112
|
+
const drops = computeCoverageDrop(lcov, baseline, rules, changedRules);
|
|
110
113
|
const passed = buckets.every((b) => b.passed) && drops.every((d) => d.passed || d.skipped);
|
|
111
114
|
const result = { buckets, drops, informational, passed };
|
|
112
115
|
if (args.json) {
|
|
@@ -166,5 +169,5 @@ export async function runCheck(args) {
|
|
|
166
169
|
stderr(`coverage-check: failed to post PR comment: ${err}`);
|
|
167
170
|
}
|
|
168
171
|
}
|
|
169
|
-
return passed ? 0 : 1;
|
|
172
|
+
return (args.advisory ?? false) || passed ? 0 : 1;
|
|
170
173
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function parseMergeArgs(argv) {
|
|
2
|
+
const args = {
|
|
3
|
+
artifacts: "./coverage-artifacts",
|
|
4
|
+
output: "",
|
|
5
|
+
stripPrefixes: [],
|
|
6
|
+
requireArtifacts: [],
|
|
7
|
+
};
|
|
8
|
+
for (let i = 0; i < argv.length; i++) {
|
|
9
|
+
const flag = argv[i];
|
|
10
|
+
const next = argv[i + 1];
|
|
11
|
+
const val = () => {
|
|
12
|
+
if (next === undefined || next.startsWith("--")) {
|
|
13
|
+
throw new Error(`${flag} requires a value`);
|
|
14
|
+
}
|
|
15
|
+
i++;
|
|
16
|
+
return next;
|
|
17
|
+
};
|
|
18
|
+
switch (flag) {
|
|
19
|
+
case "--artifacts":
|
|
20
|
+
args.artifacts = val();
|
|
21
|
+
break;
|
|
22
|
+
case "--output":
|
|
23
|
+
args.output = val();
|
|
24
|
+
break;
|
|
25
|
+
case "--strip-prefix":
|
|
26
|
+
args.stripPrefixes.push(val());
|
|
27
|
+
break;
|
|
28
|
+
case "--require-artifact":
|
|
29
|
+
args.requireArtifacts.push(val());
|
|
30
|
+
break;
|
|
31
|
+
default:
|
|
32
|
+
throw new Error(`unknown flag: ${flag}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!args.output)
|
|
36
|
+
throw new Error("--output is required");
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MergeArgs } from "./merge-args.mts";
|
|
2
|
+
export type { MergeArgs } from "./merge-args.mts";
|
|
3
|
+
export declare function main(argv: string[]): Promise<number>;
|
|
4
|
+
/**
|
|
5
|
+
* Merges all lcov.info files found under args.artifacts into a single full-fidelity LCOV file.
|
|
6
|
+
* Preserves FN/FNDA/BRDA records and recomputes summary counters (FNF/FNH/BRF/BRH/LF/LH).
|
|
7
|
+
* Hit counts are summed across reports (lines, function hits, branch hits).
|
|
8
|
+
*/
|
|
9
|
+
export declare function runMerge(args: MergeArgs): number;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
|
|
4
|
+
import { parseLcovFull, mergeLcovFull, toLcovFull } from "../lcov-records.mjs";
|
|
5
|
+
import { checkRequiredArtifacts } from "./check-output.mjs";
|
|
6
|
+
import { parseMergeArgs } from "./merge-args.mjs";
|
|
7
|
+
const stdout = (msg) => process.stdout.write(`${msg}\n`);
|
|
8
|
+
const stderr = (msg) => process.stderr.write(`${msg}\n`);
|
|
9
|
+
export async function main(argv) {
|
|
10
|
+
let args;
|
|
11
|
+
try {
|
|
12
|
+
args = parseMergeArgs(argv);
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
stderr(`coverage-check merge: ${String(err)}`);
|
|
16
|
+
return 2;
|
|
17
|
+
}
|
|
18
|
+
return runMerge(args);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Merges all lcov.info files found under args.artifacts into a single full-fidelity LCOV file.
|
|
22
|
+
* Preserves FN/FNDA/BRDA records and recomputes summary counters (FNF/FNH/BRF/BRH/LF/LH).
|
|
23
|
+
* Hit counts are summed across reports (lines, function hits, branch hits).
|
|
24
|
+
*/
|
|
25
|
+
export function runMerge(args) {
|
|
26
|
+
if (!checkRequiredArtifacts(args.artifacts, args.requireArtifacts))
|
|
27
|
+
return 2;
|
|
28
|
+
const lcovFiles = collectLcovFiles(args.artifacts);
|
|
29
|
+
if (lcovFiles.length === 0) {
|
|
30
|
+
stderr(`coverage-check merge: no lcov.info files found under ${args.artifacts}`);
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
|
|
34
|
+
const reports = lcovFiles.map((f) => parseLcovFull(readFileSync(f, "utf8"), stripPrefixes));
|
|
35
|
+
const merged = mergeLcovFull(reports);
|
|
36
|
+
const lcovText = toLcovFull(merged);
|
|
37
|
+
mkdirSync(dirname(args.output), { recursive: true });
|
|
38
|
+
writeFileSync(args.output, lcovText, "utf8");
|
|
39
|
+
stdout(`coverage-check merge: merged ${lcovFiles.length} file(s) → ${args.output}`);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
export { runCheck } from "./commands/check.mts";
|
|
2
2
|
export { runStorePut } from "./commands/store-put.mts";
|
|
3
|
+
export { runMerge } from "./commands/merge.mts";
|
|
3
4
|
export { FileSystemSuiteStore } from "./suite-store.mts";
|
|
4
5
|
export { S3SuiteStore } from "./s3-suite-store.mts";
|
|
5
6
|
export { parseDiffWithContent, getChangedLineContent } from "./diff-parser-content.mts";
|
|
7
|
+
export { collapseRanges } from "./report.mts";
|
|
8
|
+
export { parseLcovFull, mergeLcovFull, toLcovFull } from "./lcov-records.mts";
|
|
6
9
|
export type { CheckArgs } from "./commands/check.mts";
|
|
7
10
|
export type { StorePutArgs } from "./commands/store-put.mts";
|
|
11
|
+
export type { MergeArgs } from "./commands/merge.mts";
|
|
8
12
|
export type { SuiteStore, SuiteMeta } from "./suite-store.mts";
|
|
9
13
|
export type { S3SuiteStoreOptions } from "./s3-suite-store.mts";
|
|
10
14
|
export { computeCoverageDrop } from "./coverage-drop.mts";
|
|
15
|
+
export type { FullLcovData, FullFileCoverage } from "./lcov-records.mts";
|
|
11
16
|
export type { CoverageCheckResult, BucketResult, DropResult, FileCoverageResult, LcovData, DiffLines, DiffLineContent, CoverageRule, } from "./types.mts";
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { runCheck } from "./commands/check.mjs";
|
|
2
2
|
export { runStorePut } from "./commands/store-put.mjs";
|
|
3
|
+
export { runMerge } from "./commands/merge.mjs";
|
|
3
4
|
export { FileSystemSuiteStore } from "./suite-store.mjs";
|
|
4
5
|
export { S3SuiteStore } from "./s3-suite-store.mjs";
|
|
5
6
|
export { parseDiffWithContent, getChangedLineContent } from "./diff-parser-content.mjs";
|
|
7
|
+
export { collapseRanges } from "./report.mjs";
|
|
8
|
+
export { parseLcovFull, mergeLcovFull, toLcovFull } from "./lcov-records.mjs";
|
|
6
9
|
export { computeCoverageDrop } from "./coverage-drop.mjs";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { CoverageRule, DropResult, LcovData } from "./types.mts";
|
|
2
|
-
export declare function computeCoverageDrop(current: LcovData, baseline: LcovData | null, rules: CoverageRule[]): DropResult[];
|
|
2
|
+
export declare function computeCoverageDrop(current: LcovData, baseline: LcovData | null, rules: CoverageRule[], changedRules?: Set<CoverageRule>): DropResult[];
|
|
@@ -13,11 +13,11 @@ function fileTotals(lcov, targetRule, allRules) {
|
|
|
13
13
|
}
|
|
14
14
|
return { hit, total };
|
|
15
15
|
}
|
|
16
|
-
export function computeCoverageDrop(current, baseline, rules) {
|
|
16
|
+
export function computeCoverageDrop(current, baseline, rules, changedRules) {
|
|
17
17
|
const dropRules = rules.filter((r) => r.no_coverage_drop);
|
|
18
18
|
return dropRules.map((rule) => {
|
|
19
19
|
const maxDrop = rule.max_coverage_drop ?? 0;
|
|
20
|
-
if (baseline === null) {
|
|
20
|
+
if (baseline === null || (changedRules !== undefined && !changedRules.has(rule))) {
|
|
21
21
|
return {
|
|
22
22
|
rule: rule.paths,
|
|
23
23
|
currentPct: null,
|
package/dist/src/diff-parser.mjs
CHANGED
|
@@ -114,6 +114,9 @@ export function parseDiff(text) {
|
|
|
114
114
|
}
|
|
115
115
|
/** Runs git merge-base + git diff and returns the raw diff text. Internal shared helper. */
|
|
116
116
|
export async function runGitDiff(baseRef, headRef) {
|
|
117
|
+
if (baseRef.startsWith("-") || headRef.startsWith("-")) {
|
|
118
|
+
throw new Error("Git reference cannot start with a hyphen (prevents argument injection)");
|
|
119
|
+
}
|
|
117
120
|
const { spawn } = await import("node:child_process");
|
|
118
121
|
const spawnProcess = (cmd, args) => new Promise((resolve, reject) => {
|
|
119
122
|
const chunks = [];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-fidelity LCOV data for one source file.
|
|
3
|
+
* Preserves function, branch, and line coverage records.
|
|
4
|
+
*/
|
|
5
|
+
export type FullFileCoverage = {
|
|
6
|
+
/** FN: definitions keyed by function name. */
|
|
7
|
+
functions: Map<string, {
|
|
8
|
+
line: number;
|
|
9
|
+
name: string;
|
|
10
|
+
}>;
|
|
11
|
+
/** FNDA: hit counts keyed by function name. */
|
|
12
|
+
functionHits: Map<string, number>;
|
|
13
|
+
/** BRDA: hit counts keyed by "lineNo,blockNo,branchNo". */
|
|
14
|
+
branches: Map<string, number>;
|
|
15
|
+
/** DA: hit counts keyed by line number. */
|
|
16
|
+
lines: Map<number, number>;
|
|
17
|
+
};
|
|
18
|
+
/** Map from normalized file path → full LCOV coverage data. */
|
|
19
|
+
export type FullLcovData = Map<string, FullFileCoverage>;
|
|
20
|
+
/**
|
|
21
|
+
* Parses full-fidelity LCOV, preserving FN/FNDA/BRDA/DA records.
|
|
22
|
+
* Hits from repeated records for the same line/function/branch are summed.
|
|
23
|
+
* Summary records (TN/LF/LH/FNF/FNH/BRF/BRH) are skipped and recomputed by toLcovFull.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseLcovFull(text: string, stripPrefixes?: string[]): FullLcovData;
|
|
26
|
+
/** Merges multiple FullLcovData maps by summing all hit counts per file/line/function/branch. */
|
|
27
|
+
export declare function mergeLcovFull(reports: FullLcovData[]): FullLcovData;
|
|
28
|
+
/** Serializes FullLcovData to LCOV text with recomputed FNF/FNH/BRF/BRH/LF/LH summaries. */
|
|
29
|
+
export declare function toLcovFull(lcov: FullLcovData): string;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
function normalizeSfPath(rawPath, stripPrefixes) {
|
|
2
|
+
let path = rawPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
3
|
+
for (const prefix of stripPrefixes) {
|
|
4
|
+
if (path.startsWith(prefix))
|
|
5
|
+
return path.slice(prefix.length);
|
|
6
|
+
}
|
|
7
|
+
if (path.startsWith("/") || /^[A-Z]:\//i.test(path)) {
|
|
8
|
+
const match = path.match(/^.*?\/_?work\/([^/]+)\/\1\//);
|
|
9
|
+
if (match)
|
|
10
|
+
return path.slice(match[0].length);
|
|
11
|
+
}
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
function getOrCreate(data, file) {
|
|
15
|
+
let cov = data.get(file);
|
|
16
|
+
if (cov === undefined) {
|
|
17
|
+
cov = { branches: new Map(), functionHits: new Map(), functions: new Map(), lines: new Map() };
|
|
18
|
+
data.set(file, cov);
|
|
19
|
+
}
|
|
20
|
+
return cov;
|
|
21
|
+
}
|
|
22
|
+
function applyRecord(line, cov) {
|
|
23
|
+
if (line.startsWith("FN:")) {
|
|
24
|
+
const comma = line.indexOf(",", 3);
|
|
25
|
+
if (comma === -1)
|
|
26
|
+
return;
|
|
27
|
+
const lineNum = parseInt(line.slice(3, comma), 10);
|
|
28
|
+
const name = line.slice(comma + 1);
|
|
29
|
+
if (Number.isFinite(lineNum) && name)
|
|
30
|
+
cov.functions.set(name, { line: lineNum, name });
|
|
31
|
+
}
|
|
32
|
+
else if (line.startsWith("FNDA:")) {
|
|
33
|
+
const comma = line.indexOf(",", 5);
|
|
34
|
+
if (comma === -1)
|
|
35
|
+
return;
|
|
36
|
+
const hits = parseInt(line.slice(5, comma), 10);
|
|
37
|
+
const name = line.slice(comma + 1);
|
|
38
|
+
if (Number.isFinite(hits) && name)
|
|
39
|
+
cov.functionHits.set(name, (cov.functionHits.get(name) ?? 0) + hits);
|
|
40
|
+
}
|
|
41
|
+
else if (line.startsWith("BRDA:")) {
|
|
42
|
+
const parts = line.slice(5).split(",");
|
|
43
|
+
if (parts.length !== 4)
|
|
44
|
+
return;
|
|
45
|
+
const key = `${parts[0]},${parts[1]},${parts[2]}`;
|
|
46
|
+
const raw = parts[3];
|
|
47
|
+
const hits = raw === "-" ? 0 : parseInt(raw, 10);
|
|
48
|
+
if (Number.isFinite(hits))
|
|
49
|
+
cov.branches.set(key, (cov.branches.get(key) ?? 0) + hits);
|
|
50
|
+
}
|
|
51
|
+
else if (line.startsWith("DA:")) {
|
|
52
|
+
const comma = line.indexOf(",", 3);
|
|
53
|
+
if (comma === -1)
|
|
54
|
+
return;
|
|
55
|
+
const lineNum = parseInt(line.slice(3, comma), 10);
|
|
56
|
+
const hits = parseInt(line.slice(comma + 1), 10);
|
|
57
|
+
if (Number.isFinite(lineNum) && Number.isFinite(hits))
|
|
58
|
+
cov.lines.set(lineNum, (cov.lines.get(lineNum) ?? 0) + hits);
|
|
59
|
+
}
|
|
60
|
+
// TN:, LF:, LH:, FNF:, FNH:, BRF:, BRH: are summary lines — recomputed by toLcovFull
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Parses full-fidelity LCOV, preserving FN/FNDA/BRDA/DA records.
|
|
64
|
+
* Hits from repeated records for the same line/function/branch are summed.
|
|
65
|
+
* Summary records (TN/LF/LH/FNF/FNH/BRF/BRH) are skipped and recomputed by toLcovFull.
|
|
66
|
+
*/
|
|
67
|
+
export function parseLcovFull(text, stripPrefixes = []) {
|
|
68
|
+
const result = new Map();
|
|
69
|
+
let current = null;
|
|
70
|
+
let start = 0;
|
|
71
|
+
while (start < text.length) {
|
|
72
|
+
let end = text.indexOf("\n", start);
|
|
73
|
+
if (end === -1)
|
|
74
|
+
end = text.length;
|
|
75
|
+
let lineEnd = end;
|
|
76
|
+
while (lineEnd > start) {
|
|
77
|
+
const c = text.charCodeAt(lineEnd - 1);
|
|
78
|
+
if (c === 32 || c === 9 || c === 13) {
|
|
79
|
+
lineEnd--;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (text.startsWith("SF:", start)) {
|
|
85
|
+
current = getOrCreate(result, normalizeSfPath(text.slice(start + 3, lineEnd), stripPrefixes));
|
|
86
|
+
}
|
|
87
|
+
else if (lineEnd - start === 13 && text.startsWith("end_of_record", start)) {
|
|
88
|
+
current = null;
|
|
89
|
+
}
|
|
90
|
+
else if (current !== null) {
|
|
91
|
+
applyRecord(text.slice(start, lineEnd), current);
|
|
92
|
+
}
|
|
93
|
+
start = end + 1;
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
/** Merges multiple FullLcovData maps by summing all hit counts per file/line/function/branch. */
|
|
98
|
+
export function mergeLcovFull(reports) {
|
|
99
|
+
const merged = new Map();
|
|
100
|
+
for (const report of reports) {
|
|
101
|
+
for (const [file, cov] of report) {
|
|
102
|
+
const target = merged.get(file);
|
|
103
|
+
if (target === undefined) {
|
|
104
|
+
merged.set(file, {
|
|
105
|
+
functions: new Map(cov.functions),
|
|
106
|
+
functionHits: new Map(cov.functionHits),
|
|
107
|
+
branches: new Map(cov.branches),
|
|
108
|
+
lines: new Map(cov.lines),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
for (const [name, def] of cov.functions) {
|
|
113
|
+
if (!target.functions.has(name))
|
|
114
|
+
target.functions.set(name, def);
|
|
115
|
+
}
|
|
116
|
+
for (const [name, hits] of cov.functionHits)
|
|
117
|
+
target.functionHits.set(name, (target.functionHits.get(name) ?? 0) + hits);
|
|
118
|
+
for (const [key, hits] of cov.branches)
|
|
119
|
+
target.branches.set(key, (target.branches.get(key) ?? 0) + hits);
|
|
120
|
+
for (const [lineNum, hits] of cov.lines)
|
|
121
|
+
target.lines.set(lineNum, (target.lines.get(lineNum) ?? 0) + hits);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return merged;
|
|
126
|
+
}
|
|
127
|
+
/** Serializes FullLcovData to LCOV text with recomputed FNF/FNH/BRF/BRH/LF/LH summaries. */
|
|
128
|
+
export function toLcovFull(lcov) {
|
|
129
|
+
if (lcov.size === 0)
|
|
130
|
+
return "";
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const [file, cov] of [...lcov.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
133
|
+
out.push("TN:", `SF:${file}`);
|
|
134
|
+
const fns = [...cov.functions.values()].sort((a, b) => a.line - b.line || a.name.localeCompare(b.name));
|
|
135
|
+
for (const fn of fns)
|
|
136
|
+
out.push(`FN:${fn.line},${fn.name}`);
|
|
137
|
+
const fnHits = [...cov.functionHits.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
138
|
+
for (const [name, hits] of fnHits)
|
|
139
|
+
out.push(`FNDA:${hits},${name}`);
|
|
140
|
+
out.push(`FNF:${cov.functions.size}`);
|
|
141
|
+
out.push(`FNH:${[...cov.functionHits.values()].filter((h) => h > 0).length}`);
|
|
142
|
+
const brs = [...cov.branches.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
143
|
+
for (const [key, hits] of brs)
|
|
144
|
+
out.push(`BRDA:${key},${hits}`);
|
|
145
|
+
out.push(`BRF:${cov.branches.size}`);
|
|
146
|
+
out.push(`BRH:${[...cov.branches.values()].filter((h) => h > 0).length}`);
|
|
147
|
+
const lines = [...cov.lines.entries()].sort(([a], [b]) => a - b);
|
|
148
|
+
for (const [lineNum, hits] of lines)
|
|
149
|
+
out.push(`DA:${lineNum},${hits}`);
|
|
150
|
+
out.push(`LF:${cov.lines.size}`);
|
|
151
|
+
out.push(`LH:${[...cov.lines.values()].filter((h) => h > 0).length}`);
|
|
152
|
+
out.push("end_of_record");
|
|
153
|
+
}
|
|
154
|
+
return `${out.join("\n")}\n`;
|
|
155
|
+
}
|
|
@@ -64,9 +64,16 @@ export function lcovBufferToIstanbul(lcov, stripPrefixes) {
|
|
|
64
64
|
continue;
|
|
65
65
|
const fileCov = coverage[filePath]; // filePath was validated against coverage when SF: was processed
|
|
66
66
|
if (line.startsWith("DA:")) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
67
|
+
// Optimization: Instead of using `split(",")`, we use `indexOf` and `slice`
|
|
68
|
+
// to avoid allocating intermediate arrays, reducing garbage collection pauses.
|
|
69
|
+
const rest = line.slice(3);
|
|
70
|
+
const commaIdx = rest.indexOf(",");
|
|
71
|
+
if (commaIdx === -1)
|
|
72
|
+
continue;
|
|
73
|
+
const lineNoStr = rest.slice(0, commaIdx);
|
|
74
|
+
const hitsStr = rest.slice(commaIdx + 1);
|
|
75
|
+
const l = Number.parseInt(lineNoStr, 10);
|
|
76
|
+
const h = Number.parseInt(hitsStr, 10);
|
|
70
77
|
if (!Number.isInteger(l) || !Number.isInteger(h))
|
|
71
78
|
continue;
|
|
72
79
|
const key = String(l);
|
|
@@ -112,11 +119,25 @@ export function lcovBufferToIstanbul(lcov, stripPrefixes) {
|
|
|
112
119
|
}
|
|
113
120
|
}
|
|
114
121
|
else if (line.startsWith("BRDA:")) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
// Optimization: Instead of using `split(",")`, we use `indexOf` and `slice`
|
|
123
|
+
// to avoid allocating intermediate arrays, reducing garbage collection pauses.
|
|
124
|
+
const rest = line.slice(5);
|
|
125
|
+
let comma1 = rest.indexOf(",");
|
|
126
|
+
if (comma1 === -1)
|
|
127
|
+
continue;
|
|
128
|
+
let comma2 = rest.indexOf(",", comma1 + 1);
|
|
129
|
+
if (comma2 === -1)
|
|
130
|
+
continue;
|
|
131
|
+
let comma3 = rest.indexOf(",", comma2 + 1);
|
|
132
|
+
if (comma3 === -1)
|
|
133
|
+
continue;
|
|
134
|
+
const lineNoStr = rest.slice(0, comma1);
|
|
135
|
+
const blockId = rest.slice(comma1 + 1, comma2);
|
|
136
|
+
const branchId = rest.slice(comma2 + 1, comma3);
|
|
137
|
+
const comma4 = rest.indexOf(",", comma3 + 1);
|
|
138
|
+
const takenStr = comma4 === -1 ? rest.slice(comma3 + 1) : rest.slice(comma3 + 1, comma4);
|
|
139
|
+
const lineNo = Number.parseInt(lineNoStr, 10);
|
|
140
|
+
const taken = takenStr === "-" ? 0 : Number.parseInt(takenStr, 10);
|
|
120
141
|
if (!Number.isInteger(lineNo) || !blockId || !branchId || !Number.isInteger(taken))
|
|
121
142
|
continue;
|
|
122
143
|
const blockKey = `${lineNo}-${blockId}`;
|
|
@@ -140,7 +161,10 @@ export function lcovBufferToIstanbul(lcov, stripPrefixes) {
|
|
|
140
161
|
for (const [fp, blocks] of fileBranches) {
|
|
141
162
|
const fileCov = coverage[fp]; // fp was validated against coverage when added to fileBranches
|
|
142
163
|
for (const [blockKey, branches] of blocks) {
|
|
143
|
-
|
|
164
|
+
// blockKey is always "N-blockId". Optimization: avoid split("-")
|
|
165
|
+
const dashIdx = blockKey.indexOf("-");
|
|
166
|
+
const lineNoStr = blockKey.slice(0, dashIdx);
|
|
167
|
+
const lineNo = Number.parseInt(lineNoStr, 10);
|
|
144
168
|
const branchLoc = loc(lineNo);
|
|
145
169
|
const sorted = [...branches.entries()].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }));
|
|
146
170
|
fileCov.branchMap[blockKey] = {
|
package/dist/src/report.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { CoverageCheckResult } from "./types.mts";
|
|
2
2
|
export declare const COMMENT_MARKER = "<!-- coverage-check -->";
|
|
3
|
-
export declare function collapseRanges(lines: number[]): string;
|
|
3
|
+
export declare function collapseRanges(lines: number[], prefix?: string): string;
|
|
4
4
|
export declare function renderFailureComment(result: CoverageCheckResult, runUrl: string, now?: string): string;
|
package/dist/src/report.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const COMMENT_MARKER = "<!-- coverage-check -->";
|
|
2
|
-
export function collapseRanges(lines) {
|
|
2
|
+
export function collapseRanges(lines, prefix = "L") {
|
|
3
3
|
if (lines.length === 0)
|
|
4
4
|
return "";
|
|
5
5
|
const sorted = [...lines].sort((a, b) => a - b);
|
|
@@ -12,12 +12,12 @@ export function collapseRanges(lines) {
|
|
|
12
12
|
end = n;
|
|
13
13
|
}
|
|
14
14
|
else {
|
|
15
|
-
ranges.push(start === end ?
|
|
15
|
+
ranges.push(start === end ? `${prefix}${start}` : `${prefix}${start}-${end}`);
|
|
16
16
|
start = n;
|
|
17
17
|
end = n;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
ranges.push(start === end ?
|
|
20
|
+
ranges.push(start === end ? `${prefix}${start}` : `${prefix}${start}-${end}`);
|
|
21
21
|
return ranges.join(", ");
|
|
22
22
|
}
|
|
23
23
|
function pct(bucket) {
|
package/dist/src/rules.d.mts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type { CoverageRule } from "./types.mts";
|
|
1
|
+
import type { CoverageRule, DiffLines } from "./types.mts";
|
|
2
2
|
export declare function loadRules(rulesPath: string): CoverageRule[];
|
|
3
3
|
/** Returns the first matching rule for a repo-root-relative file path, or null. */
|
|
4
4
|
export declare function matchRule(file: string, rules: CoverageRule[]): CoverageRule | null;
|
|
5
|
+
/**
|
|
6
|
+
* Returns the set of rules that have at least one changed file in the diff.
|
|
7
|
+
* Used by `--drop-only-changed-areas` to scope the coverage-drop gate.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildChangedRules(diff: DiffLines, rules: CoverageRule[]): Set<CoverageRule>;
|
package/dist/src/rules.mjs
CHANGED
|
@@ -44,3 +44,10 @@ export function matchRule(file, rules) {
|
|
|
44
44
|
}
|
|
45
45
|
return null;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Returns the set of rules that have at least one changed file in the diff.
|
|
49
|
+
* Used by `--drop-only-changed-areas` to scope the coverage-drop gate.
|
|
50
|
+
*/
|
|
51
|
+
export function buildChangedRules(diff, rules) {
|
|
52
|
+
return new Set([...diff.keys()].map((f) => matchRule(f, rules)).filter((r) => r !== null));
|
|
53
|
+
}
|
|
@@ -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, branch: string, incomingTimestamp: string): Promise<boolean>;
|
|
16
|
+
export declare function readPointer(ctx: ReadContext, suite: string, branch: string): Promise<StoredPointer>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import { encodeBranchName, isNewerTimestamp } from "./suite-store.mjs";
|
|
3
|
+
import { bodyToBuffer, isNotFound } from "./s3-utils.mjs";
|
|
4
|
+
export async function getLegacy(ctx, suite) {
|
|
5
|
+
const key = ctx.key(suite, "lcov.info");
|
|
6
|
+
try {
|
|
7
|
+
const resp = (await ctx.sendS3("get legacy coverage payload", key, new GetObjectCommand({
|
|
8
|
+
Bucket: ctx.bucket,
|
|
9
|
+
Key: key,
|
|
10
|
+
})));
|
|
11
|
+
return bodyToBuffer(resp.Body);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
if (isNotFound(err))
|
|
15
|
+
return null;
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function shouldWritePointer(ctx, suite, branch, incomingTimestamp) {
|
|
20
|
+
const key = ctx.key(suite, "branch", encodeBranchName(branch), "latest.json");
|
|
21
|
+
try {
|
|
22
|
+
const resp = (await ctx.sendS3("get branch pointer for compare", key, new GetObjectCommand({
|
|
23
|
+
Bucket: ctx.bucket,
|
|
24
|
+
Key: key,
|
|
25
|
+
})));
|
|
26
|
+
if (resp.Body === undefined)
|
|
27
|
+
return true;
|
|
28
|
+
const body = await bodyToBuffer(resp.Body);
|
|
29
|
+
const current = JSON.parse(body.toString("utf8"));
|
|
30
|
+
return !isNewerTimestamp(current.timestamp, incomingTimestamp);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (isNotFound(err))
|
|
34
|
+
return true;
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function readPointer(ctx, suite, branch) {
|
|
39
|
+
const keys = [
|
|
40
|
+
ctx.key(suite, "branch", encodeBranchName(branch), "latest.json"),
|
|
41
|
+
ctx.key(suite, "branch", branch, "latest.json"),
|
|
42
|
+
];
|
|
43
|
+
let lastNotFound;
|
|
44
|
+
for (const key of keys) {
|
|
45
|
+
try {
|
|
46
|
+
const resp = (await ctx.sendS3("get branch pointer", key, new GetObjectCommand({ Bucket: ctx.bucket, Key: key })));
|
|
47
|
+
const body = await bodyToBuffer(resp.Body);
|
|
48
|
+
return JSON.parse(body.toString("utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (!isNotFound(err))
|
|
52
|
+
throw err;
|
|
53
|
+
lastNotFound = err;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw lastNotFound;
|
|
57
|
+
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
+
import type { ClientLike, S3OperationDetails } from "./s3-diagnostics.mts";
|
|
1
2
|
import type { SuitePutMeta, SuiteStore } from "./suite-store.mts";
|
|
2
|
-
type ClientLike = {
|
|
3
|
-
send(cmd: object): Promise<unknown>;
|
|
4
|
-
};
|
|
5
3
|
export type S3SuiteStoreOptions = {
|
|
6
4
|
bucket: string;
|
|
7
5
|
prefix?: string;
|
|
@@ -10,19 +8,19 @@ export type S3SuiteStoreOptions = {
|
|
|
10
8
|
client?: ClientLike;
|
|
11
9
|
};
|
|
12
10
|
export declare class S3SuiteStore implements SuiteStore {
|
|
13
|
-
|
|
11
|
+
readonly bucket: string;
|
|
14
12
|
private readonly prefix;
|
|
15
13
|
private readonly client;
|
|
16
14
|
constructor({ bucket, prefix, region, client }: S3SuiteStoreOptions);
|
|
17
|
-
|
|
15
|
+
key(...parts: string[]): string;
|
|
18
16
|
list(): Promise<string[]>;
|
|
19
17
|
get(suite: string, opts?: {
|
|
20
18
|
sha?: string;
|
|
21
19
|
branch?: string;
|
|
22
20
|
}): Promise<Buffer | null>;
|
|
23
21
|
put(suite: string, lcov: Buffer, meta?: SuitePutMeta): Promise<void>;
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
private
|
|
22
|
+
sendS3(operation: string, key: string, command: object, details?: S3OperationDetails): Promise<unknown>;
|
|
23
|
+
private getVersionedPayload;
|
|
24
|
+
private putLegacyPayload;
|
|
25
|
+
private putPointer;
|
|
27
26
|
}
|
|
28
|
-
export {};
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { GetObjectCommand, ListObjectsV2Command, PutObjectCommand
|
|
2
|
-
import {
|
|
1
|
+
import { GetObjectCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
3
|
+
import { assertSafePathComponent, assertValidTimestamp, encodeBranchName } from "./suite-store.mjs";
|
|
4
|
+
import { createS3Client, sendS3 } from "./s3-diagnostics.mjs";
|
|
5
|
+
import { getLegacy, readPointer, shouldWritePointer } from "./s3-suite-store-reads.mjs";
|
|
3
6
|
import { bodyToBuffer, isNotFound } from "./s3-utils.mjs";
|
|
4
7
|
export class S3SuiteStore {
|
|
5
8
|
bucket;
|
|
@@ -8,7 +11,7 @@ export class S3SuiteStore {
|
|
|
8
11
|
constructor({ bucket, prefix, region, client }) {
|
|
9
12
|
this.bucket = bucket;
|
|
10
13
|
this.prefix = prefix ? prefix.replace(/\/+$/, "") : "";
|
|
11
|
-
this.client = client ??
|
|
14
|
+
this.client = client ?? createS3Client(region);
|
|
12
15
|
}
|
|
13
16
|
key(...parts) {
|
|
14
17
|
return this.prefix ? [this.prefix, ...parts].join("/") : parts.join("/");
|
|
@@ -18,7 +21,7 @@ export class S3SuiteStore {
|
|
|
18
21
|
const suites = [];
|
|
19
22
|
let continuationToken;
|
|
20
23
|
do {
|
|
21
|
-
const resp = (await this.
|
|
24
|
+
const resp = (await this.sendS3("list suites", this.key(), new ListObjectsV2Command({
|
|
22
25
|
Bucket: this.bucket,
|
|
23
26
|
Prefix: pfx,
|
|
24
27
|
Delimiter: "/",
|
|
@@ -36,112 +39,101 @@ export class S3SuiteStore {
|
|
|
36
39
|
if (opts?.sha !== undefined)
|
|
37
40
|
assertSafePathComponent(opts.sha, "sha");
|
|
38
41
|
let sha = opts?.sha;
|
|
42
|
+
let pointer = null;
|
|
39
43
|
if (!sha) {
|
|
40
44
|
const branch = opts?.branch ?? "main";
|
|
41
45
|
try {
|
|
42
|
-
|
|
46
|
+
pointer = await readPointer(this, suite, branch);
|
|
43
47
|
assertSafePathComponent(pointer.sha, "sha");
|
|
44
48
|
sha = pointer.sha;
|
|
45
49
|
}
|
|
46
50
|
catch (err) {
|
|
47
51
|
if (isNotFound(err))
|
|
48
|
-
return
|
|
52
|
+
return getLegacy(this, suite);
|
|
49
53
|
throw err;
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
|
-
|
|
53
|
-
const resp = (await this.client.send(new GetObjectCommand({
|
|
54
|
-
Bucket: this.bucket,
|
|
55
|
-
Key: this.key(suite, "sha", sha, "lcov.info"),
|
|
56
|
-
})));
|
|
57
|
-
return bodyToBuffer(resp.Body);
|
|
58
|
-
}
|
|
59
|
-
catch (err) {
|
|
60
|
-
if (isNotFound(err))
|
|
61
|
-
return null;
|
|
62
|
-
throw err;
|
|
63
|
-
}
|
|
56
|
+
return this.getVersionedPayload(suite, sha, pointer, opts?.sha !== undefined);
|
|
64
57
|
}
|
|
65
58
|
async put(suite, lcov, meta) {
|
|
66
59
|
assertSafePathComponent(suite, "suite");
|
|
67
60
|
if (meta === undefined) {
|
|
68
|
-
await this.
|
|
69
|
-
Bucket: this.bucket,
|
|
70
|
-
Key: this.key(suite, "lcov.info"),
|
|
71
|
-
Body: lcov,
|
|
72
|
-
ContentType: "text/plain",
|
|
73
|
-
}));
|
|
61
|
+
await this.putLegacyPayload(suite, lcov);
|
|
74
62
|
return;
|
|
75
63
|
}
|
|
76
64
|
const { sha, branch } = meta;
|
|
77
65
|
assertSafePathComponent(sha, "sha");
|
|
78
66
|
const ts = meta.timestamp ?? new Date().toISOString();
|
|
79
67
|
assertValidTimestamp(ts);
|
|
80
|
-
|
|
68
|
+
const payload = gzipSync(lcov);
|
|
69
|
+
const payloadKey = this.key(suite, "sha", sha, "lcov.info.gz");
|
|
70
|
+
await this.sendS3("put coverage payload", payloadKey, new PutObjectCommand({
|
|
81
71
|
Bucket: this.bucket,
|
|
82
|
-
Key:
|
|
83
|
-
Body:
|
|
72
|
+
Key: payloadKey,
|
|
73
|
+
Body: payload,
|
|
74
|
+
ContentEncoding: "gzip",
|
|
84
75
|
ContentType: "text/plain",
|
|
85
|
-
}));
|
|
86
|
-
if (!(await
|
|
76
|
+
}), { rawBytes: lcov.byteLength, storedBytes: payload.byteLength });
|
|
77
|
+
if (!(await shouldWritePointer(this, suite, branch, ts)))
|
|
87
78
|
return;
|
|
88
|
-
await this.
|
|
89
|
-
Bucket: this.bucket,
|
|
90
|
-
Key: this.key(suite, "branch", encodeBranchName(branch), "latest.json"),
|
|
91
|
-
Body: Buffer.from(JSON.stringify({ sha, timestamp: ts }), "utf8"),
|
|
92
|
-
ContentType: "application/json",
|
|
93
|
-
}));
|
|
79
|
+
await this.putPointer(suite, branch, sha, ts, payloadKey, lcov.byteLength, payload.byteLength);
|
|
94
80
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const resp = (await this.client.send(new GetObjectCommand({
|
|
98
|
-
Bucket: this.bucket,
|
|
99
|
-
Key: this.key(suite, "lcov.info"),
|
|
100
|
-
})));
|
|
101
|
-
return bodyToBuffer(resp.Body);
|
|
102
|
-
}
|
|
103
|
-
catch (err) {
|
|
104
|
-
if (isNotFound(err))
|
|
105
|
-
return null;
|
|
106
|
-
throw err;
|
|
107
|
-
}
|
|
81
|
+
sendS3(operation, key, command, details = {}) {
|
|
82
|
+
return sendS3(this.client, this.bucket, operation, key, command, details);
|
|
108
83
|
}
|
|
109
|
-
async
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
Key: this.key(suite, "branch", encodeBranchName(branch), "latest.json"),
|
|
114
|
-
})));
|
|
115
|
-
if (resp.Body === undefined)
|
|
116
|
-
return true;
|
|
117
|
-
const body = await bodyToBuffer(resp.Body);
|
|
118
|
-
const current = JSON.parse(body.toString("utf8"));
|
|
119
|
-
return !isNewerTimestamp(current.timestamp, incomingTimestamp);
|
|
84
|
+
async getVersionedPayload(suite, sha, pointer, explicitSha) {
|
|
85
|
+
let candidates;
|
|
86
|
+
if (pointer?.payloadKey !== undefined) {
|
|
87
|
+
candidates = [{ key: pointer.payloadKey, encoding: pointer.encoding }];
|
|
120
88
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
89
|
+
else if (explicitSha) {
|
|
90
|
+
candidates = [
|
|
91
|
+
{ key: this.key(suite, "sha", sha, "lcov.info.gz"), encoding: "gzip" },
|
|
92
|
+
{ key: this.key(suite, "sha", sha, "lcov.info"), encoding: undefined },
|
|
93
|
+
];
|
|
125
94
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
this.key(suite, "branch", branch, "latest.json"),
|
|
131
|
-
];
|
|
132
|
-
let lastNotFound;
|
|
133
|
-
for (const key of keys) {
|
|
95
|
+
else {
|
|
96
|
+
candidates = [{ key: this.key(suite, "sha", sha, "lcov.info"), encoding: undefined }];
|
|
97
|
+
}
|
|
98
|
+
for (const candidate of candidates) {
|
|
134
99
|
try {
|
|
135
|
-
const resp = (await this.
|
|
100
|
+
const resp = (await this.sendS3("get coverage payload", candidate.key, new GetObjectCommand({
|
|
101
|
+
Bucket: this.bucket,
|
|
102
|
+
Key: candidate.key,
|
|
103
|
+
})));
|
|
136
104
|
const body = await bodyToBuffer(resp.Body);
|
|
137
|
-
return
|
|
105
|
+
return candidate.encoding === "gzip" ? gunzipSync(body) : body;
|
|
138
106
|
}
|
|
139
107
|
catch (err) {
|
|
140
108
|
if (!isNotFound(err))
|
|
141
109
|
throw err;
|
|
142
|
-
lastNotFound = err;
|
|
143
110
|
}
|
|
144
111
|
}
|
|
145
|
-
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
async putLegacyPayload(suite, lcov) {
|
|
115
|
+
const key = this.key(suite, "lcov.info");
|
|
116
|
+
await this.sendS3("put legacy coverage payload", key, new PutObjectCommand({
|
|
117
|
+
Bucket: this.bucket,
|
|
118
|
+
Key: key,
|
|
119
|
+
Body: lcov,
|
|
120
|
+
ContentType: "text/plain",
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
async putPointer(suite, branch, sha, timestamp, payloadKey, rawBytes, storedBytes) {
|
|
124
|
+
const pointerKey = this.key(suite, "branch", encodeBranchName(branch), "latest.json");
|
|
125
|
+
await this.sendS3("put branch pointer", pointerKey, new PutObjectCommand({
|
|
126
|
+
Bucket: this.bucket,
|
|
127
|
+
Key: pointerKey,
|
|
128
|
+
Body: Buffer.from(JSON.stringify({
|
|
129
|
+
sha,
|
|
130
|
+
timestamp,
|
|
131
|
+
payloadKey,
|
|
132
|
+
encoding: "gzip",
|
|
133
|
+
rawBytes,
|
|
134
|
+
storedBytes,
|
|
135
|
+
}), "utf8"),
|
|
136
|
+
ContentType: "application/json",
|
|
137
|
+
}));
|
|
146
138
|
}
|
|
147
139
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coverage-check",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@aws-sdk/client-s3": "^3.1048.0",
|
|
35
|
+
"@smithy/node-http-handler": "^4.7.7",
|
|
35
36
|
"js-yaml": "^4.1.1",
|
|
36
37
|
"monocart-coverage-reports": "^2.12.11"
|
|
37
38
|
},
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
"@types/node": "^25.8.0",
|
|
41
42
|
"@vitest/coverage-v8": "^4.1.6",
|
|
42
43
|
"husky": "^9.1.7",
|
|
43
|
-
"oxfmt": "^0.
|
|
44
|
+
"oxfmt": "^0.52.0",
|
|
44
45
|
"oxlint": "^1.65.0",
|
|
45
46
|
"typescript": "^6.0.3",
|
|
46
47
|
"vitest": "^4.1.6"
|