@avi2dg/checks 0.1.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
 
@@ -50,7 +51,7 @@ cp node_modules/@avi2dg/checks/bunfig.toml bunfig.toml
50
51
  `package.json` gains three scripts:
51
52
 
52
53
  ```json
53
- "lint": "oxlint --type-aware && ./node_modules/@avi2dg/checks/scripts/lint-coverage.sh && bun ./node_modules/@avi2dg/checks/scripts/test-layout.ts && bun ./node_modules/@avi2dg/checks/scripts/commit-identity.ts HEAD",
54
+ "lint": "oxlint --type-aware && checks-lint-coverage && checks-test-layout && checks-commit-identity HEAD",
54
55
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
55
56
  "test": "bun test --randomize"
56
57
  ```
@@ -64,14 +65,25 @@ 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
 
70
83
  ## Test layout
71
84
 
72
- `bun ./node_modules/@avi2dg/checks/scripts/test-layout.ts` fails unless the
73
- repo holds this shape, and names the file and the path to move it to when
74
- it does not:
85
+ `checks-test-layout` fails unless the repo holds this shape, and names the
86
+ file and the path to move it to when it does not:
75
87
 
76
88
  - Every test file is `tests/**/*.test.ts` or `.tsx`. A `*.test.ts`, `*.spec.ts` or
77
89
  `*_test.ts` under `src/`, `test/`, `__tests__/` or the repo root fails.
@@ -192,8 +204,8 @@ commit-identity check below is the enforcement.
192
204
  one carries an identity other than the repository owner's:
193
205
 
194
206
  ```sh
195
- bun ./node_modules/@avi2dg/checks/scripts/commit-identity.ts <base-ref> <head-ref>
196
- bun ./node_modules/@avi2dg/checks/scripts/commit-identity.ts <ref>
207
+ bun run checks-commit-identity <base-ref> <head-ref>
208
+ bun run checks-commit-identity <ref>
197
209
  ```
198
210
 
199
211
  With one argument it checks that commit alone, which is the form the
@@ -235,8 +247,8 @@ leaves stale once the base branch advances after the pull request opens.
235
247
  when an added line carries a banned comment:
236
248
 
237
249
  ```sh
238
- bun ./node_modules/@avi2dg/checks/scripts/comment-gate.ts <base-ref> <head-ref>
239
- bun ./node_modules/@avi2dg/checks/scripts/comment-gate.ts <ref>
250
+ bun run checks-comment-gate <base-ref> <head-ref>
251
+ bun run checks-comment-gate <ref>
240
252
  ```
241
253
 
242
254
  With two arguments it diffs the base against the head. With one it diffs
@@ -268,7 +280,7 @@ jobs:
268
280
  at each recent commit, so a repository can measure its own history:
269
281
 
270
282
  ```sh
271
- bun ./node_modules/@avi2dg/checks/scripts/backtest.ts [commit-count]
283
+ bun run checks-backtest [commit-count]
272
284
  ```
273
285
 
274
286
  It walks the last `commit-count` first-parent commits, 60 by default,
@@ -278,6 +290,52 @@ comment text as a share of added lines.
278
290
  `generated/`, `vendor/`, `repos/`, `node_modules/` and `dist/` are out
279
291
  of reach, so the figures are authored code.
280
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
+
281
339
  ## Why it is shaped this way
282
340
 
283
341
  - `plugins` does not inherit through oxlint `extends`. `rules`,
@@ -308,9 +366,16 @@ of reach, so the figures are authored code.
308
366
  consumer's copy is compared key by key against the installed one
309
367
  instead. `[test] pathIgnorePatterns` is a real bunfig key, and an empty
310
368
  `--path-ignore-patterns` flag overrides the file's own list.
311
- - The layout check ships as `.ts` and is invoked with `bun`, which needs
312
- no build step and no `dist/` entry, unlike the oxlint plugin that node
313
- loads.
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.
373
+ - Each runnable script ships a `checks-` bin entry, so consumer
374
+ `package.json` scripts call the short name, which the package manager
375
+ puts on `PATH` only there; a shell runs it through `bun run`, which
376
+ never falls back to the registry the way `bunx` does. The `.ts` checks
377
+ keep a `bun` shebang, which needs no build step and no `dist/`
378
+ entry, unlike the oxlint plugin that node loads.
314
379
  - `bun` counts as a built-in module. Nothing installed resolves it except
315
380
  `@types/bun`, which would otherwise make every runtime `bun` import look
316
381
  like a dev-only dependency.
@@ -344,7 +409,7 @@ a tag off `main` and reruns the build, `dist/` check, lint, typecheck
344
409
  and tests before it publishes:
345
410
 
346
411
  ```sh
347
- git tag v0.2.0 && git push origin v0.2.0
412
+ git tag v0.3.0 && git push origin v0.3.0
348
413
  ```
349
414
 
350
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.1.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,16 +34,26 @@
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
  },
46
+ "bin": {
47
+ "checks-lint-coverage": "scripts/lint-coverage.sh",
48
+ "checks-test-layout": "scripts/test-layout.ts",
49
+ "checks-commit-identity": "scripts/commit-identity.ts",
50
+ "checks-mutation-compare": "scripts/mutation-compare.ts",
51
+ "checks-comment-gate": "scripts/comment-gate.ts",
52
+ "checks-backtest": "scripts/backtest.ts"
53
+ },
42
54
  "scripts": {
43
55
  "build": "bun build effect-channel/index.ts --outdir dist --target node --format esm",
44
- "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",
45
57
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
46
58
  "test": "bun test --randomize"
47
59
  },
File without changes
File without changes
@@ -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
+ }
@@ -40,6 +40,7 @@ const BANNED_GLOBAL_CALLS: readonly string[] = ["fetch"];
40
40
 
41
41
  export const REQUIRED_TEST_SCRIPT = "bun test --randomize";
42
42
  export const LAYOUT_CHECK_MARK = "scripts/test-layout.ts";
43
+ export const LAYOUT_CHECK_BIN = "checks-test-layout";
43
44
 
44
45
  function isRecord(value: unknown): value is Record<string, unknown> {
45
46
  return typeof value === "object" && value !== null;
@@ -215,11 +216,11 @@ export function scriptViolations(manifest: unknown): readonly Violation[] {
215
216
  message: `scripts.test must be exactly "${REQUIRED_TEST_SCRIPT}", found ${JSON.stringify(test ?? null)}`,
216
217
  });
217
218
  }
218
- if (typeof lint !== "string" || !lint.includes(LAYOUT_CHECK_MARK)) {
219
+ if (typeof lint !== "string" || (!lint.includes(LAYOUT_CHECK_MARK) && !lint.includes(LAYOUT_CHECK_BIN))) {
219
220
  violations.push({
220
221
  file,
221
222
  line: undefined,
222
- message: `scripts.lint must run the layout check: add "bun ./node_modules/@avi2dg/checks/${LAYOUT_CHECK_MARK}"`,
223
+ message: `scripts.lint must run the layout check: add "${LAYOUT_CHECK_BIN}"`,
223
224
  });
224
225
  }
225
226
  return violations;
@@ -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
+ };