@avi2dg/checks 0.28.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,27 @@
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
+
14
+ ## 0.29.0
15
+
16
+ Released 2026-09-28.
17
+
18
+ ### Breaking changes
19
+
20
+ - **docs:** hold agent files to a 3,000-character router where every entry points [#101](https://github.com/avi2d/checks/pull/101)
21
+
22
+ ### Fixes
23
+
24
+ - **testing:** run checks-test with CI=true so focused tests fail locally [#100](https://github.com/avi2d/checks/pull/100)
25
+
5
26
  ## 0.28.0
6
27
 
7
28
  Released 2026-09-28.
package/README.md CHANGED
@@ -124,7 +124,7 @@ The table groups the gates by vector, the part of a repository each one judges.
124
124
  | quality | [`checks-comment-gate`](docs/gates/checks-comment-gate.md) | the range | every repository |
125
125
  | testing | [`checks-test-layout`](docs/gates/checks-test-layout.md) | the working tree | a repository tracking `*.ts` or `*.tsx` |
126
126
  | testing | [`checks-quarantine-clock`](docs/gates/checks-quarantine-clock.md) | the range | every repository |
127
- | docs | [`checks-docs`](docs/gates/checks-docs.md) | the range | every repository |
127
+ | docs | [`checks-docs`](docs/gates/checks-docs.md) | the range, and every agent file at the head commit | every repository |
128
128
  | delivery | [`checks-commit-identity`](docs/gates/checks-commit-identity.md) | the range | every repository |
129
129
  | delivery | [`checks-ci-wiring`](docs/gates/checks-ci-wiring.md) | the working tree | every repository |
130
130
  | dependencies | [`checks-advisories`](docs/gates/checks-advisories.md) | the range | a repository tracking `bun.lock` |
@@ -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
 
@@ -7,11 +7,3 @@
7
7
  <Leave this section out when the lead holds every constraint.>
8
8
 
9
9
  - <A constraint an agent cannot infer from the code, and the file that holds its detail.>
10
-
11
- ## Maintaining this file
12
-
13
- Keep this file for knowledge useful to almost every future agent session in this project.
14
- Do not repeat what the codebase already shows.
15
- Point to the authoritative file or command instead.
16
- Prefer rewriting or pruning existing entries over appending new ones.
17
- When updating this file, preserve this bar for all agents and keep entries concise.
package/docs/design.md CHANGED
@@ -86,6 +86,7 @@ npm adds `package.json`, `README.md` and `LICENSE` whatever `files` says.
86
86
  `bun pm pack` builds the same tarball the registry serves, and the consumer e2e test installs that tarball.
87
87
 
88
88
  Each oxlint plugin ships compiled under `dist/`, because Node refuses to strip types from a `.ts` file under `node_modules`.
89
+ `@oxlint/plugins` ships no RuleTester, so each `effect-channel`, `readability` and `data-shape` rule is proven red and green against an installed consumer in `tests/e2e/consumer.test.ts`.
89
90
  `dist/` is committed, with the doc templates in `dist/templates/`, and so is `CHANGELOG.md`, which the same build writes.
90
91
  No `prepack` or `prepublishOnly` script rebuilds them, so a publish ships the committed files.
91
92
  CI runs `git diff --exit-code` over the whole tree after `bun run build`.
@@ -140,7 +141,8 @@ That flag reports a repeated block as new once its text changes, so a change tha
140
141
  ## checks-test runs the suite itself
141
142
 
142
143
  `checks-test` runs bun itself rather than reading a report that another run left.
143
- A skip taken only on CI shows only in CI's own run, and an earlier run's report may be stale or narrowed.
144
+ A `"ci"` skip gated on a daemon or tool that CI lacks shows in CI's own run, and an earlier run's report may be stale or narrowed.
145
+ When the same resource is also absent locally, the test skips there too, and its `"ci"` declaration leaves that skip undeclared, so the local run fails.
144
146
  It reads the JUnit report bun writes to a temporary directory, because bun has no other per-test output meant for a program.
145
147
 
146
148
  ## Quarantine has one limit
@@ -175,7 +177,8 @@ A repository adopts the templates as its files change, and an untouched file is
175
177
  The prose rules judge only the lines a change adds or edits.
176
178
  A report about the past is refused the way a promise about the future is, because history on a living page reads as current fact.
177
179
  Text nobody touched never breaks the templates or the prose rules, and a record keeps the words it was written in.
178
- A repository needs no cleanup pass before the gate runs.
180
+ A repository needs no cleanup pass before the gate runs, except on its agent files.
181
+ The ceiling, the rule against a `## Maintaining this file` section and the entry rule judge every agent file at the head commit, because an agent reads the whole file every session, touched or not.
179
182
  Review, not the check, keeps a task heading verb first.
180
183
  No word list tells `Test layout` from `Test the layout`, and a check that passes the noun would be worse than none.
181
184
 
@@ -13,6 +13,8 @@ It holds each doc file a change touches to the template for its kind.
13
13
  It lists every other doc file that does not match its template yet, and does not fail on it.
14
14
  It holds each line a change adds or edits in a living doc or an agent file to the prose rules, as [The prose rules](#the-prose-rules) says.
15
15
  It fails when a living doc or an agent file names a path, link or command that does not resolve, and the range added or broke it.
16
+ It fails when an agent file holds more than 3,000 characters or a `## Maintaining this file` section, whatever the range touches, as [Agent files](#agent-files) says.
17
+ It fails when an entry in an agent file names no tracked path, link or `bun run` command, whatever the range touches.
16
18
  It fails when a living doc or an agent file names a code span the range removed from every file outside the docs, on any line.
17
19
  [Paths, links and commands](#paths-links-and-commands) says how each reference resolves.
18
20
  The package ships one template per kind under `dist/templates/`, and a repository starts a new doc file by copying one:
@@ -59,6 +61,7 @@ A template decides a file's structure, and the template file itself is the refer
59
61
  A `Date: YYYY-MM-DD` line follows the title.
60
62
  The first word under Status is Proposed, Accepted, Rejected, Deprecated, Superseded or Retired.
61
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.
62
65
  - A changelog lists its releases newest first, and each opens with a `Released YYYY-MM-DD.` line.
63
66
  - A how-to or tutorial page numbers its steps.
64
67
  - `CLAUDE.md` is its template word for word.
@@ -142,6 +145,24 @@ audience: consumers
142
145
  ---
143
146
  ```
144
147
 
148
+ ## Agent files
149
+
150
+ An agent file holds the router its template sketches, and the rules below hold its shape whatever the range touches.
151
+ A file over 3,000 characters fails.
152
+ Move each part's notes into the people doc that covers that part, and delete what a check or the code already holds.
153
+ A file that holds a `## Maintaining this file` section fails, because this gate holds the shape the section asked for.
154
+ Each entry names at least one of these, or it fails:
155
+
156
+ - A path in inline code that git tracks at the head commit, a file or a directory, such as `package.json`, `LICENSE` or `.gitignore`.
157
+ It resolves from the root or from the file's directory, `./` and `../` included, and a path that ends in `/` or `/.` names a directory, never a file.
158
+ - A Markdown link written `[text](target)` with a destination and a closing parenthesis, and not an image.
159
+ - A `bun run` command.
160
+
161
+ Whether the link or the command resolves is the reference rule's call, as [Paths, links and commands](#paths-links-and-commands) says, and it fails when the range adds or breaks one.
162
+ An entry is any list item a reader sees, the items above the first section included.
163
+ A list item inside an HTML comment, an HTML block or an indented code block is not an entry.
164
+ A fresh file passes the ceiling, and its entries pass once each names the file that holds its detail.
165
+
145
166
  ## What it reads
146
167
 
147
168
  It reads each Markdown file at the head commit, and uses its path or front matter to choose its kind.
@@ -150,6 +171,7 @@ It reads the lines the range adds or edits from the diff, with renames detected,
150
171
  It reads the files tracked at both ends of the range, and the `scripts` of each `package.json` a living doc or an agent file sits under.
151
172
  It compares each code span a living doc or an agent file names with the text git tracks outside the docs at both ends of the range.
152
173
  It reads the repository's own name and its direct dependencies from the root `package.json` at the head commit.
174
+ It reads each agent file at the head commit for the ceiling, its sections and its entries, whatever the range touches.
153
175
  A name that an installed direct dependency still holds counts as present.
154
176
  From the working tree it reads the ignore files git reads, `node_modules/.bin`, and the directory of each direct dependency under `node_modules`.
155
177
 
@@ -167,8 +189,8 @@ With one it is that commit against its parent, or against the empty tree for a r
167
189
 
168
190
  | Code | When |
169
191
  | --- | --- |
170
- | 0 | every doc file the range touches holds to its template, every line it adds to a living doc or an agent file holds to the prose rules, it adds or breaks no reference that does not resolve, and no code span a living doc or an agent file names vanished from every file outside the docs |
171
- | 1 | a doc file the range touches does not hold to its template, a line the range adds to a living doc or an agent file breaks a prose rule, the range adds or breaks a reference that does not resolve, or the range removes a name a living doc or an agent file still carries |
192
+ | 0 | every doc file the range touches holds to its template, every line it adds to a living doc or an agent file holds to the prose rules, it adds or breaks no reference that does not resolve, every agent file holds to the ceiling and holds no `## Maintaining this file`, every entry in an agent file names a tracked path, a link or a command, and no code span a living doc or an agent file names vanished from every file outside the docs |
193
+ | 1 | a doc file the range touches does not hold to its template, a line the range adds to a living doc or an agent file breaks a prose rule, the range adds or breaks a reference that does not resolve, an agent file is over the ceiling or holds `## Maintaining this file`, an entry in an agent file names no tracked path, link or command, or the range removes a name a living doc or an agent file still carries |
172
194
  | 2 | a `package.json` does not decode, a ref does not resolve, or `grep` cannot read an installed direct dependency |
173
195
 
174
196
  ## Sample output
@@ -191,6 +213,7 @@ docs: advisory, 1 path(s), link(s) or command(s) the living docs or agent files
191
213
 
192
214
  `checks-lint` runs it over each pull request's range in every repository, as [checks-lint](checks-lint.md) says.
193
215
  A repository adopts the templates as its files change, and the prose rules as its lines change, because an untouched file or line never fails those checks.
216
+ It reshapes its agent files when it adopts the gate, because [Agent files](#agent-files) judges each one whatever the range touches.
194
217
  A name the range removes fails wherever a doc still carries it, because the removal is what turned the line stale.
195
218
 
196
219
  ## Related topics
@@ -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)
@@ -12,6 +12,7 @@ The gate runs jscpd at 50 tokens and 5 lines against the base and head revisions
12
12
  It compares repeated lines for each file, so a decrease in another file never offsets a rise.
13
13
  It follows an edited rename back to the original file.
14
14
  A file that repeats lines without a rise is advisory.
15
+ A block two bins need goes into a module both import, as `rangeFromArgs` and `checkoutFiles` in `src/core/git.ts` show.
15
16
 
16
17
  ## What it reads
17
18
 
@@ -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
@@ -9,6 +9,7 @@ audience: consumers
9
9
  ## What it checks
10
10
 
11
11
  It runs the default suite with `bun test --randomize` and reads Bun's JUnit report from that run.
12
+ It runs Bun with `CI=true`, so `test.only` fails the run in every environment.
12
13
  Bun exits zero when tests skip, so `checks-test` checks every skipped test against its source declaration.
13
14
  A test that `test.skip`, `test.skipIf`, `test.if`, `test.todo` or an enclosing `describe.skip` skips fails unless the test declares its reason.
14
15
 
@@ -33,6 +34,8 @@ A skip is declared on the test itself and only there.
33
34
  Add `"ci"` or `"local"` as the third `skipReason` argument when a declaration applies to one environment.
34
35
  Omit the third argument when it applies in both environments.
35
36
  A declaration for the other environment is not judged in the current run.
37
+ Bun sees `CI` set in every `checks-test` run, so a `"ci"` skip whose condition reads `process.env.CI` also skips in a local run and fails there as undeclared.
38
+ Gate a `"ci"` skip on what CI lacks, such as a daemon or a tool, and never on `process.env.CI`.
36
39
 
37
40
  A declaration whose test passes or does not register fails a CI run.
38
41
  A local run warns about the same declaration because a condition can depend on the machine.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.28.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",
@@ -57,6 +60,7 @@
57
60
  "src/dependencies/advisories.ts",
58
61
  "src/dependencies/advisory-rules.ts",
59
62
  "src/dependencies/osv-scanner.ts",
63
+ "src/docs/doc-agents.ts",
60
64
  "src/docs/doc-outline.ts",
61
65
  "src/docs/doc-rules.ts",
62
66
  "src/docs/doc-references.ts",
@@ -94,6 +98,7 @@
94
98
  "checks-test": "src/testing/test.ts",
95
99
  "checks-flake": "src/testing/flake.ts",
96
100
  "checks-commit-identity": "src/delivery/commit-identity.ts",
101
+ "checks-mutation": "src/testing/mutation.ts",
97
102
  "checks-mutation-compare": "src/testing/mutation-compare.ts",
98
103
  "checks-subsumed-tests": "src/testing/subsumed-tests.ts",
99
104
  "checks-ci-wiring": "src/delivery/ci-wiring.ts",
@@ -27,7 +27,7 @@ const hasKnipConfig = Effect.fn("hasKnipConfig")(function* (root: string) {
27
27
  const manifest: unknown = yield* fs.readFileString(path.join(root, "package.json")).pipe(
28
28
  Effect.flatMap((text) =>
29
29
  Effect.try({
30
- try: () => JSON.parse(text),
30
+ try: (): unknown => JSON.parse(text),
31
31
  catch: () => new KnipError({ message: "package.json does not parse as JSON" }),
32
32
  })
33
33
  ),
package/src/core/gates.ts CHANGED
@@ -19,6 +19,7 @@ export type KitGate = {
19
19
  readonly vector: Vector;
20
20
  readonly file: string;
21
21
  readonly reads: "tree" | "range";
22
+ readonly alsoReads?: string;
22
23
  readonly args?: readonly string[];
23
24
  readonly appliesTo: typeof EVERY_REPOSITORY | TrackedContent;
24
25
  };
@@ -40,7 +41,7 @@ export const KIT_GATES = [
40
41
  { bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
41
42
  { bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
42
43
  { bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
43
- { bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
44
+ { bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", alsoReads: "every agent file at the head commit", appliesTo: EVERY_REPOSITORY },
44
45
  { bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
45
46
  { bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
46
47
  { bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
@@ -0,0 +1,106 @@
1
+ import { commandNames, type Snapshot } from "./doc-references.ts";
2
+ import { AGENT_NAMES, scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
3
+
4
+ export const AGENT_CEILING = 3000;
5
+
6
+ const LIST_ITEM = /^(?:\s*>)*\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
7
+ const INDENT = /^[ \t]*/;
8
+ const CODE_INDENT = 4;
9
+ const INLINE_LINK =
10
+ /(?<![!\\])\[(?:[^[\]\\]|\\.|\[[^\]]*\])*\]\(\s*(?:<[^<>\n]+>|[^\s()<>]+(?:\([^\s()]*\)[^\s()<>]*)*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
11
+ const MAINTAINING = /^\s{0,3}#{1,6}\s+Maintaining this file(?:\s+#+)?\s*$/;
12
+
13
+ export type AgentFinding = {
14
+ readonly line: number | undefined;
15
+ readonly message: string;
16
+ };
17
+
18
+ export function isAgentFile(repositoryPath: string): boolean {
19
+ return AGENT_NAMES.includes(repositoryPath.slice(repositoryPath.lastIndexOf("/") + 1));
20
+ }
21
+
22
+ export function ceilingFinding(text: string): AgentFinding | undefined {
23
+ if (text.length <= AGENT_CEILING) return undefined;
24
+ return {
25
+ line: undefined,
26
+ message: `is ${text.length} characters, over the 3,000-character ceiling for agent files. Keep what nearly every session needs plus one pointer per part, move each part's notes into the people doc that covers that part, and delete what a check already holds`,
27
+ };
28
+ }
29
+
30
+ export function maintainingFinding(text: string): AgentFinding | undefined {
31
+ const heading = scanMarkdown(text).find(({ kind, raw }) => kind === "heading" && MAINTAINING.test(raw));
32
+ if (heading === undefined) return undefined;
33
+ return {
34
+ line: heading.line,
35
+ message: "holds `## Maintaining this file`, which a router leaves out. Delete the section, since checks-docs holds the file's shape",
36
+ };
37
+ }
38
+
39
+ function indentOf(raw: string): number {
40
+ return (INDENT.exec(raw)?.[0] ?? "").replaceAll("\t", " ").length;
41
+ }
42
+
43
+ export function entries(text: string): readonly MarkdownLine[] {
44
+ let inList = false;
45
+ let afterBlank = true;
46
+ return scanMarkdown(text).filter((line) => {
47
+ const blank = line.raw.trim() === "";
48
+ const indented = !blank && indentOf(line.raw) >= CODE_INDENT && !line.raw.trimStart().startsWith(">");
49
+ const listItem = line.kind === "prose" && LIST_ITEM.test(line.prose) && (inList || !indented);
50
+ if (listItem) inList = true;
51
+ else if (!blank && !indented && (afterBlank || line.kind !== "prose")) inList = false;
52
+ afterBlank = blank;
53
+ return listItem;
54
+ });
55
+ }
56
+
57
+ type Tracked = Pick<Snapshot, "files" | "directories">;
58
+
59
+ const STAYS = new Set(["", "."]);
60
+
61
+ function joined(directory: string, segment: string): string {
62
+ return directory === "" ? segment : `${directory}/${segment}`;
63
+ }
64
+
65
+ function enter(directory: string, segment: string, tracked: Tracked): string | undefined {
66
+ if (STAYS.has(segment)) return directory;
67
+ if (segment === "..") return directory === "" ? undefined : directory.slice(0, Math.max(directory.lastIndexOf("/"), 0));
68
+ const next = joined(directory, segment);
69
+ return tracked.directories.has(next) ? next : undefined;
70
+ }
71
+
72
+ function resolvesFrom(directory: string, span: string, tracked: Tracked): boolean {
73
+ const segments = span.split("/");
74
+ const last = segments.pop() ?? "";
75
+ const walked = segments.reduce<string | undefined>((at, segment) => (at === undefined ? undefined : enter(at, segment, tracked)), directory);
76
+ if (walked === undefined) return false;
77
+ if (!STAYS.has(last) && last !== ".." && tracked.files.has(joined(walked, last))) return true;
78
+ const end = enter(walked, last, tracked);
79
+ return end !== undefined && end !== "" && end !== directory;
80
+ }
81
+
82
+ function namesTrackedPath(agentFile: string, span: string, tracked: Tracked): boolean {
83
+ const directory = agentFile.includes("/") ? agentFile.slice(0, agentFile.lastIndexOf("/")) : "";
84
+ const fromFile = resolvesFrom(directory, span, tracked);
85
+ return fromFile || (!span.startsWith("./") && !span.startsWith("../") && resolvesFrom("", span, tracked));
86
+ }
87
+
88
+ function namesLink({ raw, prose }: MarkdownLine): boolean {
89
+ return [...raw.matchAll(INLINE_LINK)].some(({ index }) => prose.charAt(index) === "[");
90
+ }
91
+
92
+ function points(agentFile: string, line: MarkdownLine, tracked: Tracked): boolean {
93
+ return line.code.some((span) => namesTrackedPath(agentFile, span, tracked)) || namesLink(line) || commandNames(line).length > 0;
94
+ }
95
+
96
+ export function entryFindings(agentFile: string, text: string, tracked: Tracked): readonly AgentFinding[] {
97
+ return entries(text).flatMap((line) => {
98
+ if (points(agentFile, line, tracked)) return [];
99
+ return [
100
+ {
101
+ line: line.line,
102
+ message: "names no tracked path, link or `bun run` command. Name the file, link or command that holds the detail",
103
+ },
104
+ ];
105
+ });
106
+ }
@@ -131,6 +131,10 @@ function commandsOn({ kind, raw, code }: MarkdownLine): readonly string[] {
131
131
  return texts.flatMap((text) => [...text.matchAll(RUN)].map(([, name = ""]) => name.replace(/[),.;:]+$/, "")));
132
132
  }
133
133
 
134
+ export function commandNames(line: MarkdownLine): readonly string[] {
135
+ return commandsOn(line).filter((name) => !NOT_A_NAME.test(name));
136
+ }
137
+
134
138
  export type Judging = { readonly commands: boolean };
135
139
 
136
140
  export function unresolvedIn(doc: string, text: string, snapshot: Snapshot, { commands }: Judging): readonly Unresolved[] {
@@ -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;
@@ -57,14 +57,6 @@ const BEFORE_YOU_BEGIN = fixed("Before you begin", REQUIRED, ["- <each prerequis
57
57
 
58
58
  const STEPS = ["To <do the task>:", "", "1. <step>", "1. <step>"];
59
59
 
60
- const MAINTAINING = [
61
- "Keep this file for knowledge useful to almost every future agent session in this project.",
62
- "Do not repeat what the codebase already shows.",
63
- "Point to the authoritative file or command instead.",
64
- "Prefer rewriting or pruning existing entries over appending new ones.",
65
- "When updating this file, preserve this bar for all agents and keep entries concise.",
66
- ];
67
-
68
60
  export const CHANGE_GROUPS = ["Breaking changes", "Features", "Fixes", "Performance", "Reverts"] as const;
69
61
  export type ChangeGroup = (typeof CHANGE_GROUPS)[number];
70
62
 
@@ -122,7 +114,6 @@ export const TEMPLATES: Readonly<Record<Kind, Template>> = {
122
114
  open("<A topic an agent needs>", "any", optional("the lead holds every constraint"), [
123
115
  "- <A constraint an agent cannot infer from the code, and the file that holds its detail.>",
124
116
  ]),
125
- fixed("Maintaining this file", REQUIRED, MAINTAINING),
126
117
  ],
127
118
  },
128
119
  claude: {
package/src/docs/docs.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect } from "effect";
3
- import { rootsOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
4
- import { ADR_DIRECTORY, judge, placementOf, placementProblem, speaksToConsumers, type Placement } from "./doc-rules.ts";
3
+ import { ceilingFinding, entryFindings, isAgentFile, maintainingFinding } from "./doc-agents.ts";
4
+ import { rootsOf, snapshotOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
5
+ import { ADR_DIRECTORY, judge, placementOf, placementProblem, recordsOf, speaksToConsumers, type Placement, type Records } from "./doc-rules.ts";
5
6
  import { vanishedNames } from "./doc-names.ts";
6
7
  import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
7
8
  import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
@@ -18,6 +19,7 @@ type Judged = {
18
19
  readonly held: readonly string[];
19
20
  readonly edited: { readonly docs: number; readonly lines: number };
20
21
  readonly named: number;
22
+ readonly agents: number;
21
23
  readonly findings: readonly Finding[];
22
24
  readonly advisory: ReadonlyMap<string, number>;
23
25
  readonly brokenBefore: readonly Finding[];
@@ -38,7 +40,7 @@ const NAME = "docs";
38
40
  const USAGE = "usage: docs.ts <ref> | <base-ref> <head-ref>";
39
41
  const MARKDOWN = [":(glob)**/*.md"];
40
42
 
41
- 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[] {
42
44
  const misplaced = placementProblem(placement);
43
45
  if (misplaced !== undefined) return [{ path, line: undefined, message: misplaced }];
44
46
  if (placement.type !== "judged") return [];
@@ -89,13 +91,13 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
89
91
  const renamedFrom = new Map(changes.flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from] as const] : [])));
90
92
  const changed = yield* changedLines(base, head, MARKDOWN, root);
91
93
  const present = yield* pathsAt(head, MARKDOWN, root);
92
- const records = present.filter((path) => path.startsWith(ADR_DIRECTORY));
93
94
  const proseDocs = present.flatMap((path) => {
94
95
  const reader = readerOf(path);
95
96
  return reader === undefined ? [] : [{ path, reader }];
96
97
  });
97
98
  const texts = yield* readTexts(root, head, [...new Set([...present.filter((path) => path.endsWith(".md")), ...proseDocs.map(({ path }) => path)])]);
98
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) })));
99
101
  const judged = present.map((path) => ({ path, placement: placementOf(path, text(path)) })).filter(({ placement }) => placement.type !== "unjudged");
100
102
 
101
103
  const templated = judged.flatMap(({ path, placement }) => templateFindings(path, text(path), placement, records));
@@ -109,13 +111,27 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
109
111
  const referenced = new Map(proseDocs.map(({ path }) => [path, text(path)]));
110
112
  const references = yield* referenceFindings({ root, base, head, roots, changed, renamedFrom }, referenced, judging);
111
113
  const vanished = yield* vanishedNames(root, base, head, referenced, references.failed);
114
+ const agents = proseDocs.filter(({ path }) => isAgentFile(path));
115
+ const tracked = snapshotOf(yield* pathsAt(head, [], root), new Map(), new Map());
116
+ const shapes = agents.flatMap(({ path }) =>
117
+ [ceilingFinding(text(path)), maintainingFinding(text(path))].flatMap((finding) => (finding === undefined ? [] : [{ path, ...finding }])),
118
+ );
119
+ const entries = agents.flatMap(({ path }) => entryFindings(path, text(path), tracked).map((finding) => ({ path, ...finding })));
112
120
  const advisory = new Map<string, number>();
113
121
  for (const { path } of templated.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
114
122
  return {
115
123
  held: judged.map(({ path }) => path).filter((path) => touched.has(path)),
116
124
  edited: { docs: edited.length, lines: edited.reduce((sum, { path }) => sum + (changed.get(path)?.size ?? 0), 0) },
117
125
  named: referenced.size,
118
- findings: [...templated.filter((finding) => touched.has(finding.path)), ...prose, ...references.failing, ...vanished].toSorted(inPathOrder),
126
+ agents: agents.length,
127
+ findings: [
128
+ ...templated.filter((finding) => touched.has(finding.path)),
129
+ ...prose,
130
+ ...references.failing,
131
+ ...vanished,
132
+ ...shapes,
133
+ ...entries,
134
+ ].toSorted(inPathOrder),
119
135
  advisory,
120
136
  brokenBefore: references.brokenBefore.toSorted(inPathOrder),
121
137
  } satisfies Judged;
@@ -125,13 +141,14 @@ function describe({ path, line, message }: Finding): string {
125
141
  return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
126
142
  }
127
143
 
128
- export function report({ held, edited, named, findings, advisory, brokenBefore }: Judged): string {
144
+ export function report({ held, edited, named, agents, findings, advisory, brokenBefore }: Judged): string {
129
145
  const verdict =
130
146
  findings.length === 0
131
147
  ? [
132
148
  `${NAME}: ${held.length} doc file(s) the range touches hold to their templates`,
133
149
  `${NAME}: ${edited.lines} line(s) the range adds or edits in ${edited.docs} living doc(s) or agent file(s) hold to the prose rules`,
134
150
  `${NAME}: the range breaks no path, link or command the ${named} living doc(s) or agent file(s) name`,
151
+ `${NAME}: the ${agents} agent file(s) hold to the ceiling, and every entry names a tracked path, a link or a command`,
135
152
  ]
136
153
  : [`${NAME}: ${findings.length} violation(s):`, ...findings.map(describe)];
137
154
  const unconformed =
@@ -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);
@@ -118,7 +118,13 @@ const runSuite = Effect.fn("runSuite")(function* (outfile: string, tier: TestTie
118
118
  const tierArgs = tier === undefined ? [] : ["--path-ignore-patterns", "", `./tests/${tier}`];
119
119
  const args = ["test", "--randomize", ...tierArgs, ...reporterArgs(outfile)];
120
120
  return yield* spawner.exitCode(
121
- ChildProcess.make(process.execPath, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }),
121
+ ChildProcess.make(process.execPath, args, {
122
+ stdin: "ignore",
123
+ stdout: "inherit",
124
+ stderr: "inherit",
125
+ env: { CI: "true" },
126
+ extendEnv: true,
127
+ }),
122
128
  );
123
129
  });
124
130
 
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,