@avi2dg/checks 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,8 +5,9 @@ Deterministic checks shared across my TypeScript repos. One package,
5
5
  Effect language-service block, the shared commitlint config, the shared
6
6
  dependency-cruiser base, the test-layout check with its bunfig preset,
7
7
  the commit-identity check with its workflow, the comment gate with its
8
- workflow and backtest, and the Effect error-channel plugin compiled to
9
- JavaScript.
8
+ workflow and backtest, the Stryker mutation-testing preset with its
9
+ no-regression comparator, and the Effect error-channel plugin compiled
10
+ to JavaScript.
10
11
 
11
12
  Published as `@avi2dg/checks` on the public npm registry.
12
13
 
@@ -64,6 +65,18 @@ cp node_modules/@avi2dg/checks/bunfig.toml bunfig.toml
64
65
  `commit-identity.ts` refuses a commit with an author other than the
65
66
  repository owner; see "Commit identity" below.
66
67
 
68
+ A repo that runs mutation testing installs `@stryker-mutator/core` and
69
+ `@hughescr/stryker-bun-runner`, then spreads the shipped preset in
70
+ `stryker.conf.mjs`:
71
+
72
+ ```js
73
+ import preset from "@avi2dg/checks/stryker.preset.js";
74
+
75
+ export default {
76
+ ...preset,
77
+ };
78
+ ```
79
+
67
80
  The registry version is pinned by the consumer's lockfile; bump
68
81
  `@avi2dg/checks` to adopt a new release.
69
82
 
@@ -277,6 +290,52 @@ comment text as a share of added lines.
277
290
  `generated/`, `vendor/`, `repos/`, `node_modules/` and `dist/` are out
278
291
  of reach, so the figures are authored code.
279
292
 
293
+ ## Mutation compare
294
+
295
+ `checks-mutation-compare` gates a pull request on no-regression rather
296
+ than an absolute threshold: the head mutation score may not fall below
297
+ the score at the merge-base.
298
+
299
+ ```sh
300
+ checks-mutation-compare <base-report> <head-report> [--advisory]
301
+ ```
302
+
303
+ Both reports are Stryker `mutation.json` files. It prints the overall
304
+ score of each report and the per-file scores that differ, and exits 1
305
+ when the head score is below the base score. The score is Stryker's:
306
+ `Killed` and `Timeout` over those plus `Survived` and `NoCoverage`, so
307
+ `CompileError`, `RuntimeError`, `Ignored` and `Pending` mutants leave
308
+ it. Every file in a report counts toward that report's score, including
309
+ files present in only one of the two.
310
+
311
+ `--advisory` prints the same verdict and always exits 0. That is how
312
+ consumers run it for the first month; after that they drop the flag and
313
+ it blocks.
314
+
315
+ Enforcement runs on pull requests, comparing the head report against a
316
+ report built at the merge-base:
317
+
318
+ ```yaml
319
+ jobs:
320
+ mutation-compare:
321
+ runs-on: ubuntu-latest
322
+ steps:
323
+ - uses: actions/checkout@v5
324
+ with:
325
+ fetch-depth: 0
326
+ - uses: oven-sh/setup-bun@v2
327
+ - run: bun install --frozen-lockfile
328
+ - run: bunx stryker run
329
+ - run: |
330
+ base="$(git merge-base HEAD origin/main)"
331
+ git worktree add /tmp/mutation-base "$base"
332
+ (cd /tmp/mutation-base && bun install --frozen-lockfile && bunx stryker run)
333
+ - run: bun run checks-mutation-compare --advisory /tmp/mutation-base/reports/mutation/mutation.json reports/mutation/mutation.json
334
+ ```
335
+
336
+ The shared Stryker preset's `json` reporter writes
337
+ `reports/mutation/mutation.json` in each worktree.
338
+
280
339
  ## Why it is shaped this way
281
340
 
282
341
  - `plugins` does not inherit through oxlint `extends`. `rules`,
@@ -307,6 +366,10 @@ of reach, so the figures are authored code.
307
366
  consumer's copy is compared key by key against the installed one
308
367
  instead. `[test] pathIgnorePatterns` is a real bunfig key, and an empty
309
368
  `--path-ignore-patterns` flag overrides the file's own list.
369
+ - The Stryker preset is a JavaScript module, not JSON: Stryker 10 does
370
+ not resolve `extends` in a JSON config, but a `.mjs` config that
371
+ spreads an imported object consumes it. Keys the consumer sets after
372
+ the spread win.
310
373
  - Each runnable script ships a `checks-` bin entry, so consumer
311
374
  `package.json` scripts call the short name, which the package manager
312
375
  puts on `PATH` only there; a shell runs it through `bun run`, which
@@ -346,7 +409,7 @@ a tag off `main` and reruns the build, `dist/` check, lint, typecheck
346
409
  and tests before it publishes:
347
410
 
348
411
  ```sh
349
- git tag v0.2.0 && git push origin v0.2.0
412
+ git tag v0.3.0 && git push origin v0.3.0
350
413
  ```
351
414
 
352
415
  The `release` workflow publishes the tagged version through npm
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Deterministic checks shared across the captain's TypeScript repos",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,10 +18,12 @@
18
18
  "scripts/lint-coverage.sh",
19
19
  "scripts/test-layout.ts",
20
20
  "scripts/commit-identity.ts",
21
+ "scripts/mutation-compare.ts",
21
22
  "scripts/comments.ts",
22
23
  "scripts/comment-gate.ts",
23
24
  "scripts/backtest.ts",
24
25
  "oxlintrc.json",
26
+ "stryker.preset.js",
25
27
  "tsconfig.effect.json",
26
28
  "dist/index.js"
27
29
  ],
@@ -32,10 +34,12 @@
32
34
  "./scripts/lint-coverage.sh": "./scripts/lint-coverage.sh",
33
35
  "./scripts/test-layout.ts": "./scripts/test-layout.ts",
34
36
  "./scripts/commit-identity.ts": "./scripts/commit-identity.ts",
37
+ "./scripts/mutation-compare.ts": "./scripts/mutation-compare.ts",
35
38
  "./scripts/comments.ts": "./scripts/comments.ts",
36
39
  "./scripts/comment-gate.ts": "./scripts/comment-gate.ts",
37
40
  "./scripts/backtest.ts": "./scripts/backtest.ts",
38
41
  "./oxlintrc.json": "./oxlintrc.json",
42
+ "./stryker.preset.js": "./stryker.preset.js",
39
43
  "./tsconfig.effect.json": "./tsconfig.effect.json",
40
44
  "./dist/index.js": "./dist/index.js"
41
45
  },
@@ -43,12 +47,13 @@
43
47
  "checks-lint-coverage": "scripts/lint-coverage.sh",
44
48
  "checks-test-layout": "scripts/test-layout.ts",
45
49
  "checks-commit-identity": "scripts/commit-identity.ts",
50
+ "checks-mutation-compare": "scripts/mutation-compare.ts",
46
51
  "checks-comment-gate": "scripts/comment-gate.ts",
47
52
  "checks-backtest": "scripts/backtest.ts"
48
53
  },
49
54
  "scripts": {
50
55
  "build": "bun build effect-channel/index.ts --outdir dist --target node --format esm",
51
- "lint": "oxlint --type-aware && ./scripts/lint-coverage.sh && bun scripts/test-layout.ts && bun scripts/commit-identity.ts HEAD && bun scripts/comment-gate.ts HEAD && depcruise --config .dependency-cruiser.cjs effect-channel scripts tests commitlint.config.js dependency-cruiser.config.js .dependency-cruiser.cjs",
56
+ "lint": "oxlint --type-aware && ./scripts/lint-coverage.sh && bun scripts/test-layout.ts && bun scripts/commit-identity.ts HEAD && bun scripts/comment-gate.ts HEAD && depcruise --config .dependency-cruiser.cjs effect-channel scripts tests commitlint.config.js dependency-cruiser.config.js stryker.preset.js .dependency-cruiser.cjs",
52
57
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
53
58
  "test": "bun test --randomize"
54
59
  },
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env bun
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ export type Tally = {
5
+ readonly killed: number;
6
+ readonly total: number;
7
+ };
8
+
9
+ export type FileComparison = {
10
+ readonly path: string;
11
+ readonly base: Tally;
12
+ readonly head: Tally;
13
+ };
14
+
15
+ export type Comparison = {
16
+ readonly base: Tally;
17
+ readonly head: Tally;
18
+ readonly files: readonly FileComparison[];
19
+ readonly regression: boolean;
20
+ };
21
+
22
+ export type Options = {
23
+ readonly basePath: string;
24
+ readonly headPath: string;
25
+ readonly advisory: boolean;
26
+ };
27
+
28
+ export class ReportError extends Error {}
29
+
30
+ const DETECTED = new Set(["Killed", "Timeout"]);
31
+ const UNDETECTED = new Set(["Survived", "NoCoverage"]);
32
+ const USAGE = "usage: mutation-compare.ts [--advisory] <base-report> <head-report>";
33
+
34
+ function isRecord(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === "object" && value !== null;
36
+ }
37
+
38
+ export function parseReport(text: string): Map<string, readonly string[]> {
39
+ let parsed: unknown;
40
+ try {
41
+ parsed = JSON.parse(text);
42
+ } catch {
43
+ throw new ReportError("mutation-compare: report is not valid JSON");
44
+ }
45
+ if (!isRecord(parsed) || !isRecord(parsed["files"])) {
46
+ throw new ReportError("mutation-compare: report has no files table");
47
+ }
48
+ const files = new Map<string, readonly string[]>();
49
+ for (const [path, entry] of Object.entries(parsed["files"])) {
50
+ if (!isRecord(entry) || !Array.isArray(entry["mutants"])) {
51
+ throw new ReportError(`mutation-compare: ${path} has no mutants list`);
52
+ }
53
+ const statuses: string[] = [];
54
+ for (const mutant of entry["mutants"]) {
55
+ if (!isRecord(mutant) || typeof mutant["status"] !== "string") {
56
+ throw new ReportError(`mutation-compare: ${path} carries a mutant without a status`);
57
+ }
58
+ statuses.push(mutant["status"]);
59
+ }
60
+ files.set(path, statuses);
61
+ }
62
+ return files;
63
+ }
64
+
65
+ function tally(statuses: readonly string[]): Tally {
66
+ let killed = 0;
67
+ let total = 0;
68
+ for (const status of statuses) {
69
+ if (DETECTED.has(status)) killed += 1;
70
+ if (DETECTED.has(status) || UNDETECTED.has(status)) total += 1;
71
+ }
72
+ return { killed, total };
73
+ }
74
+
75
+ const EMPTY: Tally = { killed: 0, total: 0 };
76
+
77
+ export function compareReports(
78
+ baseFiles: ReadonlyMap<string, readonly string[]>,
79
+ headFiles: ReadonlyMap<string, readonly string[]>,
80
+ ): Comparison {
81
+ const paths = [...new Set([...baseFiles.keys(), ...headFiles.keys()])].sort();
82
+ const files = paths.map((path) => {
83
+ const baseStatuses = baseFiles.get(path);
84
+ const headStatuses = headFiles.get(path);
85
+ return {
86
+ path,
87
+ base: baseStatuses === undefined ? EMPTY : tally(baseStatuses),
88
+ head: headStatuses === undefined ? EMPTY : tally(headStatuses),
89
+ };
90
+ });
91
+ const base = tally([...baseFiles.values()].flat());
92
+ const head = tally([...headFiles.values()].flat());
93
+ return { base, head, files, regression: regressed(base, head) };
94
+ }
95
+
96
+ export function regressed(base: Tally, head: Tally): boolean {
97
+ if (base.total === 0) return head.total > 0 && head.killed < head.total;
98
+ if (head.total === 0) return false;
99
+ return head.killed * base.total < base.killed * head.total;
100
+ }
101
+
102
+ function points(count: Tally): number {
103
+ return count.total === 0 ? 100 : (100 * count.killed) / count.total;
104
+ }
105
+
106
+ function percent(count: Tally): string {
107
+ return count.total === 0 ? "n/a" : `${points(count).toFixed(2)}%`;
108
+ }
109
+
110
+ function describe(count: Tally): string {
111
+ return `${percent(count)} (${count.killed}/${count.total})`;
112
+ }
113
+
114
+ function deltaPoints(base: Tally, head: Tally): string {
115
+ const delta = points(head) - points(base);
116
+ return `${delta < 0 ? "-" : "+"}${Math.abs(delta).toFixed(2)}pp`;
117
+ }
118
+
119
+ export function formatComparison(comparison: Comparison, advisory: boolean): string {
120
+ const changed = comparison.files.filter(
121
+ (file) => file.base.killed !== file.head.killed || file.base.total !== file.head.total,
122
+ );
123
+ const unchanged = comparison.files.length - changed.length;
124
+ const lines = [
125
+ `mutation-compare: base ${describe(comparison.base)} head ${describe(comparison.head)} delta ${deltaPoints(comparison.base, comparison.head)}`,
126
+ ];
127
+ for (const file of changed) {
128
+ lines.push(` ${file.path}: ${describe(file.base)} -> ${describe(file.head)}`);
129
+ }
130
+ if (unchanged > 0) lines.push(` ${unchanged} unchanged file(s)`);
131
+ if (comparison.regression) {
132
+ lines.push(`mutation-compare: REGRESSION (${deltaPoints(comparison.base, comparison.head)})${advisory ? " in advisory mode, exit 0" : ""}`);
133
+ } else {
134
+ lines.push(`mutation-compare: no regression${advisory ? " (advisory mode, exit 0)" : ""}`);
135
+ }
136
+ return lines.join("\n");
137
+ }
138
+
139
+ export function parseArgs(argv: readonly string[]): Options {
140
+ let advisory = false;
141
+ const paths: string[] = [];
142
+ for (const arg of argv) {
143
+ if (arg === "--advisory") advisory = true;
144
+ else if (arg.startsWith("--")) throw new ReportError(`${USAGE}: unknown flag ${arg}`);
145
+ else paths.push(arg);
146
+ }
147
+ if (paths.length !== 2 || paths[0] === undefined || paths[1] === undefined) throw new ReportError(USAGE);
148
+ return { basePath: paths[0], headPath: paths[1], advisory };
149
+ }
150
+
151
+ export function exitFor(comparison: Comparison, advisory: boolean): number {
152
+ return comparison.regression && !advisory ? 1 : 0;
153
+ }
154
+
155
+ async function load(path: string): Promise<string> {
156
+ try {
157
+ return await readFile(path, "utf8");
158
+ } catch {
159
+ throw new ReportError(`mutation-compare: cannot read ${path}`);
160
+ }
161
+ }
162
+
163
+ if (import.meta.main) {
164
+ try {
165
+ const options = parseArgs(process.argv.slice(2));
166
+ const comparison = compareReports(parseReport(await load(options.basePath)), parseReport(await load(options.headPath)));
167
+ console.log(formatComparison(comparison, options.advisory));
168
+ process.exit(exitFor(comparison, options.advisory));
169
+ } catch (error) {
170
+ console.error(error instanceof ReportError ? error.message : `mutation-compare: ${String(error)}`);
171
+ process.exit(2);
172
+ }
173
+ }
@@ -0,0 +1,13 @@
1
+ export default {
2
+ packageManager: "npm",
3
+ plugins: ["@stryker-mutator/*", "@hughescr/stryker-bun-runner"],
4
+ testRunner: "bun",
5
+ bun: { timeout: 60000 },
6
+ inPlace: true,
7
+ tempDirName: "../.stryker-tmp",
8
+ coverageAnalysis: "perTest",
9
+ reporters: ["html", "json", "clear-text", "progress"],
10
+ timeoutMS: 60000,
11
+ concurrency: 8,
12
+ thresholds: { high: 85, low: 70, break: null },
13
+ };