@avi2dg/checks 0.29.0 → 0.30.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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.30.0
6
+
7
+ Released 2026-09-28.
8
+
9
+ ### Features
10
+
11
+ - **testing:** refuse full mutation runs outside CI [#106](https://github.com/avi2d/checks/pull/106)
12
+ - **docs:** refuse a decision-record revision link named on one side only [#105](https://github.com/avi2d/checks/pull/105)
13
+
5
14
  ## 0.29.0
6
15
 
7
16
  Released 2026-09-28.
package/README.md CHANGED
@@ -135,6 +135,7 @@ These bins run on their own:
135
135
 
136
136
  - [`checks-test`](docs/gates/checks-test.md) runs the suite as `scripts.test` and refuses a skip without a reason at its test site.
137
137
  - [`checks-flake`](docs/gates/checks-flake.md) runs the suite on a schedule and records the seeds a flaky test fails with.
138
+ - [`checks-mutation`](docs/gates/checks-mutation.md) runs Stryker for scoped checks and refuses a full run outside CI.
138
139
  - [`checks-mutation-compare`](docs/gates/checks-mutation-compare.md) holds every mutant in a pull request to no regression.
139
140
  - [`checks-subsumed-tests`](docs/gates/checks-subsumed-tests.md) lists each test another test subsumes in a mutation run.
140
141
  - [`checks-changelog`](docs/gates/checks-changelog.md) writes the pending release into `CHANGELOG.md` from the conventional commits since the last release.
@@ -174,7 +175,7 @@ Every path is relative to the installed package, `node_modules/@avi2dg/checks/`.
174
175
  | `src/` | every bin, which a package script calls by its `checks-` name, the modules the bins import, and the Effect rule blocks under `src/quality/presets/` |
175
176
  | `dist/` | the compiled oxlint plugins and the doc templates, one template per kind of doc file |
176
177
  | `oxlintrc.json` | the oxlint base config `.oxlintrc.json` extends |
177
- | `stryker.preset.js` | the Stryker mutation-testing preset |
178
+ | `stryker.preset.js` | the Stryker mutation-testing preset, which refuses a full run outside CI |
178
179
  | `tsconfig.effect.json` | the tsconfig fragment with the shared compiler options and the Effect language-service block |
179
180
  | `ts-reset.d.ts` | the two ts-reset rules `tsconfig.effect.json` lists in `files` |
180
181
 
@@ -61,6 +61,7 @@ A template decides a file's structure, and the template file itself is the refer
61
61
  A `Date: YYYY-MM-DD` line follows the title.
62
62
  The first word under Status is Proposed, Accepted, Rejected, Deprecated, Superseded or Retired.
63
63
  No other record holds its number.
64
+ A Status that amends, narrows or supersedes another record names it, and the other record names it back.
64
65
  - A changelog lists its releases newest first, and each opens with a `Released YYYY-MM-DD.` line.
65
66
  - A how-to or tutorial page numbers its steps.
66
67
  - `CLAUDE.md` is its template word for word.
@@ -69,12 +69,18 @@ mutation-compare: REGRESSION (1 mutant(s))
69
69
 
70
70
  Only a CI step the repository writes runs it.
71
71
  A repository runs it with `--advisory` for its first month, then drops the flag so it blocks.
72
+ A full sweep runs in CI and never on a laptop.
73
+ Start a baseline with `gh workflow run mutation` and keep its report as an artifact.
74
+ The shared preset refuses a full `stryker run` outside CI and names that workflow command instead, as [checks-mutation](checks-mutation.md) says.
72
75
 
73
76
  ## Running it in CI
74
77
 
75
- It runs on pull requests, comparing the head report against a report built at the merge-base:
78
+ It runs on pull requests from a workflow named `mutation-compare`, comparing the head report against a report built at the merge-base:
76
79
 
77
80
  ```yaml
81
+ name: mutation-compare
82
+ on:
83
+ pull_request:
78
84
  jobs:
79
85
  mutation-compare:
80
86
  runs-on: ubuntu-latest
@@ -86,12 +92,25 @@ jobs:
86
92
  - run: bun install --frozen-lockfile
87
93
  - run: bunx stryker run
88
94
  - run: |
89
- base="$(git merge-base HEAD origin/main)"
90
- git worktree add /tmp/mutation-base "$base"
91
- (cd /tmp/mutation-base && bun install --frozen-lockfile && bunx stryker run)
92
- - run: bun run checks-mutation-compare --advisory /tmp/mutation-base/reports/mutation/mutation.json reports/mutation/mutation.json
95
+ base_worktree="$RUNNER_TEMP/mutation-base-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
96
+ echo "BASE_WORKTREE=$base_worktree" >> "$GITHUB_ENV"
97
+ git worktree prune
98
+ git worktree add "$base_worktree" "$(git merge-base HEAD origin/main)"
99
+ (cd "$base_worktree" && bun install --frozen-lockfile && bunx stryker run)
100
+ - run: bun run checks-mutation-compare --advisory "$BASE_WORKTREE/reports/mutation/mutation.json" reports/mutation/mutation.json
101
+ - if: always() && env.BASE_WORKTREE != ''
102
+ run: |
103
+ git worktree remove --force "$BASE_WORKTREE"
104
+ git worktree prune
93
105
  ```
94
106
 
107
+ Both Stryker runs are full sweeps, and GitHub sets `CI=true` on every runner, so the preset lets them through.
108
+ The base worktree's path carries the run's id and attempt, and the last step removes it even when a run fails, so a runner kept between jobs starts each job clean.
109
+ A public repository keeps `runs-on: ubuntu-latest`, because a pull request from a fork runs its own code on the runner.
110
+ A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || fromJSON('["self-hosted","Linux","X64","winbox"]') }}` instead.
111
+ With `CI_RUNS_ON` unset, the job then runs on the fleet's self-hosted Linux runner labelled `winbox`, which is where a private repository sends its full sweeps.
112
+
95
113
  ## Related topics
96
114
 
115
+ - [checks-mutation](checks-mutation.md)
97
116
  - [checks-test-layout](checks-test-layout.md)
@@ -0,0 +1,98 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-mutation
6
+
7
+ `checks-mutation` runs Stryker and refuses a full run outside CI.
8
+
9
+ ## What it checks
10
+
11
+ It refuses a full mutation run when `CI` is not `true`.
12
+ A full run is one with no `--mutate <glob>` and no `--incremental` flag.
13
+ A `--mutate` without a glob, `--incrementalFile` alone or `--incremental` with `--force` is still a full run.
14
+ Its refusal names `gh workflow run mutation` as the command that starts the same run in CI.
15
+ A run scoped with `--mutate <glob>`, `--mutate=<glob>` or `-m <glob>` stays allowed locally, because pull request comparisons scope to named files.
16
+ An incremental run stays allowed locally only when its incremental report exists, because it reuses the results of mutants that did not change.
17
+ That report is the `incrementalFile` Stryker resolves from the command line and the config file, else `reports/stryker-incremental.json`.
18
+ With no report there, the refusal says to pass `--mutate <glob>`, or to start the full baseline in CI with `gh workflow run mutation`.
19
+ `--help`, `-h` and `--version` are not runs, so they pass straight through to Stryker.
20
+ A laptop with `CI=true` set opts in to a full run on purpose, and the refusal lets it through.
21
+
22
+ The shared Stryker preset makes the same decision from `process.argv` when a `stryker run` loads it.
23
+ So a bare `bunx stryker run` in a repository whose `stryker.conf.mjs` spreads the preset is refused the same way, and `checks-mutation` is a thin wrapper over that decision.
24
+ The preset also registers an ignore plugin that checks the incremental report once Stryker has resolved its options, because a config file can set `incrementalFile` after the preset loads.
25
+ A config that replaces `plugins` or `ignorers` must keep the preset's entries, or that check does not run.
26
+
27
+ ## What it reads
28
+
29
+ It reads `CI` from the environment.
30
+ It forwards every argument to `stryker run` through `bun x stryker run`.
31
+
32
+ ## Arguments
33
+
34
+ ```sh
35
+ checks-mutation [--mutate <glob>] [--incremental] [<stryker args>...]
36
+ ```
37
+
38
+ Every argument after the bin name forwards to `stryker run`.
39
+
40
+ ## Exit codes
41
+
42
+ | Code | When |
43
+ | --- | --- |
44
+ | 0 | Stryker exited 0 |
45
+ | 1 | Stryker exited nonzero, including an incremental run refused for a missing report |
46
+ | 2 | a full run outside CI was refused, or Stryker could not start |
47
+
48
+ ## Sample output
49
+
50
+ A full run outside CI prints its refusal and exits 2:
51
+
52
+ ```
53
+ checks-mutation: refusing a full mutation run outside CI; start the same run in CI with `gh workflow run mutation`, or scope this run with `--mutate <glob>` or `--incremental`
54
+ ```
55
+
56
+ A bare `bunx stryker run` fails to load its config with the same refusal as the error and exits 1.
57
+ An `--incremental` run with no report stops before instrumenting with the missing-report refusal as the error and exits 1.
58
+
59
+ ## When it runs
60
+
61
+ A repository runs full baselines from the mutation workflow on `workflow_dispatch`.
62
+ Run scoped checks locally during development.
63
+ A scheduled run never starts one, because a baseline costs a full Stryker run.
64
+
65
+ ## Running it in CI
66
+
67
+ A repository starts a baseline by hand from a workflow named `mutation` and keeps its report as an artifact:
68
+
69
+ ```yaml
70
+ name: mutation
71
+ on:
72
+ workflow_dispatch:
73
+ jobs:
74
+ mutation:
75
+ runs-on: ${{ vars.CI_RUNS_ON || fromJSON('["self-hosted","Linux","X64","winbox"]') }}
76
+ steps:
77
+ - uses: actions/checkout@v5
78
+ - uses: oven-sh/setup-bun@v2
79
+ with:
80
+ bun-version-file: .bun-version
81
+ - run: bun install --frozen-lockfile
82
+ - run: bunx stryker run
83
+ - uses: actions/upload-artifact@v4
84
+ if: always()
85
+ with:
86
+ name: mutation-report
87
+ path: reports/mutation/mutation.json
88
+ ```
89
+
90
+ With `CI_RUNS_ON` unset, the job runs on the fleet's self-hosted Linux runner labelled `winbox`, which is where a private repository sends its full sweeps.
91
+ A repository sets `CI_RUNS_ON` only to name a different runner.
92
+ The `name: mutation` line is what `gh workflow run mutation` looks up.
93
+ GitHub sets `CI=true` on every runner, so the preset lets the full run through there.
94
+
95
+ ## Related topics
96
+
97
+ - [checks-mutation-compare](checks-mutation-compare.md)
98
+ - [checks-subsumed-tests](checks-subsumed-tests.md)
@@ -30,6 +30,8 @@ Build a bail-off report with this command:
30
30
  bunx stryker run --disableBail
31
31
  ```
32
32
 
33
+ It is a full sweep, so outside CI the shared preset refuses it unless `--mutate <glob>` scopes it, as [checks-mutation](checks-mutation.md) says.
34
+
33
35
  The report records each killer as a test index, so it names each test by its file and its name from the report's `testFiles` table.
34
36
 
35
37
  ## Arguments
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,6 +36,9 @@
36
36
  "src/testing/test-report.ts",
37
37
  "src/testing/flake.ts",
38
38
  "src/delivery/commit-identity.ts",
39
+ "src/testing/mutation.ts",
40
+ "src/testing/mutation-scope.js",
41
+ "src/testing/mutation-guard-plugin.js",
39
42
  "src/testing/mutation-compare.ts",
40
43
  "src/testing/subsumed-tests.ts",
41
44
  "src/delivery/ci-wiring.ts",
@@ -95,6 +98,7 @@
95
98
  "checks-test": "src/testing/test.ts",
96
99
  "checks-flake": "src/testing/flake.ts",
97
100
  "checks-commit-identity": "src/delivery/commit-identity.ts",
101
+ "checks-mutation": "src/testing/mutation.ts",
98
102
  "checks-mutation-compare": "src/testing/mutation-compare.ts",
99
103
  "checks-subsumed-tests": "src/testing/subsumed-tests.ts",
100
104
  "checks-ci-wiring": "src/delivery/ci-wiring.ts",
@@ -8,6 +8,7 @@ import {
8
8
  ruleProblem,
9
9
  type Heading,
10
10
  type Outline,
11
+ type Section,
11
12
  type Violation,
12
13
  VERSION,
13
14
  } from "./doc-outline.ts";
@@ -93,8 +94,76 @@ function recordNumber(path: string): number | undefined {
93
94
  return name === undefined ? undefined : Number(name);
94
95
  }
95
96
 
97
+ const REVISION_CLAUSE = /\b(?:amend(?:s|ed)|narrow(?:s|ed)|supersede[sd])\b(?:[^.]|\.(?=\S))*/gi;
98
+ const RECORD_REFERENCE = /\b(\d{4})\b/g;
99
+
100
+ function numbersIn(text: string): ReadonlySet<number> {
101
+ return new Set(Array.from(text.matchAll(RECORD_REFERENCE), (match) => Number(match[1])));
102
+ }
103
+
104
+ function revisedIn(status: string): ReadonlySet<number> {
105
+ return new Set((status.match(REVISION_CLAUSE) ?? []).flatMap((clause) => [...numbersIn(clause)]));
106
+ }
107
+
108
+ function padded(number: number): string {
109
+ return String(number).padStart(4, "0");
110
+ }
111
+
112
+ function outlineOf(text: string): Outline {
113
+ return parseOutline(text.slice(frontMatterOf(text).text.length));
114
+ }
115
+
116
+ function statusSection(outline: Outline): Section | undefined {
117
+ return outline.sections.find(({ heading }) => heading.title === "Status");
118
+ }
119
+
120
+ type StatusLinks = {
121
+ readonly cited: ReadonlySet<number>;
122
+ readonly revised: ReadonlySet<number>;
123
+ };
124
+
125
+ function statusLinks(section: Section): StatusLinks {
126
+ const text = section.body.map(({ text: body }) => body).join("\n");
127
+ return { cited: numbersIn(text), revised: revisedIn(text) };
128
+ }
129
+
130
+ export type Records = {
131
+ readonly paths: readonly string[];
132
+ readonly links: ReadonlyMap<number, StatusLinks>;
133
+ };
134
+
135
+ export function recordsOf(records: readonly Doc[]): Records {
136
+ const links = new Map<number, StatusLinks>();
137
+ for (const { path, text } of records) {
138
+ const number = recordNumber(path);
139
+ const section = number === undefined || links.has(number) ? undefined : statusSection(outlineOf(text));
140
+ if (number !== undefined && section !== undefined) links.set(number, statusLinks(section));
141
+ }
142
+ return { paths: records.map(({ path }) => path), links };
143
+ }
144
+
145
+ function pairProblems(filed: number, own: Section, mine: StatusLinks, number: number, other: StatusLinks): readonly Violation[] {
146
+ if (number === filed) return [];
147
+ if (mine.revised.has(number) && !other.cited.has(filed)) {
148
+ const line = own.body.find(({ text: body }) => body.includes(padded(number)))?.line ?? own.heading.line;
149
+ return [{ line, message: `\`## Status\` names ${padded(number)} without ${padded(number)} naming ${padded(filed)} back` }];
150
+ }
151
+ if (other.revised.has(filed) && !mine.cited.has(number)) {
152
+ return [{ line: own.heading.line, message: `\`## Status\` is named by ${padded(number)} without naming ${padded(number)} back` }];
153
+ }
154
+ return [];
155
+ }
156
+
157
+ function revisionLinkProblems(path: string, outline: Outline, links: ReadonlyMap<number, StatusLinks>): readonly Violation[] {
158
+ const filed = recordNumber(path);
159
+ const own = filed === undefined ? undefined : statusSection(outline);
160
+ if (filed === undefined || own === undefined) return [];
161
+ const mine = statusLinks(own);
162
+ return [...links].flatMap(([number, other]) => pairProblems(filed, own, mine, number, other));
163
+ }
164
+
96
165
  function statusProblem(outline: Outline): Violation | undefined {
97
- const status = outline.sections.find(({ heading }) => heading.title === "Status");
166
+ const status = statusSection(outline);
98
167
  if (status === undefined) return undefined;
99
168
  const opening = firstText(status.body);
100
169
  const word = opening?.text.trim().split(/\s+/, 1)[0]?.replace(/[.,;:]+$/, "");
@@ -126,7 +195,7 @@ function sharedNumberProblem(path: string, filed: number | undefined, records: r
126
195
  return sharing.length === 0 ? undefined : { line: 1, message: `shares number ${filed} with ${sharing.join(", ")}` };
127
196
  }
128
197
 
129
- function adrProblems(path: string, outline: Outline, records: readonly string[]): readonly Violation[] {
198
+ function adrProblems(path: string, outline: Outline, records: Records): readonly Violation[] {
130
199
  const filed = recordNumber(path);
131
200
  const misnamed: Violation | undefined =
132
201
  filed === undefined
@@ -137,7 +206,8 @@ function adrProblems(path: string, outline: Outline, records: readonly string[])
137
206
  recordTitleProblem(outline, filed),
138
207
  recordDateProblem(outline),
139
208
  statusProblem(outline),
140
- sharedNumberProblem(path, filed, records),
209
+ sharedNumberProblem(path, filed, records.paths),
210
+ ...revisionLinkProblems(path, outline, records.links),
141
211
  ].filter((violation) => violation !== undefined);
142
212
  }
143
213
 
@@ -167,7 +237,7 @@ function stepsProblems(kind: Kind, { prose }: Outline): readonly Violation[] {
167
237
  return [{ line: 1, message: `numbers no steps, which a ${kind} page lists as \`1.\` items` }];
168
238
  }
169
239
 
170
- function kindProblems(kind: Kind, doc: Doc, outline: Outline, records: readonly string[]): readonly Violation[] {
240
+ function kindProblems(kind: Kind, doc: Doc, outline: Outline, records: Records): readonly Violation[] {
171
241
  if (kind === "adr") return adrProblems(doc.path, outline, records);
172
242
  if (kind === "changelog") return changelogProblems(outline);
173
243
  if (kind === "how-to" || kind === "tutorial") return stepsProblems(kind, outline);
@@ -182,7 +252,7 @@ function exactProblems(kind: Kind, expected: string, actual: string): readonly V
182
252
  return [{ line, message: `differs from ${templateFile(kind)}, which it holds word for word` }];
183
253
  }
184
254
 
185
- export function judge(kind: Kind, doc: Doc, records: readonly string[]): readonly Violation[] {
255
+ export function judge(kind: Kind, doc: Doc, records: Records): readonly Violation[] {
186
256
  const template = TEMPLATES[kind];
187
257
  if (template.shape === "exact") return exactProblems(kind, template.text, doc.text);
188
258
  const frontMatter = frontMatterOf(doc.text).text;
package/src/docs/docs.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Console, Effect } from "effect";
3
3
  import { ceilingFinding, entryFindings, isAgentFile, maintainingFinding } from "./doc-agents.ts";
4
4
  import { rootsOf, snapshotOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
5
- import { ADR_DIRECTORY, judge, placementOf, placementProblem, speaksToConsumers, type Placement } from "./doc-rules.ts";
5
+ import { ADR_DIRECTORY, judge, placementOf, placementProblem, recordsOf, speaksToConsumers, type Placement, type Records } from "./doc-rules.ts";
6
6
  import { vanishedNames } from "./doc-names.ts";
7
7
  import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
8
8
  import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
@@ -40,7 +40,7 @@ const NAME = "docs";
40
40
  const USAGE = "usage: docs.ts <ref> | <base-ref> <head-ref>";
41
41
  const MARKDOWN = [":(glob)**/*.md"];
42
42
 
43
- function templateFindings(path: string, text: string, placement: Placement, records: readonly string[]): readonly Finding[] {
43
+ function templateFindings(path: string, text: string, placement: Placement, records: Records): readonly Finding[] {
44
44
  const misplaced = placementProblem(placement);
45
45
  if (misplaced !== undefined) return [{ path, line: undefined, message: misplaced }];
46
46
  if (placement.type !== "judged") return [];
@@ -91,13 +91,13 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
91
91
  const renamedFrom = new Map(changes.flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from] as const] : [])));
92
92
  const changed = yield* changedLines(base, head, MARKDOWN, root);
93
93
  const present = yield* pathsAt(head, MARKDOWN, root);
94
- const records = present.filter((path) => path.startsWith(ADR_DIRECTORY));
95
94
  const proseDocs = present.flatMap((path) => {
96
95
  const reader = readerOf(path);
97
96
  return reader === undefined ? [] : [{ path, reader }];
98
97
  });
99
98
  const texts = yield* readTexts(root, head, [...new Set([...present.filter((path) => path.endsWith(".md")), ...proseDocs.map(({ path }) => path)])]);
100
99
  const text = (path: string): string => texts.get(path) ?? "";
100
+ const records = recordsOf(present.filter((path) => path.startsWith(ADR_DIRECTORY)).map((path) => ({ path, text: text(path) })));
101
101
  const judged = present.map((path) => ({ path, placement: placementOf(path, text(path)) })).filter(({ placement }) => placement.type !== "unjudged");
102
102
 
103
103
  const templated = judged.flatMap(({ path, placement }) => templateFindings(path, text(path), placement, records));
@@ -0,0 +1,13 @@
1
+ import { missingReportRefusal } from "./mutation-scope.js";
2
+
3
+ export const GUARD_IGNORER = "checks-incremental-report-guard";
4
+
5
+ // An ignorer is built before instrumentation from the resolved options, so a throw here stops the run before any mutant.
6
+ function incrementalReportGuard(options) {
7
+ const refusal = missingReportRefusal(process.argv.slice(3), process.env.CI ?? "", options.incrementalFile);
8
+ if (refusal !== undefined) throw new Error(refusal);
9
+ return { shouldIgnore: () => undefined };
10
+ }
11
+ incrementalReportGuard.inject = ["options"];
12
+
13
+ export const strykerPlugins = [{ kind: "Ignore", name: GUARD_IGNORER, factory: incrementalReportGuard }];
@@ -0,0 +1,27 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ const NON_RUNS = ["--help", "-h", "--version"];
4
+ const MUTATE_FLAGS = ["--mutate", "-m"];
5
+
6
+ function namesGlob(args) {
7
+ return args.some(
8
+ (arg, index) =>
9
+ (MUTATE_FLAGS.includes(arg) && (args[index + 1] ?? "") !== "") ||
10
+ (arg.startsWith("--mutate=") && arg !== "--mutate="),
11
+ );
12
+ }
13
+
14
+ function allowedAnyway(args, ci) {
15
+ return ci === "true" || args.some((arg) => NON_RUNS.includes(arg)) || namesGlob(args);
16
+ }
17
+
18
+ export function fullRunRefusal(args, ci) {
19
+ if (allowedAnyway(args, ci) || (args.includes("--incremental") && !args.includes("--force"))) return undefined;
20
+ return "refusing a full mutation run outside CI; start the same run in CI with `gh workflow run mutation`, or scope this run with `--mutate <glob>` or `--incremental`";
21
+ }
22
+
23
+ // Only the resolved options know the incrementalFile a consumer config sets, so this runs after config load.
24
+ export function missingReportRefusal(args, ci, incrementalFile, reportExists = existsSync) {
25
+ if (allowedAnyway(args, ci) || reportExists(incrementalFile)) return undefined;
26
+ return `refusing a full mutation run outside CI: \`--incremental\` finds no report at ${incrementalFile} to reuse; scope this run with \`--mutate <glob>\`, or start the full baseline in CI with \`gh workflow run mutation\``;
27
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bun
2
+ import { Config, Effect, Schema } from "effect";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
+ import { runMain } from "../core/main.ts";
5
+ import { fullRunRefusal } from "./mutation-scope.js";
6
+
7
+ export class MutationError extends Schema.TaggedError<MutationError>()("MutationError", {
8
+ message: Schema.String,
9
+ }) {}
10
+
11
+ const readCi = Config.String("CI").pipe(
12
+ Config.withDefault(""),
13
+ Effect.mapError((cause) => new MutationError({ message: `cannot read CI: ${cause.message}` })),
14
+ );
15
+
16
+ const runStryker = Effect.fn("runStryker")(function* (args: readonly string[]) {
17
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
18
+ const exitCode = yield* spawner.exitCode(
19
+ ChildProcess.make(process.execPath, ["x", "stryker", "run", ...args], {
20
+ stdin: "ignore",
21
+ stdout: "inherit",
22
+ stderr: "inherit",
23
+ }),
24
+ );
25
+ return exitCode === ChildProcessSpawner.ExitCode(0);
26
+ });
27
+
28
+ const mutation = Effect.gen(function* () {
29
+ const args = process.argv.slice(2);
30
+ const refusal = fullRunRefusal(args, yield* readCi);
31
+ if (refusal !== undefined) return yield* new MutationError({ message: refusal });
32
+ return yield* runStryker(args);
33
+ });
34
+
35
+ if (import.meta.main) runMain("checks-mutation", mutation);
package/stryker.preset.js CHANGED
@@ -1,6 +1,19 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { GUARD_IGNORER } from "./src/testing/mutation-guard-plugin.js";
3
+ import { fullRunRefusal } from "./src/testing/mutation-scope.js";
4
+
5
+ const [, , command, ...args] = process.argv;
6
+ const refusal = command === "run" ? fullRunRefusal(args, process.env.CI ?? "") : undefined;
7
+ if (refusal !== undefined) throw new Error(refusal);
8
+
1
9
  export default {
2
10
  packageManager: "npm",
3
- plugins: ["@stryker-mutator/*", "@hughescr/stryker-bun-runner"],
11
+ plugins: [
12
+ "@stryker-mutator/*",
13
+ "@hughescr/stryker-bun-runner",
14
+ fileURLToPath(new URL("./src/testing/mutation-guard-plugin.js", import.meta.url)),
15
+ ],
16
+ ignorers: [GUARD_IGNORER],
4
17
  testRunner: "bun",
5
18
  bun: { timeout: 60000 },
6
19
  inPlace: true,