coverage-check 0.7.1 → 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 CHANGED
@@ -162,6 +162,22 @@ coverage-check check \
162
162
  --advisory
163
163
  ```
164
164
 
165
+ For wrappers that need machine-readable advisory results without a temp file, pass `--json -`.
166
+ This writes JSON-only output to stdout and suppresses the human report:
167
+
168
+ ```sh
169
+ coverage-check check \
170
+ --rules .coverage-rules.yml \
171
+ --artifacts ./coverage-artifacts \
172
+ --base origin/main \
173
+ --head HEAD \
174
+ --advisory \
175
+ --json -
176
+ ```
177
+
178
+ JSON output keeps the normal check result fields (`passed`, `buckets`, `drops`, `informational`) and
179
+ adds `exitCode`, `advisory`, and `skipped`.
180
+
165
181
  ### PR-scoped regression gate
166
182
 
167
183
  By default, `no_coverage_drop` applies to every rule area regardless of what changed. Pass `--drop-only-changed-areas` to restrict the drop gate to rule areas that contain at least one changed file in the PR diff. Areas with no changed files are reported as `skipped` — they pass non-blockingly:
@@ -191,6 +207,24 @@ coverage-check check \
191
207
 
192
208
  `--require-artifact` is also accepted by `coverage-check merge`.
193
209
 
210
+ ### Preparing fan-in artifacts
211
+
212
+ Use `prepare-artifacts` before a fan-in coverage check when downloaded artifacts may be either
213
+ nested as `coverage-<suite>/lcov.info` or flattened to a root-level `lcov.info`. Pass one
214
+ `--expect-suite <job>=<suite>` for each successful coverage producer. The command normalizes a
215
+ single flat LCOV into the expected suite directory and fails if any expected LCOV is missing:
216
+
217
+ ```sh
218
+ coverage-check prepare-artifacts \
219
+ --artifacts ./coverage-artifacts \
220
+ --expect-suite test-backend=backend \
221
+ --expect-suite test-web=web
222
+ ```
223
+
224
+ For local or fan-in wrappers, `check --aggregate-artifacts` treats all fresh LCOV files as one source
225
+ for diagnostics, `check --fail-on-empty` exits `1` instead of skipping when no LCOV exists, and
226
+ `check --ignore-path <glob>` prepends a zero-threshold override for CI-only paths in that run.
227
+
194
228
  ## Rules file
195
229
 
196
230
  ```yaml
@@ -235,6 +269,8 @@ First-match-wins means that if you have a more specific rule before a broader on
235
269
 
236
270
  ## CLI reference
237
271
 
272
+ Run `coverage-check --help` or `coverage-check check --help` to print the available commands and check flags.
273
+
238
274
  ### `coverage-check check`
239
275
 
240
276
  | Flag | Default | Description |
@@ -251,11 +287,14 @@ First-match-wins means that if you have a more specific rule before a broader on
251
287
  | `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
252
288
  | `--pr` | — | Pull request number for sticky comment |
253
289
  | `--repo` | `$GITHUB_REPOSITORY` | `owner/repo` for sticky comment |
254
- | `--json` | — | Write JSON result to this path |
290
+ | `--json` | — | Write JSON result to this path; use `-` for JSON-only stdout |
255
291
  | `--annotate-source` | — | Print the trimmed source text of each uncovered line in stdout (does not alter PR comment or step summary) |
256
292
  | `--advisory` | — | Exit `0` even on shortfall; still prints, writes JSON, and posts PR comment |
257
293
  | `--drop-only-changed-areas` | — | Restrict `no_coverage_drop` to rule areas that have ≥1 changed file; others are reported as skipped |
258
294
  | `--require-artifact` | — | Fail (exit `2`) if this relative path is absent under `--artifacts` (repeatable) |
295
+ | `--fail-on-empty` | — | Exit `1` when no coverage data is found instead of skipping |
296
+ | `--aggregate-artifacts` | — | Treat fresh LCOV files as one source for diagnostics and summaries |
297
+ | `--ignore-path` | — | Prepend a zero-threshold override glob for this run (repeatable) |
259
298
 
260
299
  ### `coverage-check merge`
261
300
 
@@ -268,6 +307,13 @@ First-match-wins means that if you have a more specific rule before a broader on
268
307
 
269
308
  Hit counts are summed across all inputs. Function (`FN`/`FNDA`) and branch (`BRDA`) records are preserved; summary counters (`LF/LH/FNF/FNH/BRF/BRH`) are recomputed from the merged data.
270
309
 
310
+ ### `coverage-check prepare-artifacts`
311
+
312
+ | Flag | Default | Description |
313
+ | ---------------- | ---------------------- | ---------------------------------------------- |
314
+ | `--artifacts` | `./coverage-artifacts` | Coverage artifact directory to normalize/check |
315
+ | `--expect-suite` | — | Expected producer and suite as `<job>=<suite>` |
316
+
271
317
  ### `coverage-check store-put`
272
318
 
273
319
  | Flag | Default | Description |
@@ -289,10 +335,15 @@ When `--sha` and `--branch` are both provided, `store-put` writes a SHA-addresse
289
335
 
290
336
  ```ts
291
337
  import {
338
+ checkCoverage,
339
+ evaluateCheck,
292
340
  runCheck,
293
341
  runMerge,
294
342
  runStorePut,
343
+ prepareCoverageArtifacts,
295
344
  collapseRanges,
345
+ collectLcovFiles,
346
+ zeroThresholdGlobs,
296
347
  parseLcovFull,
297
348
  mergeLcovFull,
298
349
  toLcovFull,
@@ -306,6 +357,27 @@ const fsStore = new FileSystemSuiteStore("/path/to/store");
306
357
  // S3 store (requires AWS credentials — see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html)
307
358
  const s3Store = new S3SuiteStore({ bucket: "my-bucket", prefix: "coverage" });
308
359
 
360
+ const check = await checkCoverage({
361
+ rules: ".coverage-rules.yml",
362
+ artifacts: "./coverage",
363
+ base: "origin/main",
364
+ head: "HEAD",
365
+ pr: null,
366
+ repo: "",
367
+ json: null,
368
+ stripPrefixes: [],
369
+ store: s3Store,
370
+ suite: "backend",
371
+ branch: "main",
372
+ advisory: true,
373
+ dropOnlyChangedAreas: false,
374
+ requireArtifacts: [],
375
+ });
376
+
377
+ if (!check.result?.passed) {
378
+ console.log(check.exitCode, check.advisory, check.result);
379
+ }
380
+
309
381
  await runCheck({
310
382
  rules: ".coverage-rules.yml",
311
383
  artifacts: "./coverage",
@@ -321,8 +393,35 @@ await runCheck({
321
393
  advisory: false,
322
394
  dropOnlyChangedAreas: false,
323
395
  requireArtifacts: [],
396
+ failOnEmpty: false,
397
+ aggregateArtifacts: false,
398
+ ignorePaths: [],
399
+ });
400
+
401
+ const evaluated = await evaluateCheck({
402
+ rules: ".coverage-rules.yml",
403
+ artifacts: "./coverage",
404
+ base: "origin/main",
405
+ head: "HEAD",
406
+ pr: null,
407
+ repo: "",
408
+ json: null,
409
+ stripPrefixes: [],
410
+ store: null,
411
+ suite: null,
412
+ });
413
+ if (evaluated.result?.passed === false) {
414
+ // Integrations can render their own advisory output from the typed result.
415
+ }
416
+
417
+ prepareCoverageArtifacts({
418
+ artifacts: "./coverage-artifacts",
419
+ expectedSuites: [{ job: "test-backend", suite: "backend" }],
324
420
  });
325
421
 
422
+ collectLcovFiles("./coverage-artifacts");
423
+ zeroThresholdGlobs(".coverage-rules.yml");
424
+
326
425
  await runMerge({
327
426
  artifacts: "./coverage-artifacts",
328
427
  output: "./coverage-merged/lcov.info",
package/dist/src/cli.mjs CHANGED
@@ -1,11 +1,18 @@
1
1
  import { main as checkMain } from "./commands/check.mjs";
2
+ import { checkHelp } from "./commands/check-render.mjs";
2
3
  import { main as storePutMain } from "./commands/store-put.mjs";
3
4
  import { main as htmlMain } from "./commands/html.mjs";
4
5
  import { main as mergeMain } from "./commands/merge.mjs";
5
6
  import { main as summaryMain } from "./commands/summary.mjs";
7
+ import { main as prepareArtifactsMain } from "./commands/prepare-artifacts.mjs";
6
8
  const stderr = (msg) => process.stderr.write(`${msg}\n`);
9
+ const stdout = (msg) => process.stdout.write(`${msg}\n`);
7
10
  export async function main(argv) {
8
11
  const sub = argv[0];
12
+ if (sub === "--help" || sub === "-h") {
13
+ stdout(help());
14
+ return 0;
15
+ }
9
16
  if (!sub || sub.startsWith("-"))
10
17
  return checkMain(argv);
11
18
  if (sub === "check")
@@ -18,6 +25,24 @@ export async function main(argv) {
18
25
  return htmlMain(argv.slice(1));
19
26
  if (sub === "summary")
20
27
  return summaryMain(argv.slice(1));
28
+ if (sub === "prepare-artifacts")
29
+ return prepareArtifactsMain(argv.slice(1));
21
30
  stderr(`coverage-check: unknown subcommand: ${JSON.stringify(sub)}`);
22
31
  return 2;
23
32
  }
33
+ function help() {
34
+ return `coverage-check
35
+
36
+ Usage:
37
+ coverage-check <command> [options]
38
+ coverage-check [check options]
39
+
40
+ Commands:
41
+ check Check patch coverage (default)
42
+ store-put Store suite LCOV data
43
+ merge Merge lcov.info files
44
+ html Generate HTML coverage reports
45
+ summary Generate a coverage summary
46
+
47
+ ${checkHelp()}`;
48
+ }
@@ -24,5 +24,11 @@ export type CheckArgs = {
24
24
  dropOnlyChangedAreas?: boolean;
25
25
  /** Relative paths under --artifacts that must exist before the check runs (repeatable). */
26
26
  requireArtifacts?: string[];
27
+ /** Exit non-zero when no LCOV data is available instead of treating the check as skipped. */
28
+ failOnEmpty?: boolean;
29
+ /** Merge fresh LCOV artifacts before evaluating, so fan-in artifacts are treated as one source. */
30
+ aggregateArtifacts?: boolean;
31
+ /** Path globs to exempt by prepending zero-threshold override rules (repeatable). */
32
+ ignorePaths?: string[];
27
33
  };
28
34
  export declare function parseCheckArgs(argv: string[]): CheckArgs;
@@ -1,5 +1,5 @@
1
1
  import { makeStore } from "../store-factory.mjs";
2
- import { assertSafePathComponent } from "../suite-store.mjs";
2
+ import { assertSafePathComponent, assertValidRepo } from "../suite-store.mjs";
3
3
  export function parseCheckArgs(argv) {
4
4
  let storeFs = null;
5
5
  let storeS3 = null;
@@ -20,6 +20,9 @@ export function parseCheckArgs(argv) {
20
20
  advisory: false,
21
21
  dropOnlyChangedAreas: false,
22
22
  requireArtifacts: [],
23
+ failOnEmpty: false,
24
+ aggregateArtifacts: false,
25
+ ignorePaths: [],
23
26
  };
24
27
  for (let i = 0; i < argv.length; i++) {
25
28
  const flag = argv[i];
@@ -92,14 +95,29 @@ export function parseCheckArgs(argv) {
92
95
  case "--require-artifact":
93
96
  args.requireArtifacts.push(val());
94
97
  break;
98
+ case "--fail-on-empty":
99
+ args.failOnEmpty = true;
100
+ break;
101
+ case "--aggregate-artifacts":
102
+ args.aggregateArtifacts = true;
103
+ break;
104
+ case "--ignore-path":
105
+ args.ignorePaths.push(val());
106
+ break;
95
107
  default:
96
108
  throw new Error(`unknown flag: ${flag}`);
97
109
  }
98
110
  }
99
111
  if (storeFs && storeS3)
100
112
  throw new Error("--store-fs and --store-s3 are mutually exclusive");
101
- if (args.pr !== null && args.repo.trim() === "")
113
+ const repo = args.repo.trim();
114
+ args.repo = repo;
115
+ if (args.pr !== null && repo.length === 0) {
102
116
  throw new Error("--repo is required when --pr is set (or define GITHUB_REPOSITORY)");
117
+ }
118
+ if (repo !== "") {
119
+ args.repo = assertValidRepo(repo);
120
+ }
103
121
  args.store = makeStore({ fs: storeFs, s3: storeS3 });
104
122
  return args;
105
123
  }
@@ -4,8 +4,13 @@ import type { DropResult, DiffLines, LcovData } from "../types.mts";
4
4
  * Prints `::error::` to stderr for each missing file and returns false if any are absent.
5
5
  */
6
6
  export declare function checkRequiredArtifacts(artifactsDir: string, requireArtifacts: string[]): boolean;
7
+ export declare function missingRequiredArtifacts(artifactsDir: string, requireArtifacts: string[]): string[];
7
8
  export declare function warnNonContributing(parsedSources: {
8
9
  name: string;
9
10
  lcov: LcovData;
10
11
  }[], diff: DiffLines): void;
12
+ export declare function nonContributingWarnings(parsedSources: {
13
+ name: string;
14
+ lcov: LcovData;
15
+ }[], diff: DiffLines): string[];
11
16
  export declare function printDropOutput(drops: DropResult[]): void;
@@ -26,23 +26,28 @@ function lcovContributesToDiff(sourceLcov, diff) {
26
26
  * Prints `::error::` to stderr for each missing file and returns false if any are absent.
27
27
  */
28
28
  export function checkRequiredArtifacts(artifactsDir, requireArtifacts) {
29
- 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;
29
+ const missing = missingRequiredArtifacts(artifactsDir, requireArtifacts);
30
+ for (const rel of missing)
31
+ stderr(`::error:: missing expected coverage artifact: ${rel}`);
32
+ return missing.length === 0;
33
+ }
34
+ export function missingRequiredArtifacts(artifactsDir, requireArtifacts) {
35
+ return requireArtifacts.filter((rel) => !existsSync(join(artifactsDir, rel)));
37
36
  }
38
37
  export function warnNonContributing(parsedSources, diff) {
38
+ for (const warning of nonContributingWarnings(parsedSources, diff))
39
+ stderr(warning);
40
+ }
41
+ export function nonContributingWarnings(parsedSources, diff) {
39
42
  if (diff.size === 0)
40
- return;
43
+ return [];
44
+ const warnings = [];
41
45
  for (const { name, lcov: sourceLcov } of parsedSources) {
42
46
  if (!lcovContributesToDiff(sourceLcov, diff)) {
43
- stderr(`coverage-check: warning: coverage from ${name} contributed 0 coverable lines to the patch result. This may indicate a path prefix mismatch.`);
47
+ warnings.push(`coverage-check: warning: coverage from ${name} contributed 0 coverable lines to the patch result. This may indicate a path prefix mismatch.`);
44
48
  }
45
49
  }
50
+ return warnings;
46
51
  }
47
52
  export function printDropOutput(drops) {
48
53
  if (drops.length === 0)
@@ -0,0 +1 @@
1
+ export declare function checkHelp(): string;
@@ -0,0 +1,27 @@
1
+ export function checkHelp() {
2
+ return `coverage-check check
3
+
4
+ Usage:
5
+ coverage-check check [options]
6
+ coverage-check [options]
7
+
8
+ Options:
9
+ --rules <path> Path to YAML rules file (default: .coverage-rules.yml)
10
+ --artifacts <dir> Directory to scan for lcov.info files (default: ./coverage-artifacts)
11
+ --base <ref> Base git ref for git diff (default: origin/main)
12
+ --head <ref> Head git ref for git diff (default: HEAD)
13
+ --store-fs <path> Path to a filesystem suite store directory
14
+ --store <path> Alias for --store-fs
15
+ --store-s3 <bucket[/prefix]> S3 suite store
16
+ --branch <name> Branch pointer to follow when reading from the store (default: main)
17
+ --suite <name> Name of the current suite
18
+ --strip-prefix <prefix> Extra path prefix to strip from LCOV SF: lines (repeatable)
19
+ --pr <number> Pull request number for sticky comment
20
+ --repo <owner/repo> Repository for sticky comment (default: $GITHUB_REPOSITORY)
21
+ --json <path|-> Write JSON result to a path, or JSON-only stdout with -
22
+ --annotate-source Print trimmed source text for each uncovered line
23
+ --advisory Exit 0 even on coverage shortfall
24
+ --drop-only-changed-areas Restrict no_coverage_drop to changed rule areas
25
+ --require-artifact <relpath> Fail if this path is absent under --artifacts (repeatable)
26
+ -h, --help Show this help`;
27
+ }
@@ -0,0 +1,17 @@
1
+ import type { CoverageCheckResult } from "../types.mts";
2
+ export type CheckRunResult = {
3
+ result: CoverageCheckResult | null;
4
+ exitCode: 0 | 1 | 2;
5
+ advisory: boolean;
6
+ skipped: boolean;
7
+ error: string | null;
8
+ warnings: string[];
9
+ };
10
+ export type CheckJsonPayload = CoverageCheckResult & {
11
+ exitCode: 0 | 1 | 2;
12
+ advisory: boolean;
13
+ skipped: boolean;
14
+ error?: string;
15
+ };
16
+ export declare function emptyResult(passed: boolean): CoverageCheckResult;
17
+ export declare function toJsonPayload(check: CheckRunResult): CheckJsonPayload;
@@ -0,0 +1,12 @@
1
+ export function emptyResult(passed) {
2
+ return { buckets: [], drops: [], informational: [], passed };
3
+ }
4
+ export function toJsonPayload(check) {
5
+ return {
6
+ ...(check.result ?? emptyResult(false)),
7
+ exitCode: check.exitCode,
8
+ advisory: check.advisory,
9
+ skipped: check.skipped,
10
+ ...(check.error === null ? {} : { error: check.error }),
11
+ };
12
+ }
@@ -1,4 +1,25 @@
1
1
  import type { CheckArgs } from "./check-args.mts";
2
+ import type { CheckRunResult } from "./check-result.mts";
3
+ import type { SuiteSource } from "../step-summary.mts";
4
+ import type { DiffLineContent, LcovData } from "../types.mts";
5
+ import type { CoverageCheckResult } from "../types.mts";
2
6
  export type { CheckArgs } from "./check-args.mts";
7
+ export type { CheckRunResult } from "./check-result.mts";
8
+ export type EvaluatedCheck = {
9
+ exitCode: number;
10
+ result: CoverageCheckResult | null;
11
+ suiteSources: SuiteSource[];
12
+ runUrl: string;
13
+ branch: string;
14
+ diffContent: DiffLineContent | null;
15
+ skippedReason?: string;
16
+ parsedSources: {
17
+ name: string;
18
+ lcov: LcovData;
19
+ }[];
20
+ warnings: string[];
21
+ };
3
22
  export declare function main(argv: string[]): Promise<number>;
23
+ export declare function checkCoverage(args: CheckArgs): Promise<CheckRunResult>;
24
+ export declare function evaluateCheck(args: CheckArgs): Promise<EvaluatedCheck>;
4
25
  export declare function runCheck(args: CheckArgs): Promise<number>;
@@ -1,9 +1,10 @@
1
+ // oxlint-disable max-lines -- check evaluation and CLI rendering share one pipeline
1
2
  import { readFileSync, writeFileSync } from "node:fs";
2
3
  import { parseLcov } from "../lcov-parser.mjs";
3
4
  import { mergeLcov } from "../lcov-merge.mjs";
4
5
  import { getChangedLines } from "../diff-parser.mjs";
5
6
  import { getChangedLineContent } from "../diff-parser-content.mjs";
6
- import { loadRules, buildChangedRules } from "../rules.mjs";
7
+ import { loadRules, buildChangedRules, withIgnoredPaths } from "../rules.mjs";
7
8
  import { computePatchCoverage } from "../patch-coverage.mjs";
8
9
  import { computeCoverageDrop } from "../coverage-drop.mjs";
9
10
  import { collapseRanges, renderFailureComment } from "../report.mjs";
@@ -11,10 +12,16 @@ import { upsertComment } from "../github-comment.mjs";
11
12
  import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mjs";
12
13
  import { writeSummary } from "../step-summary.mjs";
13
14
  import { parseCheckArgs } from "./check-args.mjs";
14
- import { warnNonContributing, printDropOutput, checkRequiredArtifacts } from "./check-output.mjs";
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 runCheck(args) {
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
- stderr(`coverage-check: failed to load rules: ${err}`);
35
- return 2;
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
- parsedSources.push({ name: `file '${f}'`, lcov });
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: mergeLcov(freshLcovs),
119
+ lcov: freshMerged,
71
120
  });
72
121
  }
73
122
  if (reports.length === 0) {
74
- stderr(`coverage-check: no coverage data found — skipping`);
75
- return 0;
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
- stderr(`coverage-check: git diff failed: ${err}`);
91
- return 2;
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
- warnNonContributing(parsedSources, diff);
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
- stderr(`coverage-check: warning: failed to load baseline from store: ${String(err)}`);
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
- if (args.json) {
116
- writeFileSync(args.json, JSON.stringify(result, null, 2));
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 runUrl = process.env["GITHUB_SERVER_URL"] && process.env["GITHUB_RUN_ID"]
119
- ? `${process.env["GITHUB_SERVER_URL"]}/${args.repo}/actions/runs/${process.env["GITHUB_RUN_ID"]}`
120
- : "N/A";
121
- if (!passed) {
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 type { CheckArgs } from "./commands/check.mts";
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 existingId = await findExistingComment(repo, pr, gh);
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/${repo}/issues/comments/${existingId}`, "-X", "DELETE"]);
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/${repo}/issues/comments/${existingId}`,
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/${repo}/issues/${pr}/comments`, "-f", `body=${body}`]);
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
- const parts = line.slice(5).split(",");
43
- if (parts.length !== 4)
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 key = `${parts[0]},${parts[1]},${parts[2]}`;
46
- const raw = parts[3];
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);
@@ -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
  /**
@@ -36,6 +36,16 @@ export function loadRules(rulesPath) {
36
36
  }
37
37
  return parsed.rules;
38
38
  }
39
+ /** Returns path globs whose patch coverage threshold is exactly zero. */
40
+ export function zeroThresholdGlobs(rulesPath) {
41
+ return loadRules(rulesPath).flatMap((rule) => rule.patch_coverage_min === 0 ? [rule.paths] : []);
42
+ }
43
+ /** Prepends zero-threshold rules for paths that should be ignored by this run. */
44
+ export function withIgnoredPaths(rules, ignorePaths = []) {
45
+ if (ignorePaths.length === 0)
46
+ return rules;
47
+ return [...ignorePaths.map((paths) => ({ paths, patch_coverage_min: 0 })), ...rules];
48
+ }
39
49
  /** Returns the first matching rule for a repo-root-relative file path, or null. */
40
50
  export function matchRule(file, rules) {
41
51
  for (const rule of rules) {
@@ -12,6 +12,6 @@ type ReadContext = {
12
12
  sendS3(operation: string, key: string, command: object): Promise<unknown>;
13
13
  };
14
14
  export declare function getLegacy(ctx: ReadContext, suite: string): Promise<Buffer | null>;
15
- export declare function shouldWritePointer(ctx: ReadContext, suite: string, branch: string, incomingTimestamp: string): Promise<boolean>;
15
+ export declare function shouldWritePointer(ctx: ReadContext, suite: string, encodedBranch: string, incomingTimestamp: string): Promise<boolean>;
16
16
  export declare function readPointer(ctx: ReadContext, suite: string, branch: string): Promise<StoredPointer>;
17
17
  export {};
@@ -16,8 +16,8 @@ export async function getLegacy(ctx, suite) {
16
16
  throw err;
17
17
  }
18
18
  }
19
- export async function shouldWritePointer(ctx, suite, branch, incomingTimestamp) {
20
- const key = ctx.key(suite, "branch", encodeBranchName(branch), "latest.json");
19
+ export async function shouldWritePointer(ctx, suite, encodedBranch, incomingTimestamp) {
20
+ const key = ctx.key(suite, "branch", encodedBranch, "latest.json");
21
21
  try {
22
22
  const resp = (await ctx.sendS3("get branch pointer for compare", key, new GetObjectCommand({
23
23
  Bucket: ctx.bucket,
@@ -4,7 +4,6 @@ export type S3SuiteStoreOptions = {
4
4
  bucket: string;
5
5
  prefix?: string;
6
6
  region?: string;
7
- /** Inject a custom S3 client (e.g. for testing). */
8
7
  client?: ClientLike;
9
8
  };
10
9
  export declare class S3SuiteStore implements SuiteStore {
@@ -63,6 +63,7 @@ export class S3SuiteStore {
63
63
  }
64
64
  const { sha, branch } = meta;
65
65
  assertSafePathComponent(sha, "sha");
66
+ const encodedBranch = encodeBranchName(branch);
66
67
  const ts = meta.timestamp ?? new Date().toISOString();
67
68
  assertValidTimestamp(ts);
68
69
  const payload = gzipSync(lcov);
@@ -74,7 +75,7 @@ export class S3SuiteStore {
74
75
  ContentEncoding: "gzip",
75
76
  ContentType: "text/plain",
76
77
  }), { rawBytes: lcov.byteLength, storedBytes: payload.byteLength });
77
- if (!(await shouldWritePointer(this, suite, branch, ts)))
78
+ if (!(await shouldWritePointer(this, suite, encodedBranch, ts)))
78
79
  return;
79
80
  await this.putPointer(suite, branch, sha, ts, payloadKey, lcov.byteLength, payload.byteLength);
80
81
  }
@@ -1,5 +1,6 @@
1
1
  import type { SuiteMeta } from "./types.mts";
2
2
  export declare function assertSafePathComponent(value: string, label: string): void;
3
+ export declare function assertValidRepo(repo: string): string;
3
4
  export type { SuiteMeta };
4
5
  export type SuitePutMeta = {
5
6
  sha: string;
@@ -10,8 +10,27 @@ export function assertSafePathComponent(value, label) {
10
10
  throw new Error(`invalid ${label}: ${JSON.stringify(value)}`);
11
11
  }
12
12
  }
13
+ export function assertValidRepo(repo) {
14
+ const trimmed = typeof repo === "string" ? repo.trim() : "";
15
+ if (trimmed.length === 0) {
16
+ throw new Error(`Invalid repository format: ${repo}. Expected owner/repo.`);
17
+ }
18
+ const parts = trimmed.split("/");
19
+ if (parts.length !== 2 ||
20
+ !/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(parts[0]) ||
21
+ !/^[A-Za-z0-9_.-]+$/.test(parts[1]) ||
22
+ parts[1] === "." ||
23
+ parts[1] === "..") {
24
+ throw new Error(`Invalid repository format: ${repo}. Expected owner/repo.`);
25
+ }
26
+ return trimmed;
27
+ }
13
28
  export function encodeBranchName(branch) {
14
- if (typeof branch !== "string" || branch.length === 0) {
29
+ if (typeof branch !== "string" ||
30
+ branch.length === 0 ||
31
+ branch === "." ||
32
+ branch.includes("..") ||
33
+ branch.includes("\\")) {
15
34
  throw new Error(`invalid branch: ${JSON.stringify(branch)}`);
16
35
  }
17
36
  return Buffer.from(branch, "utf8").toString("base64url");
@@ -91,10 +110,11 @@ export class FileSystemSuiteStore {
91
110
  }
92
111
  const { sha, branch } = meta;
93
112
  assertSafePathComponent(sha, "sha");
113
+ const encodedBranch = encodeBranchName(branch);
94
114
  const shaDir = join(this.root, suite, "sha", sha);
95
115
  mkdirSync(shaDir, { recursive: true });
96
116
  writeFileSync(join(shaDir, "lcov.info"), lcov);
97
- const branchDir = join(this.root, suite, "branch", encodeBranchName(branch));
117
+ const branchDir = join(this.root, suite, "branch", encodedBranch);
98
118
  mkdirSync(branchDir, { recursive: true });
99
119
  const pointerPath = join(branchDir, "latest.json");
100
120
  const timestamp = meta.timestamp ?? new Date().toISOString();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coverage-check",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Patch-coverage gate: checks that newly added lines meet per-path coverage thresholds. Supports per-suite LCOV accumulation for conditional CI.",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -31,7 +31,7 @@
31
31
  "node": ">=22.0.0"
32
32
  },
33
33
  "dependencies": {
34
- "@aws-sdk/client-s3": "^3.1048.0",
34
+ "@aws-sdk/client-s3": "3.1075.0",
35
35
  "@smithy/node-http-handler": "^4.7.7",
36
36
  "js-yaml": "^4.1.1",
37
37
  "monocart-coverage-reports": "^2.12.11"
@@ -41,7 +41,7 @@
41
41
  "@types/node": "^25.8.0",
42
42
  "@vitest/coverage-v8": "^4.1.6",
43
43
  "husky": "^9.1.7",
44
- "oxfmt": "^0.52.0",
44
+ "oxfmt": "^0.53.0",
45
45
  "oxlint": "^1.65.0",
46
46
  "typescript": "^6.0.3",
47
47
  "vitest": "^4.1.6"