@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 +189 -8
- package/package.json +22 -2
- package/scripts/ci-wiring.ts +327 -0
- package/scripts/mutation-compare.ts +173 -0
- package/scripts/test-layout.ts +5 -1
- package/stryker.preset.js +13 -0
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,
|
|
9
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/scripts/test-layout.ts
CHANGED
|
@@ -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(
|
|
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
|
+
};
|