coverage-check 0.7.1 → 0.8.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 CHANGED
@@ -137,6 +137,16 @@ Two common sources of confusion:
137
137
  The annotation affects only the stdout failure output. The GitHub PR sticky comment and Actions step
138
138
  summary are unchanged.
139
139
 
140
+ ### Renamed and relocated files
141
+
142
+ `coverage-check` asks Git to detect renames with no rename-attempt limit before parsing patch lines.
143
+ Pure file moves do not create patch-coverage obligations: unchanged relocated lines are not treated
144
+ as newly added lines. If a moved file also changes content, Git still emits normal hunks for the
145
+ edited new-side lines, and those lines must satisfy the matching patch-coverage rule.
146
+
147
+ Large rewrites that fall below Git's rename similarity threshold may still appear as delete/add
148
+ pairs. In that case the destination file's executable lines are checked as new patch lines.
149
+
140
150
  ### Merging LCOV files
141
151
 
142
152
  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`):
@@ -162,6 +172,22 @@ coverage-check check \
162
172
  --advisory
163
173
  ```
164
174
 
175
+ For wrappers that need machine-readable advisory results without a temp file, pass `--json -`.
176
+ This writes JSON-only output to stdout and suppresses the human report:
177
+
178
+ ```sh
179
+ coverage-check check \
180
+ --rules .coverage-rules.yml \
181
+ --artifacts ./coverage-artifacts \
182
+ --base origin/main \
183
+ --head HEAD \
184
+ --advisory \
185
+ --json -
186
+ ```
187
+
188
+ JSON output keeps the normal check result fields (`passed`, `buckets`, `drops`, `informational`) and
189
+ adds `exitCode`, `advisory`, and `skipped`.
190
+
165
191
  ### PR-scoped regression gate
166
192
 
167
193
  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 +217,24 @@ coverage-check check \
191
217
 
192
218
  `--require-artifact` is also accepted by `coverage-check merge`.
193
219
 
220
+ ### Preparing fan-in artifacts
221
+
222
+ Use `prepare-artifacts` before a fan-in coverage check when downloaded artifacts may be either
223
+ nested as `coverage-<suite>/lcov.info` or flattened to a root-level `lcov.info`. Pass one
224
+ `--expect-suite <job>=<suite>` for each successful coverage producer. The command normalizes a
225
+ single flat LCOV into the expected suite directory and fails if any expected LCOV is missing:
226
+
227
+ ```sh
228
+ coverage-check prepare-artifacts \
229
+ --artifacts ./coverage-artifacts \
230
+ --expect-suite test-backend=backend \
231
+ --expect-suite test-web=web
232
+ ```
233
+
234
+ For local or fan-in wrappers, `check --aggregate-artifacts` treats all fresh LCOV files as one source
235
+ for diagnostics, `check --fail-on-empty` exits `1` instead of skipping when no LCOV exists, and
236
+ `check --ignore-path <glob>` prepends a zero-threshold override for CI-only paths in that run.
237
+
194
238
  ## Rules file
195
239
 
196
240
  ```yaml
@@ -235,6 +279,8 @@ First-match-wins means that if you have a more specific rule before a broader on
235
279
 
236
280
  ## CLI reference
237
281
 
282
+ Run `coverage-check --help` or `coverage-check check --help` to print the available commands and check flags.
283
+
238
284
  ### `coverage-check check`
239
285
 
240
286
  | Flag | Default | Description |
@@ -251,11 +297,14 @@ First-match-wins means that if you have a more specific rule before a broader on
251
297
  | `--strip-prefix` | — | Extra path prefix to strip from LCOV `SF:` lines (repeatable) |
252
298
  | `--pr` | — | Pull request number for sticky comment |
253
299
  | `--repo` | `$GITHUB_REPOSITORY` | `owner/repo` for sticky comment |
254
- | `--json` | — | Write JSON result to this path |
300
+ | `--json` | — | Write JSON result to this path; use `-` for JSON-only stdout |
255
301
  | `--annotate-source` | — | Print the trimmed source text of each uncovered line in stdout (does not alter PR comment or step summary) |
256
302
  | `--advisory` | — | Exit `0` even on shortfall; still prints, writes JSON, and posts PR comment |
257
303
  | `--drop-only-changed-areas` | — | Restrict `no_coverage_drop` to rule areas that have ≥1 changed file; others are reported as skipped |
258
304
  | `--require-artifact` | — | Fail (exit `2`) if this relative path is absent under `--artifacts` (repeatable) |
305
+ | `--fail-on-empty` | — | Exit `1` when no coverage data is found instead of skipping |
306
+ | `--aggregate-artifacts` | — | Treat fresh LCOV files as one source for diagnostics and summaries |
307
+ | `--ignore-path` | — | Prepend a zero-threshold override glob for this run (repeatable) |
259
308
 
260
309
  ### `coverage-check merge`
261
310
 
@@ -268,6 +317,13 @@ First-match-wins means that if you have a more specific rule before a broader on
268
317
 
269
318
  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
319
 
320
+ ### `coverage-check prepare-artifacts`
321
+
322
+ | Flag | Default | Description |
323
+ | ---------------- | ---------------------- | ---------------------------------------------- |
324
+ | `--artifacts` | `./coverage-artifacts` | Coverage artifact directory to normalize/check |
325
+ | `--expect-suite` | — | Expected producer and suite as `<job>=<suite>` |
326
+
271
327
  ### `coverage-check store-put`
272
328
 
273
329
  | Flag | Default | Description |
@@ -289,10 +345,15 @@ When `--sha` and `--branch` are both provided, `store-put` writes a SHA-addresse
289
345
 
290
346
  ```ts
291
347
  import {
348
+ checkCoverage,
349
+ evaluateCheck,
292
350
  runCheck,
293
351
  runMerge,
294
352
  runStorePut,
353
+ prepareCoverageArtifacts,
295
354
  collapseRanges,
355
+ collectLcovFiles,
356
+ zeroThresholdGlobs,
296
357
  parseLcovFull,
297
358
  mergeLcovFull,
298
359
  toLcovFull,
@@ -306,6 +367,27 @@ const fsStore = new FileSystemSuiteStore("/path/to/store");
306
367
  // S3 store (requires AWS credentials — see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html)
307
368
  const s3Store = new S3SuiteStore({ bucket: "my-bucket", prefix: "coverage" });
308
369
 
370
+ const check = await checkCoverage({
371
+ rules: ".coverage-rules.yml",
372
+ artifacts: "./coverage",
373
+ base: "origin/main",
374
+ head: "HEAD",
375
+ pr: null,
376
+ repo: "",
377
+ json: null,
378
+ stripPrefixes: [],
379
+ store: s3Store,
380
+ suite: "backend",
381
+ branch: "main",
382
+ advisory: true,
383
+ dropOnlyChangedAreas: false,
384
+ requireArtifacts: [],
385
+ });
386
+
387
+ if (!check.result?.passed) {
388
+ console.log(check.exitCode, check.advisory, check.result);
389
+ }
390
+
309
391
  await runCheck({
310
392
  rules: ".coverage-rules.yml",
311
393
  artifacts: "./coverage",
@@ -321,8 +403,35 @@ await runCheck({
321
403
  advisory: false,
322
404
  dropOnlyChangedAreas: false,
323
405
  requireArtifacts: [],
406
+ failOnEmpty: false,
407
+ aggregateArtifacts: false,
408
+ ignorePaths: [],
324
409
  });
325
410
 
411
+ const evaluated = await evaluateCheck({
412
+ rules: ".coverage-rules.yml",
413
+ artifacts: "./coverage",
414
+ base: "origin/main",
415
+ head: "HEAD",
416
+ pr: null,
417
+ repo: "",
418
+ json: null,
419
+ stripPrefixes: [],
420
+ store: null,
421
+ suite: null,
422
+ });
423
+ if (evaluated.result?.passed === false) {
424
+ // Integrations can render their own advisory output from the typed result.
425
+ }
426
+
427
+ prepareCoverageArtifacts({
428
+ artifacts: "./coverage-artifacts",
429
+ expectedSuites: [{ job: "test-backend", suite: "backend" }],
430
+ });
431
+
432
+ collectLcovFiles("./coverage-artifacts");
433
+ zeroThresholdGlobs(".coverage-rules.yml");
434
+
326
435
  await runMerge({
327
436
  artifacts: "./coverage-artifacts",
328
437
  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";
@@ -14,6 +14,6 @@ export declare function decodeGitCString(s: string): string;
14
14
  */
15
15
  export declare function parseDiff(text: string): DiffLines;
16
16
  /** Runs git merge-base + git diff and returns the raw diff text. Internal shared helper. */
17
- export declare function runGitDiff(baseRef: string, headRef: string): Promise<string>;
17
+ export declare function runGitDiff(baseRef: string, headRef: string, cwd?: string): Promise<string>;
18
18
  /** Runs git diff and returns the parsed result. */
19
19
  export declare function getChangedLines(baseRef: string, headRef: string): Promise<DiffLines>;
@@ -113,14 +113,14 @@ export function parseDiff(text) {
113
113
  return result;
114
114
  }
115
115
  /** Runs git merge-base + git diff and returns the raw diff text. Internal shared helper. */
116
- export async function runGitDiff(baseRef, headRef) {
116
+ export async function runGitDiff(baseRef, headRef, cwd) {
117
117
  if (baseRef.startsWith("-") || headRef.startsWith("-")) {
118
118
  throw new Error("Git reference cannot start with a hyphen (prevents argument injection)");
119
119
  }
120
120
  const { spawn } = await import("node:child_process");
121
121
  const spawnProcess = (cmd, args) => new Promise((resolve, reject) => {
122
122
  const chunks = [];
123
- const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "inherit"] });
123
+ const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "inherit"], cwd });
124
124
  proc.stdout.on("data", (chunk) => chunks.push(chunk));
125
125
  proc.on("error", reject);
126
126
  proc.on("close", (code) => code === 0
@@ -132,6 +132,8 @@ export async function runGitDiff(baseRef, headRef) {
132
132
  // --src-prefix/--dst-prefix override diff.noprefix and diff.mnemonicPrefix git config
133
133
  return spawnProcess("git", [
134
134
  "diff",
135
+ "-M",
136
+ "-l0",
135
137
  "--unified=0",
136
138
  "--inter-hunk-context=0",
137
139
  "--no-color",
@@ -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.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",
@@ -31,17 +31,17 @@
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"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/js-yaml": "^4.0.9",
41
- "@types/node": "^25.8.0",
41
+ "@types/node": "^26.0.1",
42
42
  "@vitest/coverage-v8": "^4.1.6",
43
43
  "husky": "^9.1.7",
44
- "oxfmt": "^0.52.0",
44
+ "oxfmt": "^0.56.0",
45
45
  "oxlint": "^1.65.0",
46
46
  "typescript": "^6.0.3",
47
47
  "vitest": "^4.1.6"