@avi2dg/checks 0.2.0 → 0.4.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, the CI-wiring check, and the Effect
10
+ error-channel plugin compiled 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
 
@@ -92,7 +105,10 @@ file and the path to move it to when it does not:
92
105
  - `scripts.test` is exactly `bun test --randomize` and `scripts.lint` runs
93
106
  this check.
94
107
  - `bunfig.toml` carries every `[test]` key of the shipped preset with the
95
- same value. Other tables, and extra `[test]` keys, are the repo's own.
108
+ same value, and `[test].pathIgnorePatterns` is always
109
+ `["**/tests/quarantine/**"]`: the check pins it itself, so this repo,
110
+ whose bunfig is the preset, cannot drift it either. Other tables, and
111
+ extra `[test]` keys, are the repo's own.
96
112
 
97
113
  The in-process half is what a mutation run can mutate; `tests/e2e/**` is
98
114
  excluded from a mutate scope by construction, because a subprocess kills
@@ -170,7 +186,15 @@ on:
170
186
  types: [opened, edited, synchronize, reopened]
171
187
  jobs:
172
188
  commitlint:
173
- uses: avi2d/checks/.github/workflows/commitlint.yml@main
189
+ runs-on: ubuntu-latest
190
+ steps:
191
+ - uses: actions/checkout@v5
192
+ - uses: oven-sh/setup-bun@v2
193
+ - run: bun install --frozen-lockfile
194
+ - run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
195
+ env:
196
+ PR_TITLE: ${{ github.event.pull_request.title }}
197
+ - run: ./node_modules/.bin/commitlint --config ./node_modules/@avi2dg/checks/commitlint.config.js --edit "$RUNNER_TEMP/pr-title"
174
198
  ```
175
199
 
176
200
  It lints the pull request title and nothing else. The title is the
@@ -221,10 +245,20 @@ on:
221
245
  types: [opened, edited, synchronize, reopened]
222
246
  jobs:
223
247
  commit-identity:
224
- uses: avi2d/checks/.github/workflows/commit-identity.yml@main
248
+ runs-on: ubuntu-latest
249
+ steps:
250
+ - uses: actions/checkout@v5
251
+ with:
252
+ fetch-depth: 0
253
+ - uses: oven-sh/setup-bun@v2
254
+ - run: bun install --frozen-lockfile
255
+ - run: bunx checks-commit-identity "origin/$BASE_REF" "$HEAD_SHA"
256
+ env:
257
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
258
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
225
259
  ```
226
260
 
227
- The workflow fetches the consumer's full history and ranges from the
261
+ The checkout fetches the full history and the range starts at the
228
262
  fetched base branch, not the event's recorded base sha, which GitHub
229
263
  leaves stale once the base branch advances after the pull request opens.
230
264
 
@@ -258,9 +292,100 @@ on:
258
292
  types: [opened, edited, synchronize, reopened]
259
293
  jobs:
260
294
  comment-gate:
261
- uses: avi2d/checks/.github/workflows/comment-gate.yml@main
295
+ runs-on: ubuntu-latest
296
+ steps:
297
+ - uses: actions/checkout@v5
298
+ with:
299
+ fetch-depth: 0
300
+ - uses: oven-sh/setup-bun@v2
301
+ - run: bun install --frozen-lockfile
302
+ - run: bunx checks-comment-gate "origin/$BASE_REF" "$HEAD_SHA"
303
+ env:
304
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
305
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
306
+ ```
307
+
308
+ ## CI wiring
309
+
310
+ `checks-ci-wiring` fails when a command the repository's CI must run no
311
+ longer runs on pull requests to the default branch. No local check sees
312
+ that: a workflow whose lint step became a no-op leaves `bun run lint`
313
+ green.
314
+
315
+ The repository declares its gates once, in `package.json`, and `lint`
316
+ runs the check:
317
+
318
+ ```json
319
+ "ciWiring": {
320
+ "gates": ["bun run lint", "bun run typecheck", "bun run test"]
321
+ },
322
+ "scripts": {
323
+ "lint": "oxlint --type-aware && checks-lint-coverage && checks-test-layout && checks-ci-wiring"
324
+ }
262
325
  ```
263
326
 
327
+ It parses every `.github/workflows/*.yml` and `*.yaml` and looks, for
328
+ each gate, for a `run:` step that is the gate command alone on one line,
329
+ optionally followed by plain arguments: words, quoted strings, and
330
+ `$VAR` or `${VAR}` expansions. `bun run lint --quiet` and
331
+ `bunx checks-comment-gate "origin/$BASE_REF" "$HEAD_SHA"` count;
332
+ `bun run lint:deps`, `echo bun run lint` and a step `name:` do not. A
333
+ step whose script has a second line, or any `|`, `||`, `&&`, `;`, `&`,
334
+ `$(...)`, backticks, `<` or `>` redirection, a comment or a leading
335
+ `NAME=value`, never counts, because each can run the gate without its
336
+ failure failing the step; the report names the gate and says to give it
337
+ its own step with nothing else in it. A gate step counts only when:
338
+
339
+ - its workflow triggers on `pull_request`, any `branches` or
340
+ `branches-ignore` filter there keeps the default branch, any `types`
341
+ filter keeps `opened` and `synchronize`, and it sets no `paths` or
342
+ `paths-ignore` filter, which lets some pull requests skip the gate;
343
+ - neither the step nor its job sets `if: false` or
344
+ `continue-on-error: true`, bare or as `${{ false }}` and `${{ true }}`;
345
+ - its job needs no job, directly or through a chain, that sets
346
+ `if: false`, unless a job on that chain has an `if:` calling
347
+ `always()`, `failure()` or `cancelled()`. GitHub prefixes every other
348
+ `if:`, including `true` and `success()`, with `success()`, so a job
349
+ whose needed job was skipped is skipped too.
350
+
351
+ A job calling a local reusable workflow (`uses: ./.github/workflows/x.yml`)
352
+ passes its own trigger and `if:` down to the called workflow's steps.
353
+ A remote reusable workflow (`uses: owner/repo/...@ref`) is not a
354
+ supported way to wire a gate: it is not read, so a gate must run as a
355
+ `run:` step, such as `bunx checks-comment-gate`, in the repo's own
356
+ workflows.
357
+
358
+ The default branch is `main`; a repo with another one sets
359
+ `"defaultBranch"` beside `"gates"`.
360
+
361
+ It exits 1 naming each gap, with every step that runs the gate and why
362
+ that step does not count:
363
+
364
+ ```
365
+ ci-wiring: 1 of 8 gate(s) do not run on pull requests to main:
366
+ bun run lint
367
+ .github/workflows/release.yml job publish step 7: .github/workflows/release.yml does not trigger on pull_request
368
+ ```
369
+
370
+ It exits 2 when `package.json` declares no gates, a gate is not one
371
+ plain command, or a workflow does not parse. Whether a workflow is
372
+ well formed is actionlint's question, not this one's.
373
+
374
+ ### Limits
375
+
376
+ The check reads workflow files and never runs them, so it deliberately
377
+ does not evaluate:
378
+
379
+ - an `if:` expression other than a constant `true` or `false`, which
380
+ counts as running;
381
+ - a `strategy.matrix` `include` or `exclude`, so a matrix that drops
382
+ every combination still counts as running its steps;
383
+ - a remote reusable workflow (`uses: owner/repo/...@ref`), whose steps
384
+ are never read;
385
+ - anything that happens at run time on the runner: what the gate
386
+ command itself does, the shell's options, and a step or job that
387
+ fails or times out before the gate step.
388
+
264
389
  ## Backtest
265
390
 
266
391
  `scripts/backtest.ts` reports what the comment check would have refused
@@ -277,6 +402,52 @@ comment text as a share of added lines.
277
402
  `generated/`, `vendor/`, `repos/`, `node_modules/` and `dist/` are out
278
403
  of reach, so the figures are authored code.
279
404
 
405
+ ## Mutation compare
406
+
407
+ `checks-mutation-compare` gates a pull request on no-regression rather
408
+ than an absolute threshold: the head mutation score may not fall below
409
+ the score at the merge-base.
410
+
411
+ ```sh
412
+ checks-mutation-compare <base-report> <head-report> [--advisory]
413
+ ```
414
+
415
+ Both reports are Stryker `mutation.json` files. It prints the overall
416
+ score of each report and the per-file scores that differ, and exits 1
417
+ when the head score is below the base score. The score is Stryker's:
418
+ `Killed` and `Timeout` over those plus `Survived` and `NoCoverage`, so
419
+ `CompileError`, `RuntimeError`, `Ignored` and `Pending` mutants leave
420
+ it. Every file in a report counts toward that report's score, including
421
+ files present in only one of the two.
422
+
423
+ `--advisory` prints the same verdict and always exits 0. That is how
424
+ consumers run it for the first month; after that they drop the flag and
425
+ it blocks.
426
+
427
+ Enforcement runs on pull requests, comparing the head report against a
428
+ report built at the merge-base:
429
+
430
+ ```yaml
431
+ jobs:
432
+ mutation-compare:
433
+ runs-on: ubuntu-latest
434
+ steps:
435
+ - uses: actions/checkout@v5
436
+ with:
437
+ fetch-depth: 0
438
+ - uses: oven-sh/setup-bun@v2
439
+ - run: bun install --frozen-lockfile
440
+ - run: bunx stryker run
441
+ - run: |
442
+ base="$(git merge-base HEAD origin/main)"
443
+ git worktree add /tmp/mutation-base "$base"
444
+ (cd /tmp/mutation-base && bun install --frozen-lockfile && bunx stryker run)
445
+ - run: bun run checks-mutation-compare --advisory /tmp/mutation-base/reports/mutation/mutation.json reports/mutation/mutation.json
446
+ ```
447
+
448
+ The shared Stryker preset's `json` reporter writes
449
+ `reports/mutation/mutation.json` in each worktree.
450
+
280
451
  ## Why it is shaped this way
281
452
 
282
453
  - `plugins` does not inherit through oxlint `extends`. `rules`,
@@ -307,12 +478,22 @@ of reach, so the figures are authored code.
307
478
  consumer's copy is compared key by key against the installed one
308
479
  instead. `[test] pathIgnorePatterns` is a real bunfig key, and an empty
309
480
  `--path-ignore-patterns` flag overrides the file's own list.
481
+ - The Stryker preset is a JavaScript module, not JSON: Stryker 10 does
482
+ not resolve `extends` in a JSON config, but a `.mjs` config that
483
+ spreads an imported object consumes it. Keys the consumer sets after
484
+ the spread win.
310
485
  - Each runnable script ships a `checks-` bin entry, so consumer
311
486
  `package.json` scripts call the short name, which the package manager
312
487
  puts on `PATH` only there; a shell runs it through `bun run`, which
313
488
  never falls back to the registry the way `bunx` does. The `.ts` checks
314
489
  keep a `bun` shebang, which needs no build step and no `dist/`
315
490
  entry, unlike the oxlint plugin that node loads.
491
+ - `checks-ci-wiring` runs inside `lint`, not in a workflow of its own:
492
+ deleting the step that runs a check is the violation it catches, so the
493
+ local `lint` is where it has to fail.
494
+ - Workflows are parsed with `Bun.YAML`, which the `bun` shebang already
495
+ provides, so the check adds no dependency. It reads `on` as a string
496
+ key, not as the YAML 1.1 boolean.
316
497
  - `bun` counts as a built-in module. Nothing installed resolves it except
317
498
  `@types/bun`, which would otherwise make every runtime `bun` import look
318
499
  like a dev-only dependency.
@@ -346,7 +527,7 @@ a tag off `main` and reruns the build, `dist/` check, lint, typecheck
346
527
  and tests before it publishes:
347
528
 
348
529
  ```sh
349
- git tag v0.2.0 && git push origin v0.2.0
530
+ git tag v0.4.0 && git push origin v0.4.0
350
531
  ```
351
532
 
352
533
  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.4.0",
4
4
  "description": "Deterministic checks shared across the captain's TypeScript repos",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,10 +18,13 @@
18
18
  "scripts/lint-coverage.sh",
19
19
  "scripts/test-layout.ts",
20
20
  "scripts/commit-identity.ts",
21
+ "scripts/mutation-compare.ts",
22
+ "scripts/ci-wiring.ts",
21
23
  "scripts/comments.ts",
22
24
  "scripts/comment-gate.ts",
23
25
  "scripts/backtest.ts",
24
26
  "oxlintrc.json",
27
+ "stryker.preset.js",
25
28
  "tsconfig.effect.json",
26
29
  "dist/index.js"
27
30
  ],
@@ -32,10 +35,13 @@
32
35
  "./scripts/lint-coverage.sh": "./scripts/lint-coverage.sh",
33
36
  "./scripts/test-layout.ts": "./scripts/test-layout.ts",
34
37
  "./scripts/commit-identity.ts": "./scripts/commit-identity.ts",
38
+ "./scripts/mutation-compare.ts": "./scripts/mutation-compare.ts",
39
+ "./scripts/ci-wiring.ts": "./scripts/ci-wiring.ts",
35
40
  "./scripts/comments.ts": "./scripts/comments.ts",
36
41
  "./scripts/comment-gate.ts": "./scripts/comment-gate.ts",
37
42
  "./scripts/backtest.ts": "./scripts/backtest.ts",
38
43
  "./oxlintrc.json": "./oxlintrc.json",
44
+ "./stryker.preset.js": "./stryker.preset.js",
39
45
  "./tsconfig.effect.json": "./tsconfig.effect.json",
40
46
  "./dist/index.js": "./dist/index.js"
41
47
  },
@@ -43,15 +49,29 @@
43
49
  "checks-lint-coverage": "scripts/lint-coverage.sh",
44
50
  "checks-test-layout": "scripts/test-layout.ts",
45
51
  "checks-commit-identity": "scripts/commit-identity.ts",
52
+ "checks-mutation-compare": "scripts/mutation-compare.ts",
53
+ "checks-ci-wiring": "scripts/ci-wiring.ts",
46
54
  "checks-comment-gate": "scripts/comment-gate.ts",
47
55
  "checks-backtest": "scripts/backtest.ts"
48
56
  },
49
57
  "scripts": {
50
58
  "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",
59
+ "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 && bun scripts/ci-wiring.ts && depcruise --config .dependency-cruiser.cjs effect-channel scripts tests commitlint.config.js dependency-cruiser.config.js stryker.preset.js .dependency-cruiser.cjs",
52
60
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
53
61
  "test": "bun test --randomize"
54
62
  },
63
+ "ciWiring": {
64
+ "gates": [
65
+ "bun run build",
66
+ "git diff --exit-code dist/",
67
+ "bun run lint",
68
+ "bun run typecheck",
69
+ "bun run test",
70
+ "./node_modules/.bin/commitlint",
71
+ "bun .checks/scripts/commit-identity.ts",
72
+ "bun .checks/scripts/comment-gate.ts"
73
+ ]
74
+ },
55
75
  "peerDependencies": {
56
76
  "@swc/core": "1.16.2",
57
77
  "dependency-cruiser": "18.4.0",
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ export type Command = readonly string[];
6
+
7
+ export type Gate = {
8
+ readonly command: string;
9
+ readonly words: Command;
10
+ };
11
+
12
+ export type Declaration = {
13
+ readonly gates: readonly Gate[];
14
+ readonly defaultBranch: string;
15
+ };
16
+
17
+ export type Workflow = {
18
+ readonly path: string;
19
+ readonly document: unknown;
20
+ };
21
+
22
+ export type BlockedInvocation = {
23
+ readonly location: string;
24
+ readonly blocker: string;
25
+ };
26
+
27
+ export type Gap = {
28
+ readonly gate: string;
29
+ readonly blocked: readonly BlockedInvocation[];
30
+ };
31
+
32
+ type RunStep = {
33
+ readonly location: string;
34
+ readonly blocker: string | undefined;
35
+ readonly script: string;
36
+ };
37
+
38
+ export class WiringError extends Error {}
39
+
40
+ const WORKFLOWS = ".github/workflows";
41
+ const DEFAULT_BRANCH = "main";
42
+ // Without these a pull_request workflow never sees the commits a pull request pushes.
43
+ const GATING_TYPES = ["opened", "synchronize"];
44
+ const CONSTANTS = new Map([
45
+ ["true", true],
46
+ ["false", false],
47
+ ]);
48
+ const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
49
+ const VARIABLE = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/;
50
+ const UNPLAIN = new Set(["|", "&", ";", "<", ">", "(", ")", "`", "\\", "#", "\n"]);
51
+
52
+ function isRecord(value: unknown): value is Record<string, unknown> {
53
+ return typeof value === "object" && value !== null && !Array.isArray(value);
54
+ }
55
+
56
+ function names(value: unknown): readonly string[] | undefined {
57
+ if (typeof value === "string") return [value];
58
+ if (!Array.isArray(value)) return undefined;
59
+ return value.filter((entry): entry is string => typeof entry === "string");
60
+ }
61
+
62
+ function expansion(text: string, from: number): number | undefined {
63
+ const match = VARIABLE.exec(text.slice(from));
64
+ return match === null ? undefined : from + match[0].length;
65
+ }
66
+
67
+ // A script counts only when it is one line of plain words: any shell control, redirection or
68
+ // substitution can run the gate without its failure failing the step.
69
+ function plainCommand(script: string): Command | undefined {
70
+ const line = script.trim();
71
+ const words: string[] = [];
72
+ let word: string | undefined;
73
+ for (let index = 0; index < line.length; index += 1) {
74
+ const char = line.charAt(index);
75
+ if (char === " " || char === "\t") {
76
+ if (word !== undefined) words.push(word);
77
+ word = undefined;
78
+ } else if (char === "'") {
79
+ const close = line.indexOf("'", index + 1);
80
+ if (close === -1) return undefined;
81
+ word = (word ?? "") + line.slice(index + 1, close);
82
+ index = close;
83
+ } else if (char === '"') {
84
+ let quoted = "";
85
+ for (index += 1; line.charAt(index) !== '"'; index += 1) {
86
+ const inner = line.charAt(index);
87
+ if (inner === "" || inner === "\\" || inner === "`") return undefined;
88
+ if (inner === "$") {
89
+ const end = expansion(line, index);
90
+ if (end === undefined) return undefined;
91
+ quoted += line.slice(index, end);
92
+ index = end - 1;
93
+ } else {
94
+ quoted += inner;
95
+ }
96
+ }
97
+ word = (word ?? "") + quoted;
98
+ } else if (char === "$") {
99
+ const end = expansion(line, index);
100
+ if (end === undefined) return undefined;
101
+ word = (word ?? "") + line.slice(index, end);
102
+ index = end - 1;
103
+ } else if (UNPLAIN.has(char)) {
104
+ return undefined;
105
+ } else {
106
+ word = (word ?? "") + char;
107
+ }
108
+ }
109
+ if (word !== undefined) words.push(word);
110
+ return words.length === 0 ? undefined : words;
111
+ }
112
+
113
+ function invokes(command: Command | undefined, gate: Command): boolean {
114
+ return command !== undefined && gate.length <= command.length && gate.every((word, index) => command[index] === word);
115
+ }
116
+
117
+ function mentions(script: string, gate: Command): boolean {
118
+ const tokens = script.split(/[\s|&;<>()`]+/);
119
+ return tokens.some((_, start) => invokes(tokens.slice(start), gate));
120
+ }
121
+
122
+ function constant(value: unknown): boolean | undefined {
123
+ if (typeof value === "boolean") return value;
124
+ if (typeof value !== "string") return undefined;
125
+ const expression = value.trim().replace(/^\$\{\{(.*)\}\}$/s, "$1").trim();
126
+ return CONSTANTS.get(expression);
127
+ }
128
+
129
+ function switchedOff(node: Readonly<Record<string, unknown>>, subject: string): string | undefined {
130
+ if (constant(node["if"]) === false) return `${subject} sets if: false`;
131
+ if (constant(node["continue-on-error"]) === true) return `${subject} sets continue-on-error: true`;
132
+ return undefined;
133
+ }
134
+
135
+ // Last match wins, as GitHub evaluates a branch filter, so one match anywhere is not enough.
136
+ function selects(patterns: readonly string[], branch: string): boolean {
137
+ let selected = false;
138
+ for (const pattern of patterns) {
139
+ const excludes = pattern.startsWith("!");
140
+ if (new Bun.Glob(excludes ? pattern.slice(1) : pattern).match(branch)) selected = !excludes;
141
+ }
142
+ return selected;
143
+ }
144
+
145
+ function triggerBlocker(workflow: Workflow, branch: string): string | undefined {
146
+ const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
147
+ const events = isRecord(on) ? Object.keys(on) : (names(on) ?? []);
148
+ if (!events.includes("pull_request")) return `${workflow.path} does not trigger on pull_request`;
149
+
150
+ const filters = isRecord(on) ? on["pull_request"] : undefined;
151
+ if (!isRecord(filters)) return undefined;
152
+ const branches = names(filters["branches"]);
153
+ if (branches !== undefined && !selects(branches, branch)) {
154
+ return `${workflow.path} limits pull_request to branches other than ${branch}`;
155
+ }
156
+ const ignored = names(filters["branches-ignore"]);
157
+ if (ignored?.some((pattern) => new Bun.Glob(pattern).match(branch)) === true) {
158
+ return `${workflow.path} ignores pull_request to ${branch}`;
159
+ }
160
+ if (filters["paths"] !== undefined || filters["paths-ignore"] !== undefined) {
161
+ return `${workflow.path} filters pull_request by paths, so some pull requests skip the gate`;
162
+ }
163
+ const types = names(filters["types"]);
164
+ const missing = types === undefined ? [] : GATING_TYPES.filter((type) => !types.includes(type));
165
+ if (missing.length > 0) return `${workflow.path} limits pull_request to types without ${missing.join(", ")}`;
166
+ return undefined;
167
+ }
168
+
169
+ // GitHub prefixes any other if: with success(), so only a status function overrides a needed job's skip.
170
+ function skippedBy(jobs: Readonly<Record<string, unknown>>, id: string, seen: readonly string[]): string | undefined {
171
+ const job = jobs[id];
172
+ if (!isRecord(job) || seen.includes(id)) return undefined;
173
+ if (constant(job["if"]) === false) return `job ${id} sets if: false`;
174
+ if (typeof job["if"] === "string" && STATUS_OVERRIDE.test(job["if"])) return undefined;
175
+ for (const need of names(job["needs"]) ?? []) {
176
+ const cause = skippedBy(jobs, need, [...seen, id]);
177
+ if (cause !== undefined) return cause;
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ function localCall(uses: unknown): string | undefined {
183
+ return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
184
+ }
185
+
186
+ function runSteps(workflows: readonly Workflow[], branch: string): readonly RunStep[] {
187
+ const documents = new Map(workflows.map((workflow) => [workflow.path, workflow.document]));
188
+ const steps: RunStep[] = [];
189
+
190
+ const visit = (
191
+ document: unknown,
192
+ location: string,
193
+ blocker: string | undefined,
194
+ walked: readonly string[],
195
+ ): void => {
196
+ const jobs = isRecord(document) ? document["jobs"] : undefined;
197
+ if (!isRecord(jobs)) return;
198
+ for (const [id, job] of Object.entries(jobs)) {
199
+ if (!isRecord(job)) continue;
200
+ const jobLocation = `${location} job ${id}`;
201
+ const skipped = skippedBy(jobs, id, []);
202
+ const jobBlocker =
203
+ blocker ??
204
+ switchedOff(job, `job ${id}`) ??
205
+ (skipped === undefined ? undefined : `job ${id} needs a job that never runs: ${skipped}`);
206
+ const called = localCall(job["uses"]);
207
+ if (called !== undefined && !walked.includes(called)) {
208
+ visit(documents.get(called), `${jobLocation} > ${called}`, jobBlocker, [...walked, called]);
209
+ }
210
+ const jobSteps = job["steps"];
211
+ if (!Array.isArray(jobSteps)) continue;
212
+ jobSteps.forEach((step: unknown, index) => {
213
+ if (!isRecord(step) || typeof step["run"] !== "string") return;
214
+ steps.push({
215
+ location: `${jobLocation} step ${index + 1}`,
216
+ blocker: jobBlocker ?? switchedOff(step, "the step"),
217
+ script: step["run"],
218
+ });
219
+ });
220
+ }
221
+ };
222
+
223
+ for (const workflow of workflows) {
224
+ visit(workflow.document, workflow.path, triggerBlocker(workflow, branch), [workflow.path]);
225
+ }
226
+ return steps;
227
+ }
228
+
229
+ export function findGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
230
+ const steps = runSteps(workflows, declaration.defaultBranch);
231
+ return declaration.gates.flatMap((gate) => {
232
+ const alone = `the step runs more than ${gate.command}; give it its own step with nothing else in it`;
233
+ const invoking = steps.flatMap(({ location, blocker, script }) => {
234
+ if (invokes(plainCommand(script), gate.words)) return [{ location, blocker }];
235
+ return mentions(script, gate.words) ? [{ location, blocker: blocker ?? alone }] : [];
236
+ });
237
+ if (invoking.some((step) => step.blocker === undefined)) return [];
238
+ const blocked = invoking.flatMap(({ location, blocker }) =>
239
+ blocker === undefined ? [] : [{ location, blocker }],
240
+ );
241
+ return [{ gate: gate.command, blocked }];
242
+ });
243
+ }
244
+
245
+ export function formatReport(declaration: Declaration, gaps: readonly Gap[]): string {
246
+ const target = `pull requests to ${declaration.defaultBranch}`;
247
+ if (gaps.length === 0) return `ci-wiring: ${declaration.gates.length} gate(s) run on ${target}`;
248
+ const lines = [`ci-wiring: ${gaps.length} of ${declaration.gates.length} gate(s) do not run on ${target}:`];
249
+ for (const gap of gaps) {
250
+ lines.push(` ${gap.gate}`);
251
+ if (gap.blocked.length === 0) lines.push(" no run step invokes it");
252
+ for (const { location, blocker } of gap.blocked) lines.push(` ${location}: ${blocker}`);
253
+ }
254
+ return lines.join("\n");
255
+ }
256
+
257
+ export function parseDeclaration(manifest: unknown, source: string): Declaration {
258
+ const configured = isRecord(manifest) && isRecord(manifest["ciWiring"]) ? manifest["ciWiring"] : {};
259
+ const listed = configured["gates"];
260
+ if (!Array.isArray(listed) || listed.length === 0) {
261
+ throw new WiringError(`ci-wiring: ${source} sets no ciWiring.gates, a non-empty array of commands`);
262
+ }
263
+ const gates = listed.map((command: unknown): Gate => {
264
+ const words = typeof command === "string" ? plainCommand(command) : undefined;
265
+ if (typeof command !== "string" || words === undefined) {
266
+ throw new WiringError(`ci-wiring: ${source} ciWiring gate ${JSON.stringify(command)} is not one plain command`);
267
+ }
268
+ return { command, words };
269
+ });
270
+ const defaultBranch = configured["defaultBranch"] ?? DEFAULT_BRANCH;
271
+ if (typeof defaultBranch !== "string" || defaultBranch === "") {
272
+ throw new WiringError(`ci-wiring: ${source} ciWiring.defaultBranch is not a branch name`);
273
+ }
274
+ return { gates, defaultBranch };
275
+ }
276
+
277
+ export function parseWorkflow(path: string, text: string): Workflow {
278
+ try {
279
+ return { path, document: Bun.YAML.parse(text) };
280
+ } catch (error) {
281
+ throw new WiringError(`ci-wiring: cannot parse ${path}: ${String(error)}`);
282
+ }
283
+ }
284
+
285
+ export function readDeclaration(root: string): Declaration {
286
+ const path = join(root, "package.json");
287
+ let manifest: unknown;
288
+ try {
289
+ manifest = JSON.parse(readFileSync(path, "utf8"));
290
+ } catch {
291
+ throw new WiringError(`ci-wiring: cannot read ${path} as JSON`);
292
+ }
293
+ return parseDeclaration(manifest, path);
294
+ }
295
+
296
+ export function readWorkflows(root: string): readonly Workflow[] {
297
+ const directory = join(root, WORKFLOWS);
298
+ if (!existsSync(directory)) return [];
299
+ return readdirSync(directory)
300
+ .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))
301
+ .sort()
302
+ .map((name) => parseWorkflow(`${WORKFLOWS}/${name}`, readFileSync(join(directory, name), "utf8")));
303
+ }
304
+
305
+ function repositoryRoot(): string {
306
+ const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { stdout: "pipe", stderr: "pipe" });
307
+ if (!result.success) {
308
+ throw new WiringError(`ci-wiring: git rev-parse --show-toplevel: ${result.stderr.toString().trim()}`);
309
+ }
310
+ return result.stdout.toString().trim();
311
+ }
312
+
313
+ if (import.meta.main) {
314
+ try {
315
+ const root = repositoryRoot();
316
+ const declaration = readDeclaration(root);
317
+ const gaps = findGaps(declaration, readWorkflows(root));
318
+ if (gaps.length > 0) {
319
+ console.error(formatReport(declaration, gaps));
320
+ process.exit(1);
321
+ }
322
+ console.log(formatReport(declaration, gaps));
323
+ } catch (error) {
324
+ console.error(error instanceof WiringError ? error.message : `ci-wiring: ${String(error)}`);
325
+ process.exit(2);
326
+ }
327
+ }
@@ -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
+ }
@@ -39,6 +39,9 @@ const BANNED_BUN_NAMES: readonly string[] = [
39
39
  const BANNED_GLOBAL_CALLS: readonly string[] = ["fetch"];
40
40
 
41
41
  export const REQUIRED_TEST_SCRIPT = "bun test --randomize";
42
+ const REQUIRED_TEST_TABLE: Record<string, unknown> = {
43
+ pathIgnorePatterns: ["**/tests/quarantine/**"],
44
+ };
42
45
  export const LAYOUT_CHECK_MARK = "scripts/test-layout.ts";
43
46
  export const LAYOUT_CHECK_BIN = "checks-test-layout";
44
47
 
@@ -236,9 +239,10 @@ export function bunfigViolations(consumer: unknown, preset: unknown): readonly V
236
239
  if (!isRecord(presetTest)) {
237
240
  return [{ file, line: undefined, message: "the shipped bunfig preset has no [test] table" }];
238
241
  }
242
+ const expected = { ...presetTest, ...REQUIRED_TEST_TABLE };
239
243
  const consumerTest = isRecord(consumer) ? consumer["test"] : undefined;
240
244
  const violations: Violation[] = [];
241
- for (const [key, value] of Object.entries(presetTest)) {
245
+ for (const [key, value] of Object.entries(expected)) {
242
246
  const found = isRecord(consumerTest) ? consumerTest[key] : undefined;
243
247
  if (!Bun.deepEquals(found, value)) {
244
248
  violations.push({
@@ -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
+ };