coverage-check 0.2.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.
- package/README.md +9 -5
- package/bin/coverage-check.mjs +4 -0
- package/dist/src/cli.d.mts +1 -0
- package/dist/src/cli.mjs +14 -0
- package/dist/src/commands/check-args.d.mts +20 -0
- package/dist/src/commands/check-args.mjs +89 -0
- package/dist/src/commands/check.d.mts +4 -0
- package/dist/src/commands/check.mjs +128 -0
- package/dist/src/commands/store-put.d.mts +11 -0
- package/dist/src/commands/store-put.mjs +104 -0
- package/{src/coverage-check.mts → dist/src/coverage-check.d.mts} +1 -9
- package/dist/src/coverage-check.mjs +4 -0
- package/dist/src/diff-parser.d.mts +17 -0
- package/dist/src/diff-parser.mjs +127 -0
- package/dist/src/github-comment.d.mts +9 -0
- package/dist/src/github-comment.mjs +66 -0
- package/dist/src/lcov-merge.d.mts +5 -0
- package/dist/src/lcov-merge.mjs +29 -0
- package/dist/src/lcov-parser.d.mts +8 -0
- package/dist/src/lcov-parser.mjs +44 -0
- package/dist/src/load-artifacts.d.mts +9 -0
- package/dist/src/load-artifacts.mjs +41 -0
- package/dist/src/patch-coverage.d.mts +5 -0
- package/dist/src/patch-coverage.mjs +65 -0
- package/dist/src/report.d.mts +4 -0
- package/dist/src/report.mjs +65 -0
- package/dist/src/rules.d.mts +4 -0
- package/dist/src/rules.mjs +30 -0
- package/dist/src/s3-suite-store.d.mts +28 -0
- package/dist/src/s3-suite-store.mjs +147 -0
- package/dist/src/s3-utils.d.mts +2 -0
- package/dist/src/s3-utils.mjs +14 -0
- package/dist/src/step-summary.d.mts +9 -0
- package/dist/src/step-summary.mjs +70 -0
- package/dist/src/store-factory.d.mts +11 -0
- package/dist/src/store-factory.mjs +23 -0
- package/dist/src/suite-store.d.mts +51 -0
- package/dist/src/suite-store.mjs +154 -0
- package/dist/src/types.d.mts +36 -0
- package/dist/src/types.mjs +1 -0
- package/package.json +19 -5
- package/bin/coverage-check.mts +0 -6
- package/src/cli.mts +0 -15
- package/src/cli.test.mts +0 -45
- package/src/commands/check-args.mts +0 -110
- package/src/commands/check.mts +0 -147
- package/src/commands/check.test.mts +0 -870
- package/src/commands/store-put.mts +0 -115
- package/src/commands/store-put.test.mts +0 -248
- package/src/diff-parser.mts +0 -127
- package/src/diff-parser.test.mts +0 -178
- package/src/github-comment.mts +0 -79
- package/src/github-comment.test.mts +0 -63
- package/src/lcov-merge.mts +0 -34
- package/src/lcov-merge.test.mts +0 -57
- package/src/lcov-parser.mts +0 -46
- package/src/lcov-parser.test.mts +0 -86
- package/src/load-artifacts.mts +0 -42
- package/src/load-artifacts.test.mts +0 -115
- package/src/patch-coverage.mts +0 -82
- package/src/patch-coverage.test.mts +0 -91
- package/src/report.mts +0 -78
- package/src/report.test.mts +0 -142
- package/src/rules.mts +0 -34
- package/src/rules.test.mts +0 -98
- package/src/s3-suite-store.mts +0 -138
- package/src/s3-suite-store.test.mts +0 -308
- package/src/step-summary.mts +0 -89
- package/src/step-summary.test.mts +0 -189
- package/src/store-factory.mts +0 -23
- package/src/store-factory.test.mts +0 -67
- package/src/suite-store.mts +0 -112
- package/src/suite-store.test.mts +0 -209
- package/src/types.mts +0 -43
|
@@ -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.2.
|
|
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.
|
|
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
|
-
"
|
|
13
|
-
"
|
|
25
|
+
"dist/**",
|
|
26
|
+
"bin/coverage-check.mjs",
|
|
14
27
|
"README.md",
|
|
15
28
|
"LICENSE"
|
|
16
29
|
],
|
|
@@ -33,7 +46,8 @@
|
|
|
33
46
|
},
|
|
34
47
|
"scripts": {
|
|
35
48
|
"prepare": "husky",
|
|
36
|
-
"
|
|
49
|
+
"build": "tsc --project tsconfig.build.json",
|
|
50
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm test",
|
|
37
51
|
"typecheck": "tsc --noEmit",
|
|
38
52
|
"lint": "oxlint src/ bin/",
|
|
39
53
|
"format": "oxfmt src/ bin/ README.md",
|
package/bin/coverage-check.mts
DELETED
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
|
-
});
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { makeStore } from "../store-factory.mts";
|
|
2
|
-
import { assertSafePathComponent } from "../suite-store.mts";
|
|
3
|
-
import type { SuiteStore } from "../suite-store.mts";
|
|
4
|
-
import type { GhRunner } from "../github-comment.mts";
|
|
5
|
-
|
|
6
|
-
export type CheckArgs = {
|
|
7
|
-
rules: string;
|
|
8
|
-
artifacts: string;
|
|
9
|
-
base: string;
|
|
10
|
-
head: string;
|
|
11
|
-
pr: number | null;
|
|
12
|
-
repo: string;
|
|
13
|
-
json: string | null;
|
|
14
|
-
stripPrefixes: string[];
|
|
15
|
-
store: SuiteStore | null;
|
|
16
|
-
suite: string | null;
|
|
17
|
-
/** Branch used to resolve baseline from the store. Default: "main". */
|
|
18
|
-
branch?: string;
|
|
19
|
-
gh?: GhRunner;
|
|
20
|
-
/** Path to append the GitHub step summary. Default: $GITHUB_STEP_SUMMARY. */
|
|
21
|
-
summaryFile?: string | null;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export function parseCheckArgs(argv: string[]): CheckArgs {
|
|
25
|
-
let storeFs: string | null = null;
|
|
26
|
-
let storeS3: string | null = null;
|
|
27
|
-
const args: Omit<CheckArgs, "store"> & { store: SuiteStore | null } = {
|
|
28
|
-
rules: ".coverage-rules.yml",
|
|
29
|
-
artifacts: "./coverage-artifacts",
|
|
30
|
-
base: "origin/main",
|
|
31
|
-
head: "HEAD",
|
|
32
|
-
pr: null,
|
|
33
|
-
repo: process.env["GITHUB_REPOSITORY"] ?? "",
|
|
34
|
-
json: null,
|
|
35
|
-
stripPrefixes: [],
|
|
36
|
-
store: null,
|
|
37
|
-
suite: null,
|
|
38
|
-
branch: "main",
|
|
39
|
-
summaryFile: process.env["GITHUB_STEP_SUMMARY"] ?? null,
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
for (let i = 0; i < argv.length; i++) {
|
|
43
|
-
const flag = argv[i]!;
|
|
44
|
-
const next = argv[i + 1];
|
|
45
|
-
const val = (): string => {
|
|
46
|
-
if (next === undefined || next.startsWith("--")) {
|
|
47
|
-
throw new Error(`${flag} requires a value`);
|
|
48
|
-
}
|
|
49
|
-
i++;
|
|
50
|
-
return next;
|
|
51
|
-
};
|
|
52
|
-
switch (flag) {
|
|
53
|
-
case "--rules":
|
|
54
|
-
args.rules = val();
|
|
55
|
-
break;
|
|
56
|
-
case "--artifacts":
|
|
57
|
-
args.artifacts = val();
|
|
58
|
-
break;
|
|
59
|
-
case "--base":
|
|
60
|
-
args.base = val();
|
|
61
|
-
break;
|
|
62
|
-
case "--head":
|
|
63
|
-
args.head = val();
|
|
64
|
-
break;
|
|
65
|
-
case "--repo":
|
|
66
|
-
args.repo = val();
|
|
67
|
-
break;
|
|
68
|
-
case "--json":
|
|
69
|
-
args.json = val();
|
|
70
|
-
break;
|
|
71
|
-
case "--suite": {
|
|
72
|
-
const s = val();
|
|
73
|
-
assertSafePathComponent(s, "suite");
|
|
74
|
-
args.suite = s;
|
|
75
|
-
break;
|
|
76
|
-
}
|
|
77
|
-
case "--strip-prefix":
|
|
78
|
-
args.stripPrefixes.push(val());
|
|
79
|
-
break;
|
|
80
|
-
case "--branch": {
|
|
81
|
-
const b = val();
|
|
82
|
-
assertSafePathComponent(b, "branch");
|
|
83
|
-
args.branch = b;
|
|
84
|
-
break;
|
|
85
|
-
}
|
|
86
|
-
case "--store":
|
|
87
|
-
case "--store-fs":
|
|
88
|
-
storeFs = val();
|
|
89
|
-
break;
|
|
90
|
-
case "--store-s3":
|
|
91
|
-
storeS3 = val();
|
|
92
|
-
break;
|
|
93
|
-
case "--pr": {
|
|
94
|
-
const raw = val();
|
|
95
|
-
if (!/^\d+$/.test(raw) || raw === "0")
|
|
96
|
-
throw new Error(`--pr must be a positive integer, got: ${JSON.stringify(raw)}`);
|
|
97
|
-
args.pr = parseInt(raw, 10);
|
|
98
|
-
break;
|
|
99
|
-
}
|
|
100
|
-
default:
|
|
101
|
-
throw new Error(`unknown flag: ${flag}`);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (storeFs && storeS3) throw new Error("--store-fs and --store-s3 are mutually exclusive");
|
|
106
|
-
if (args.pr !== null && args.repo.trim() === "")
|
|
107
|
-
throw new Error("--repo is required when --pr is set (or define GITHUB_REPOSITORY)");
|
|
108
|
-
args.store = makeStore({ fs: storeFs, s3: storeS3 });
|
|
109
|
-
return args;
|
|
110
|
-
}
|
package/src/commands/check.mts
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { parseLcov } from "../lcov-parser.mts";
|
|
3
|
-
import { mergeLcov } from "../lcov-merge.mts";
|
|
4
|
-
import { getChangedLines } from "../diff-parser.mts";
|
|
5
|
-
import { loadRules } from "../rules.mts";
|
|
6
|
-
import { computePatchCoverage } from "../patch-coverage.mts";
|
|
7
|
-
import { collapseRanges, renderFailureComment } from "../report.mts";
|
|
8
|
-
import { upsertComment } from "../github-comment.mts";
|
|
9
|
-
import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mts";
|
|
10
|
-
import { writeSummary } from "../step-summary.mts";
|
|
11
|
-
import { parseCheckArgs } from "./check-args.mts";
|
|
12
|
-
import type { CheckArgs } from "./check-args.mts";
|
|
13
|
-
import type { SuiteSource } from "../step-summary.mts";
|
|
14
|
-
import type { LcovData } from "../types.mts";
|
|
15
|
-
export type { CheckArgs } from "./check-args.mts";
|
|
16
|
-
|
|
17
|
-
const stdout = (msg: string) => process.stdout.write(`${msg}\n`);
|
|
18
|
-
const stderr = (msg: string) => process.stderr.write(`${msg}\n`);
|
|
19
|
-
|
|
20
|
-
export async function main(argv: string[]): Promise<number> {
|
|
21
|
-
let args: CheckArgs;
|
|
22
|
-
try {
|
|
23
|
-
args = parseCheckArgs(argv);
|
|
24
|
-
} catch (err) {
|
|
25
|
-
stderr(`coverage-check: ${String(err)}`);
|
|
26
|
-
return 2;
|
|
27
|
-
}
|
|
28
|
-
return runCheck(args);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function runCheck(args: CheckArgs): Promise<number> {
|
|
32
|
-
let rules;
|
|
33
|
-
try {
|
|
34
|
-
rules = loadRules(args.rules);
|
|
35
|
-
} catch (err) {
|
|
36
|
-
stderr(`coverage-check: failed to load rules: ${err}`);
|
|
37
|
-
return 2;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const branch = args.branch ?? "main";
|
|
41
|
-
const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
|
|
42
|
-
const reports: LcovData[] = [];
|
|
43
|
-
const suiteSources: SuiteSource[] = [];
|
|
44
|
-
|
|
45
|
-
if (args.store !== null) {
|
|
46
|
-
const suites = await args.store.list();
|
|
47
|
-
for (const suite of suites) {
|
|
48
|
-
if (suite === args.suite) continue;
|
|
49
|
-
const buf = await args.store.get(suite, { branch });
|
|
50
|
-
if (buf !== null) {
|
|
51
|
-
const lcov = parseLcov(buf.toString("utf8"), stripPrefixes);
|
|
52
|
-
reports.push(lcov);
|
|
53
|
-
suiteSources.push({ suite, source: "store", lcov });
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const lcovFiles = collectLcovFiles(args.artifacts);
|
|
59
|
-
const freshLcovs: LcovData[] = [];
|
|
60
|
-
for (const f of lcovFiles) {
|
|
61
|
-
const lcov = parseLcov(readFileSync(f, "utf8"), stripPrefixes);
|
|
62
|
-
reports.push(lcov);
|
|
63
|
-
freshLcovs.push(lcov);
|
|
64
|
-
}
|
|
65
|
-
if (freshLcovs.length > 0) {
|
|
66
|
-
suiteSources.push({
|
|
67
|
-
suite: args.suite ?? "(current)",
|
|
68
|
-
source: "fresh",
|
|
69
|
-
lcov: mergeLcov(freshLcovs),
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (reports.length === 0) {
|
|
74
|
-
stderr(`coverage-check: no coverage data found — skipping`);
|
|
75
|
-
return 0;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const lcov = mergeLcov(reports);
|
|
79
|
-
|
|
80
|
-
let diff;
|
|
81
|
-
try {
|
|
82
|
-
diff = await getChangedLines(args.base, args.head);
|
|
83
|
-
} catch (err) {
|
|
84
|
-
stderr(`coverage-check: git diff failed: ${err}`);
|
|
85
|
-
return 2;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const { buckets, informational } = computePatchCoverage(diff, lcov, rules);
|
|
89
|
-
const passed = buckets.every((b) => b.passed);
|
|
90
|
-
const result = { buckets, informational, passed };
|
|
91
|
-
|
|
92
|
-
if (args.json) {
|
|
93
|
-
writeFileSync(args.json, JSON.stringify(result, null, 2));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const runUrl =
|
|
97
|
-
process.env["GITHUB_SERVER_URL"] && process.env["GITHUB_RUN_ID"]
|
|
98
|
-
? `${process.env["GITHUB_SERVER_URL"]}/${args.repo}/actions/runs/${process.env["GITHUB_RUN_ID"]}`
|
|
99
|
-
: "N/A";
|
|
100
|
-
|
|
101
|
-
if (!passed) {
|
|
102
|
-
stdout("\ncoverage-check: FAILED\n");
|
|
103
|
-
for (const bucket of buckets.filter((b) => !b.passed)) {
|
|
104
|
-
/* c8 ignore next -- bucket.coverable is always > 0 by patch-coverage.mts L36 guard */
|
|
105
|
-
const pct =
|
|
106
|
-
bucket.coverable > 0 ? `${((bucket.hit / bucket.coverable) * 100).toFixed(1)}%` : "—";
|
|
107
|
-
stdout(
|
|
108
|
-
` ${bucket.rule}: ${pct} (${bucket.hit}/${bucket.coverable}) — threshold ${bucket.threshold}%`,
|
|
109
|
-
);
|
|
110
|
-
for (const file of bucket.files.filter((f) => f.uncoveredLines.length > 0)) {
|
|
111
|
-
stdout(` ${file.file}: ${collapseRanges(file.uncoveredLines)}`);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
} else {
|
|
115
|
-
stdout("\ncoverage-check: PASSED\n");
|
|
116
|
-
for (const bucket of buckets) {
|
|
117
|
-
/* c8 ignore next -- bucket.coverable is always > 0 by patch-coverage.mts L36 guard */
|
|
118
|
-
const pct =
|
|
119
|
-
bucket.coverable > 0 ? `${((bucket.hit / bucket.coverable) * 100).toFixed(1)}%` : "—";
|
|
120
|
-
stdout(` ${bucket.rule}: ${pct} ✓`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const summaryFile =
|
|
125
|
-
args.summaryFile !== undefined
|
|
126
|
-
? args.summaryFile
|
|
127
|
-
: (process.env["GITHUB_STEP_SUMMARY"] ?? null);
|
|
128
|
-
if (summaryFile) {
|
|
129
|
-
try {
|
|
130
|
-
writeSummary(summaryFile, suiteSources, result, runUrl, branch);
|
|
131
|
-
} catch (err) {
|
|
132
|
-
stderr(`coverage-check: failed to write step summary: ${err}`);
|
|
133
|
-
return 2;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (args.pr !== null && args.repo) {
|
|
138
|
-
const body = passed ? "" : renderFailureComment(result, runUrl);
|
|
139
|
-
try {
|
|
140
|
-
await upsertComment(body, args.repo, args.pr, passed, args.gh);
|
|
141
|
-
} catch (err) {
|
|
142
|
-
stderr(`coverage-check: failed to post PR comment: ${err}`);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return passed ? 0 : 1;
|
|
147
|
-
}
|