coverage-check 0.1.1 → 0.2.2

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.
Files changed (67) hide show
  1. package/README.md +109 -51
  2. package/bin/coverage-check.mjs +4 -0
  3. package/dist/src/cli.d.mts +1 -0
  4. package/dist/src/cli.mjs +14 -0
  5. package/dist/src/commands/check-args.d.mts +20 -0
  6. package/dist/src/commands/check-args.mjs +89 -0
  7. package/dist/src/commands/check.d.mts +4 -0
  8. package/dist/src/commands/check.mjs +128 -0
  9. package/dist/src/commands/store-put.d.mts +11 -0
  10. package/dist/src/commands/store-put.mjs +104 -0
  11. package/{src/coverage-check.mts → dist/src/coverage-check.d.mts} +3 -9
  12. package/dist/src/coverage-check.mjs +4 -0
  13. package/dist/src/diff-parser.d.mts +17 -0
  14. package/dist/src/diff-parser.mjs +127 -0
  15. package/dist/src/github-comment.d.mts +9 -0
  16. package/dist/src/github-comment.mjs +66 -0
  17. package/dist/src/lcov-merge.d.mts +5 -0
  18. package/dist/src/lcov-merge.mjs +29 -0
  19. package/dist/src/lcov-parser.d.mts +8 -0
  20. package/dist/src/lcov-parser.mjs +44 -0
  21. package/dist/src/load-artifacts.d.mts +9 -0
  22. package/dist/src/load-artifacts.mjs +41 -0
  23. package/dist/src/patch-coverage.d.mts +5 -0
  24. package/dist/src/patch-coverage.mjs +65 -0
  25. package/dist/src/report.d.mts +4 -0
  26. package/dist/src/report.mjs +65 -0
  27. package/dist/src/rules.d.mts +4 -0
  28. package/dist/src/rules.mjs +30 -0
  29. package/dist/src/s3-suite-store.d.mts +28 -0
  30. package/dist/src/s3-suite-store.mjs +147 -0
  31. package/dist/src/s3-utils.d.mts +2 -0
  32. package/dist/src/s3-utils.mjs +14 -0
  33. package/dist/src/step-summary.d.mts +9 -0
  34. package/dist/src/step-summary.mjs +70 -0
  35. package/dist/src/store-factory.d.mts +11 -0
  36. package/dist/src/store-factory.mjs +23 -0
  37. package/dist/src/suite-store.d.mts +51 -0
  38. package/dist/src/suite-store.mjs +154 -0
  39. package/dist/src/types.d.mts +36 -0
  40. package/dist/src/types.mjs +1 -0
  41. package/package.json +20 -5
  42. package/bin/coverage-check.mts +0 -6
  43. package/src/cli.mts +0 -15
  44. package/src/cli.test.mts +0 -45
  45. package/src/commands/check.mts +0 -200
  46. package/src/commands/check.test.mts +0 -642
  47. package/src/commands/store-put.mts +0 -100
  48. package/src/commands/store-put.test.mts +0 -154
  49. package/src/diff-parser.mts +0 -127
  50. package/src/diff-parser.test.mts +0 -178
  51. package/src/github-comment.mts +0 -74
  52. package/src/github-comment.test.mts +0 -64
  53. package/src/lcov-merge.mts +0 -34
  54. package/src/lcov-merge.test.mts +0 -57
  55. package/src/lcov-parser.mts +0 -46
  56. package/src/lcov-parser.test.mts +0 -86
  57. package/src/load-artifacts.mts +0 -42
  58. package/src/load-artifacts.test.mts +0 -115
  59. package/src/patch-coverage.mts +0 -82
  60. package/src/patch-coverage.test.mts +0 -91
  61. package/src/report.mts +0 -87
  62. package/src/report.test.mts +0 -159
  63. package/src/rules.mts +0 -34
  64. package/src/rules.test.mts +0 -98
  65. package/src/suite-store.mts +0 -62
  66. package/src/suite-store.test.mts +0 -115
  67. package/src/types.mts +0 -43
@@ -0,0 +1,147 @@
1
+ import { GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
2
+ import { assertSafePathComponent, assertValidTimestamp, encodeBranchName, isNewerTimestamp, } from "./suite-store.mjs";
3
+ import { bodyToBuffer, isNotFound } from "./s3-utils.mjs";
4
+ export class S3SuiteStore {
5
+ bucket;
6
+ prefix;
7
+ client;
8
+ constructor({ bucket, prefix, region, client }) {
9
+ this.bucket = bucket;
10
+ this.prefix = prefix ? prefix.replace(/\/+$/, "") : "";
11
+ this.client = client ?? new S3Client({ region });
12
+ }
13
+ key(...parts) {
14
+ return this.prefix ? [this.prefix, ...parts].join("/") : parts.join("/");
15
+ }
16
+ async list() {
17
+ const pfx = this.prefix ? `${this.prefix}/` : "";
18
+ const suites = [];
19
+ let continuationToken;
20
+ do {
21
+ const resp = (await this.client.send(new ListObjectsV2Command({
22
+ Bucket: this.bucket,
23
+ Prefix: pfx,
24
+ Delimiter: "/",
25
+ ...(continuationToken ? { ContinuationToken: continuationToken } : {}),
26
+ })));
27
+ suites.push(...(resp.CommonPrefixes ?? [])
28
+ .map((cp) => cp.Prefix?.replace(pfx, "").replace(/\/$/, "") ?? "")
29
+ .filter(Boolean));
30
+ continuationToken = resp.IsTruncated ? resp.NextContinuationToken : undefined;
31
+ } while (continuationToken);
32
+ return suites;
33
+ }
34
+ async get(suite, opts) {
35
+ assertSafePathComponent(suite, "suite");
36
+ if (opts?.sha !== undefined)
37
+ assertSafePathComponent(opts.sha, "sha");
38
+ let sha = opts?.sha;
39
+ if (!sha) {
40
+ const branch = opts?.branch ?? "main";
41
+ try {
42
+ const pointer = await this.readPointer(suite, branch);
43
+ assertSafePathComponent(pointer.sha, "sha");
44
+ sha = pointer.sha;
45
+ }
46
+ catch (err) {
47
+ if (isNotFound(err))
48
+ return this.getLegacy(suite);
49
+ throw err;
50
+ }
51
+ }
52
+ try {
53
+ const resp = (await this.client.send(new GetObjectCommand({
54
+ Bucket: this.bucket,
55
+ Key: this.key(suite, "sha", sha, "lcov.info"),
56
+ })));
57
+ return bodyToBuffer(resp.Body);
58
+ }
59
+ catch (err) {
60
+ if (isNotFound(err))
61
+ return null;
62
+ throw err;
63
+ }
64
+ }
65
+ async put(suite, lcov, meta) {
66
+ assertSafePathComponent(suite, "suite");
67
+ if (meta === undefined) {
68
+ await this.client.send(new PutObjectCommand({
69
+ Bucket: this.bucket,
70
+ Key: this.key(suite, "lcov.info"),
71
+ Body: lcov,
72
+ ContentType: "text/plain",
73
+ }));
74
+ return;
75
+ }
76
+ const { sha, branch } = meta;
77
+ assertSafePathComponent(sha, "sha");
78
+ const ts = meta.timestamp ?? new Date().toISOString();
79
+ assertValidTimestamp(ts);
80
+ await this.client.send(new PutObjectCommand({
81
+ Bucket: this.bucket,
82
+ Key: this.key(suite, "sha", sha, "lcov.info"),
83
+ Body: lcov,
84
+ ContentType: "text/plain",
85
+ }));
86
+ if (!(await this.shouldWritePointer(suite, branch, ts)))
87
+ return;
88
+ await this.client.send(new PutObjectCommand({
89
+ Bucket: this.bucket,
90
+ Key: this.key(suite, "branch", encodeBranchName(branch), "latest.json"),
91
+ Body: Buffer.from(JSON.stringify({ sha, timestamp: ts }), "utf8"),
92
+ ContentType: "application/json",
93
+ }));
94
+ }
95
+ async getLegacy(suite) {
96
+ try {
97
+ const resp = (await this.client.send(new GetObjectCommand({
98
+ Bucket: this.bucket,
99
+ Key: this.key(suite, "lcov.info"),
100
+ })));
101
+ return bodyToBuffer(resp.Body);
102
+ }
103
+ catch (err) {
104
+ if (isNotFound(err))
105
+ return null;
106
+ throw err;
107
+ }
108
+ }
109
+ async shouldWritePointer(suite, branch, incomingTimestamp) {
110
+ try {
111
+ const resp = (await this.client.send(new GetObjectCommand({
112
+ Bucket: this.bucket,
113
+ Key: this.key(suite, "branch", encodeBranchName(branch), "latest.json"),
114
+ })));
115
+ if (resp.Body === undefined)
116
+ return true;
117
+ const body = await bodyToBuffer(resp.Body);
118
+ const current = JSON.parse(body.toString("utf8"));
119
+ return !isNewerTimestamp(current.timestamp, incomingTimestamp);
120
+ }
121
+ catch (err) {
122
+ if (isNotFound(err))
123
+ return true;
124
+ throw err;
125
+ }
126
+ }
127
+ async readPointer(suite, branch) {
128
+ const keys = [
129
+ this.key(suite, "branch", encodeBranchName(branch), "latest.json"),
130
+ this.key(suite, "branch", branch, "latest.json"),
131
+ ];
132
+ let lastNotFound;
133
+ for (const key of keys) {
134
+ try {
135
+ const resp = (await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })));
136
+ const body = await bodyToBuffer(resp.Body);
137
+ return JSON.parse(body.toString("utf8"));
138
+ }
139
+ catch (err) {
140
+ if (!isNotFound(err))
141
+ throw err;
142
+ lastNotFound = err;
143
+ }
144
+ }
145
+ throw lastNotFound;
146
+ }
147
+ }
@@ -0,0 +1,2 @@
1
+ export declare function isNotFound(err: unknown): boolean;
2
+ export declare function bodyToBuffer(body: unknown): Promise<Buffer>;
@@ -0,0 +1,14 @@
1
+ import { Readable } from "node:stream";
2
+ import { buffer } from "node:stream/consumers";
3
+ export function isNotFound(err) {
4
+ return err instanceof Error && (err.name === "NoSuchKey" || err.name === "NotFound");
5
+ }
6
+ export async function bodyToBuffer(body) {
7
+ if (body instanceof Readable)
8
+ return buffer(body);
9
+ if (body instanceof Uint8Array)
10
+ return Buffer.from(body);
11
+ if (body instanceof Blob)
12
+ return Buffer.from(await body.arrayBuffer());
13
+ throw new Error("unexpected S3 response body type");
14
+ }
@@ -0,0 +1,9 @@
1
+ import type { LcovData } from "./types.mts";
2
+ import type { CoverageCheckResult } from "./types.mts";
3
+ export type SuiteSource = {
4
+ suite: string;
5
+ source: "fresh" | "store";
6
+ lcov: LcovData;
7
+ };
8
+ export declare function buildSummaryMarkdown(suiteSources: SuiteSource[], result: CoverageCheckResult, runUrl: string, branch?: string): string;
9
+ export declare function writeSummary(summaryFile: string, suiteSources: SuiteSource[], result: CoverageCheckResult, runUrl: string, branch?: string): void;
@@ -0,0 +1,70 @@
1
+ import { appendFileSync } from "node:fs";
2
+ function suiteTotals(lcov) {
3
+ let hit = 0;
4
+ let total = 0;
5
+ for (const lines of lcov.values()) {
6
+ for (const count of lines.values()) {
7
+ total++;
8
+ if (count > 0)
9
+ hit++;
10
+ }
11
+ }
12
+ return { hit, total };
13
+ }
14
+ function pctStr(hit, total) {
15
+ if (total === 0)
16
+ return "—";
17
+ return `${((hit / total) * 100).toFixed(1)}% (${hit}/${total})`;
18
+ }
19
+ function escMd(s) {
20
+ return s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
21
+ }
22
+ function codeSpan(s) {
23
+ const escaped = escMd(s);
24
+ const longestRun = Math.max(0, ...Array.from(escaped.matchAll(/`+/g), (m) => m[0].length));
25
+ if (longestRun === 0)
26
+ return `\`${escaped}\``;
27
+ const ticks = "`".repeat(longestRun + 1);
28
+ return `${ticks} ${escaped} ${ticks}`;
29
+ }
30
+ export function buildSummaryMarkdown(suiteSources, result, runUrl, branch = "main") {
31
+ const suiteRows = suiteSources
32
+ .map(({ suite, source, lcov }) => {
33
+ const { hit, total } = suiteTotals(lcov);
34
+ const sourceLabel = source === "fresh" ? "fresh" : `store (${escMd(branch)})`;
35
+ return `| ${codeSpan(suite)} | ${sourceLabel} | ${pctStr(hit, total)} |`;
36
+ })
37
+ .join("\n");
38
+ const suiteTable = [
39
+ "| Suite | Source | Line coverage |",
40
+ "|---|---|---|",
41
+ suiteRows || "| — | — | — |",
42
+ ].join("\n");
43
+ const ruleRows = result.buckets
44
+ .map((b) => {
45
+ const status = b.passed ? "✅" : "❌";
46
+ const pct = b.coverable > 0 ? `${((b.hit / b.coverable) * 100).toFixed(1)}%` : "—";
47
+ return `| ${codeSpan(b.rule)} | ${b.threshold}% | ${pct} | ${status} |`;
48
+ })
49
+ .join("\n");
50
+ const ruleTable = [
51
+ "| Rule | Threshold | Patch coverage | Status |",
52
+ "|---|---|---|---|",
53
+ ruleRows || "| — | — | — | — |",
54
+ ].join("\n");
55
+ const overall = result.passed ? "✅ passed" : "❌ failed";
56
+ const runLink = runUrl !== "N/A" ? `\n\n_[View run](${runUrl})_` : "";
57
+ return `## Coverage summary — ${overall}
58
+
59
+ ### Suite totals
60
+
61
+ ${suiteTable}
62
+
63
+ ### Patch coverage
64
+
65
+ ${ruleTable}${runLink}
66
+ `;
67
+ }
68
+ export function writeSummary(summaryFile, suiteSources, result, runUrl, branch) {
69
+ appendFileSync(summaryFile, buildSummaryMarkdown(suiteSources, result, runUrl, branch), "utf8");
70
+ }
@@ -0,0 +1,11 @@
1
+ import type { SuiteStore } from "./suite-store.mts";
2
+ /** Parse "bucket/prefix" or "bucket" into S3SuiteStore constructor args. */
3
+ export declare function parseS3Spec(spec: string): {
4
+ bucket: string;
5
+ prefix?: string;
6
+ };
7
+ /** Build a SuiteStore from CLI flag values. Returns null if neither is set. */
8
+ export declare function makeStore(opts: {
9
+ fs?: string | null;
10
+ s3?: string | null;
11
+ }): SuiteStore | null;
@@ -0,0 +1,23 @@
1
+ import { FileSystemSuiteStore } from "./suite-store.mjs";
2
+ import { S3SuiteStore } from "./s3-suite-store.mjs";
3
+ /** Parse "bucket/prefix" or "bucket" into S3SuiteStore constructor args. */
4
+ export function parseS3Spec(spec) {
5
+ const slash = spec.indexOf("/");
6
+ const bucket = slash === -1 ? spec : spec.slice(0, slash);
7
+ if (!bucket)
8
+ throw new Error(`invalid S3 spec ${JSON.stringify(spec)}: bucket must not be empty`);
9
+ if (slash === -1)
10
+ return { bucket };
11
+ const prefix = spec.slice(slash + 1).replace(/^\/+|\/+$/g, "");
12
+ return prefix ? { bucket, prefix } : { bucket };
13
+ }
14
+ /** Build a SuiteStore from CLI flag values. Returns null if neither is set. */
15
+ export function makeStore(opts) {
16
+ if (opts.s3 != null) {
17
+ const { bucket, prefix } = parseS3Spec(opts.s3);
18
+ return new S3SuiteStore({ bucket, prefix });
19
+ }
20
+ if (opts.fs != null)
21
+ return new FileSystemSuiteStore(opts.fs);
22
+ return null;
23
+ }
@@ -0,0 +1,51 @@
1
+ import type { SuiteMeta } from "./types.mts";
2
+ export declare function assertSafePathComponent(value: string, label: string): void;
3
+ export type { SuiteMeta };
4
+ export type SuitePutMeta = {
5
+ sha: string;
6
+ branch: string;
7
+ timestamp?: string;
8
+ };
9
+ export declare function encodeBranchName(branch: string): string;
10
+ export declare function decodeBranchName(encoded: string): string;
11
+ export interface SuiteStore {
12
+ /** Returns all suite names currently in the store. */
13
+ list(): Promise<string[]>;
14
+ /**
15
+ * Returns the merged LCOV bytes for a suite, or null if absent.
16
+ * Resolves by sha if opts.sha is set; otherwise follows the branch pointer
17
+ * (opts.branch, defaulting to "main").
18
+ */
19
+ get(suite: string, opts?: {
20
+ sha?: string;
21
+ branch?: string;
22
+ }): Promise<Buffer | null>;
23
+ /** Stores the merged LCOV bytes for a suite. sha and branch enable pointer storage. */
24
+ put(suite: string, lcov: Buffer, meta?: SuitePutMeta): Promise<void>;
25
+ }
26
+ /**
27
+ * Filesystem-backed SuiteStore.
28
+ *
29
+ * Layout:
30
+ * <root>/<suite>/sha/<sha>/lcov.info — LCOV payload
31
+ * <root>/<suite>/branch/<encoded-branch>/latest.json — { sha, timestamp }
32
+ *
33
+ * Legacy layout is still readable/writable when no sha/branch metadata is given:
34
+ * <root>/<suite>/lcov.info
35
+ *
36
+ * Transport is the caller's responsibility (e.g. S3 sync, git orphan branch).
37
+ */
38
+ export declare class FileSystemSuiteStore implements SuiteStore {
39
+ private readonly root;
40
+ constructor(root: string);
41
+ list(): Promise<string[]>;
42
+ get(suite: string, opts?: {
43
+ sha?: string;
44
+ branch?: string;
45
+ }): Promise<Buffer | null>;
46
+ put(suite: string, lcov: Buffer, meta?: SuitePutMeta): Promise<void>;
47
+ private getLegacy;
48
+ private shouldWritePointer;
49
+ }
50
+ export declare function isNewerTimestamp(current: string | undefined, incoming: string): boolean;
51
+ export declare function assertValidTimestamp(timestamp: string): void;
@@ -0,0 +1,154 @@
1
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export function assertSafePathComponent(value, label) {
4
+ if (typeof value !== "string" ||
5
+ value.length === 0 ||
6
+ value === "." ||
7
+ value === ".." ||
8
+ value.includes("/") ||
9
+ value.includes("\\")) {
10
+ throw new Error(`invalid ${label}: ${JSON.stringify(value)}`);
11
+ }
12
+ }
13
+ export function encodeBranchName(branch) {
14
+ if (typeof branch !== "string" || branch.length === 0) {
15
+ throw new Error(`invalid branch: ${JSON.stringify(branch)}`);
16
+ }
17
+ return Buffer.from(branch, "utf8").toString("base64url");
18
+ }
19
+ export function decodeBranchName(encoded) {
20
+ assertSafePathComponent(encoded, "branch");
21
+ return Buffer.from(encoded, "base64url").toString("utf8");
22
+ }
23
+ /**
24
+ * Filesystem-backed SuiteStore.
25
+ *
26
+ * Layout:
27
+ * <root>/<suite>/sha/<sha>/lcov.info — LCOV payload
28
+ * <root>/<suite>/branch/<encoded-branch>/latest.json — { sha, timestamp }
29
+ *
30
+ * Legacy layout is still readable/writable when no sha/branch metadata is given:
31
+ * <root>/<suite>/lcov.info
32
+ *
33
+ * Transport is the caller's responsibility (e.g. S3 sync, git orphan branch).
34
+ */
35
+ export class FileSystemSuiteStore {
36
+ root;
37
+ constructor(root) {
38
+ this.root = root;
39
+ }
40
+ async list() {
41
+ try {
42
+ return readdirSync(this.root, { withFileTypes: true })
43
+ .filter((e) => e.isDirectory())
44
+ .map((e) => e.name);
45
+ }
46
+ catch (err) {
47
+ if (err.code === "ENOENT")
48
+ return [];
49
+ throw err;
50
+ }
51
+ }
52
+ async get(suite, opts) {
53
+ assertSafePathComponent(suite, "suite");
54
+ if (opts?.sha !== undefined)
55
+ assertSafePathComponent(opts.sha, "sha");
56
+ let sha = opts?.sha;
57
+ if (!sha) {
58
+ const branch = opts?.branch ?? "main";
59
+ const pointerPaths = [
60
+ join(this.root, suite, "branch", encodeBranchName(branch), "latest.json"),
61
+ join(this.root, suite, "branch", branch, "latest.json"),
62
+ ];
63
+ try {
64
+ const pointer = readPointerFile(pointerPaths);
65
+ assertSafePathComponent(pointer.sha, "sha");
66
+ sha = pointer.sha;
67
+ }
68
+ catch (err) {
69
+ if (err.code === "ENOENT")
70
+ return this.getLegacy(suite);
71
+ throw err;
72
+ }
73
+ }
74
+ const lcovPath = join(this.root, suite, "sha", sha, "lcov.info");
75
+ try {
76
+ return readFileSync(lcovPath);
77
+ }
78
+ catch (err) {
79
+ if (err.code === "ENOENT")
80
+ return null;
81
+ throw err;
82
+ }
83
+ }
84
+ async put(suite, lcov, meta) {
85
+ assertSafePathComponent(suite, "suite");
86
+ if (meta === undefined) {
87
+ const suiteDir = join(this.root, suite);
88
+ mkdirSync(suiteDir, { recursive: true });
89
+ writeFileSync(join(suiteDir, "lcov.info"), lcov);
90
+ return;
91
+ }
92
+ const { sha, branch } = meta;
93
+ assertSafePathComponent(sha, "sha");
94
+ const shaDir = join(this.root, suite, "sha", sha);
95
+ mkdirSync(shaDir, { recursive: true });
96
+ writeFileSync(join(shaDir, "lcov.info"), lcov);
97
+ const branchDir = join(this.root, suite, "branch", encodeBranchName(branch));
98
+ mkdirSync(branchDir, { recursive: true });
99
+ const pointerPath = join(branchDir, "latest.json");
100
+ const timestamp = meta.timestamp ?? new Date().toISOString();
101
+ assertValidTimestamp(timestamp);
102
+ if (!this.shouldWritePointer(pointerPath, timestamp))
103
+ return;
104
+ writeFileSync(pointerPath, JSON.stringify({ sha, timestamp }, null, 2));
105
+ }
106
+ getLegacy(suite) {
107
+ try {
108
+ return readFileSync(join(this.root, suite, "lcov.info"));
109
+ }
110
+ catch (err) {
111
+ if (err.code === "ENOENT")
112
+ return null;
113
+ throw err;
114
+ }
115
+ }
116
+ shouldWritePointer(pointerPath, incomingTimestamp) {
117
+ try {
118
+ const current = JSON.parse(readFileSync(pointerPath, "utf8"));
119
+ return !isNewerTimestamp(current.timestamp, incomingTimestamp);
120
+ }
121
+ catch (err) {
122
+ if (err.code === "ENOENT")
123
+ return true;
124
+ throw err;
125
+ }
126
+ }
127
+ }
128
+ export function isNewerTimestamp(current, incoming) {
129
+ assertValidTimestamp(incoming);
130
+ if (!current)
131
+ return false;
132
+ const currentMs = Date.parse(current);
133
+ const incomingMs = Date.parse(incoming);
134
+ return Number.isFinite(currentMs) && Number.isFinite(incomingMs) && currentMs > incomingMs;
135
+ }
136
+ export function assertValidTimestamp(timestamp) {
137
+ if (!Number.isFinite(Date.parse(timestamp))) {
138
+ throw new Error(`invalid timestamp: ${JSON.stringify(timestamp)}`);
139
+ }
140
+ }
141
+ function readPointerFile(paths) {
142
+ let lastNotFound = null;
143
+ for (const path of paths) {
144
+ try {
145
+ return JSON.parse(readFileSync(path, "utf8"));
146
+ }
147
+ catch (err) {
148
+ if (err.code !== "ENOENT")
149
+ throw err;
150
+ lastNotFound = err;
151
+ }
152
+ }
153
+ throw lastNotFound;
154
+ }
@@ -0,0 +1,36 @@
1
+ export type CoverageRule = {
2
+ paths: string;
3
+ patch_coverage_min: number;
4
+ };
5
+ export type CoverageRules = {
6
+ rules: CoverageRule[];
7
+ };
8
+ /** Map from repo-root-relative file path to map of line number → hit count. */
9
+ export type LcovData = Map<string, Map<number, number>>;
10
+ /** Map from repo-root-relative file path to set of added/modified line numbers. */
11
+ export type DiffLines = Map<string, Set<number>>;
12
+ export type FileCoverageResult = {
13
+ file: string;
14
+ coverable: number;
15
+ hit: number;
16
+ uncoveredLines: number[];
17
+ rule: string | null;
18
+ };
19
+ export type BucketResult = {
20
+ rule: string;
21
+ threshold: number;
22
+ coverable: number;
23
+ hit: number;
24
+ files: FileCoverageResult[];
25
+ passed: boolean;
26
+ };
27
+ export type CoverageCheckResult = {
28
+ buckets: BucketResult[];
29
+ informational: FileCoverageResult[];
30
+ passed: boolean;
31
+ };
32
+ export type SuiteMeta = {
33
+ sha?: string;
34
+ branch?: string;
35
+ timestamp?: string;
36
+ };
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,16 +1,29 @@
1
1
  {
2
2
  "name": "coverage-check",
3
- "version": "0.1.1",
3
+ "version": "0.2.2",
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",
7
7
  "type": "module",
8
8
  "bin": {
9
- "coverage-check": "./bin/coverage-check.mts"
9
+ "coverage-check": "./bin/coverage-check.mjs"
10
10
  },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/src/coverage-check.d.mts",
14
+ "import": "./dist/src/coverage-check.mjs"
15
+ },
16
+ "./src/*.mts": {
17
+ "types": "./dist/src/*.d.mts",
18
+ "import": "./dist/src/*.mjs"
19
+ },
20
+ "./dist/*": "./dist/*",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "types": "./dist/src/coverage-check.d.mts",
11
24
  "files": [
12
- "bin/**",
13
- "src/**",
25
+ "dist/**",
26
+ "bin/coverage-check.mjs",
14
27
  "README.md",
15
28
  "LICENSE"
16
29
  ],
@@ -18,6 +31,7 @@
18
31
  "node": ">=22.0.0"
19
32
  },
20
33
  "dependencies": {
34
+ "@aws-sdk/client-s3": "^3.1048.0",
21
35
  "js-yaml": "^4.1.1"
22
36
  },
23
37
  "devDependencies": {
@@ -32,7 +46,8 @@
32
46
  },
33
47
  "scripts": {
34
48
  "prepare": "husky",
35
- "prepublishOnly": "npm run typecheck && npm test",
49
+ "build": "tsc --project tsconfig.build.json",
50
+ "prepublishOnly": "npm run build && npm run typecheck && npm test",
36
51
  "typecheck": "tsc --noEmit",
37
52
  "lint": "oxlint src/ bin/",
38
53
  "format": "oxfmt src/ bin/ README.md",
@@ -1,6 +0,0 @@
1
- #!/usr/bin/env -S node --experimental-strip-types --no-warnings
2
-
3
- import { main } from "../src/cli.mts";
4
-
5
- /* c8 ignore next */
6
- process.exit(await main(process.argv.slice(2)));
package/src/cli.mts DELETED
@@ -1,15 +0,0 @@
1
- import { main as checkMain } from "./commands/check.mts";
2
- import { main as storePutMain } from "./commands/store-put.mts";
3
-
4
- const stderr = (msg: string) => process.stderr.write(`${msg}\n`);
5
-
6
- export async function main(argv: string[]): Promise<number> {
7
- const sub = argv[0];
8
-
9
- if (!sub || sub.startsWith("-")) return checkMain(argv);
10
- if (sub === "check") return checkMain(argv.slice(1));
11
- if (sub === "store-put") return storePutMain(argv.slice(1));
12
-
13
- stderr(`coverage-check: unknown subcommand: ${JSON.stringify(sub)}`);
14
- return 2;
15
- }
package/src/cli.test.mts DELETED
@@ -1,45 +0,0 @@
1
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { tmpdir } from "node:os";
4
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
- import { main } from "./cli.mts";
6
-
7
- describe("cli subcommand dispatch", () => {
8
- let tmpDir: string;
9
- let rulesPath: string;
10
- let artifactsDir: string;
11
-
12
- beforeEach(() => {
13
- tmpDir = mkdtempSync(join(tmpdir(), "cli-dispatch-"));
14
- rulesPath = join(tmpDir, "rules.yml");
15
- artifactsDir = join(tmpDir, "artifacts");
16
- mkdirSync(artifactsDir);
17
- writeFileSync(rulesPath, "rules:\n - paths: backend/**\n patch_coverage_min: 90\n");
18
- });
19
-
20
- afterEach(() => {
21
- rmSync(tmpDir, { recursive: true, force: true });
22
- });
23
-
24
- it("defaults to check when no args given", async () => {
25
- // No args → check → no lcov files → returns 0
26
- expect(await main(["--rules", rulesPath, "--artifacts", artifactsDir])).toBe(0);
27
- });
28
-
29
- it("explicit check subcommand works", async () => {
30
- expect(await main(["check", "--rules", rulesPath, "--artifacts", artifactsDir])).toBe(0);
31
- });
32
-
33
- it("explicit store-put subcommand returns 2 when --suite is missing", async () => {
34
- expect(await main(["store-put", "--store", "/tmp/store"])).toBe(2);
35
- });
36
-
37
- it("returns 2 for unknown subcommand", async () => {
38
- expect(await main(["unknown-command"])).toBe(2);
39
- });
40
-
41
- it("flags-first argument (starting with --) goes to check", async () => {
42
- // '--rules' starts with '-', so dispatch goes to check
43
- expect(await main(["--rules", rulesPath, "--artifacts", artifactsDir])).toBe(0);
44
- });
45
- });