@t09tanaka/test-gates 0.3.3 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ Most of this release is about runs that were full ones although little had changed. `test-gates doctor` tells why a project's runs are full ones. `mutation --affected` runs again only the gates a change can affect. The comparison before a run covers only what the run loads. Two changes of behaviour: Stryker's `concurrency` now defaults to half of the cores, and the record of a run has a new format, so the first run after the update is one of every mutant.
6
+
7
+ ### Added
8
+
9
+ - `test-gates doctor` tells why `test-gates mutation` would run every mutant where it need not, and what to change: Stryker not in incremental mode, a path that turns reuse off, the file and the path that make every file of the repository part of the comparison, and a git hook that runs mutation without `--affected`. It also shows how many files are compared, whether the next run reuses the last one, and what a run with `--affected` would run again. It runs nothing and changes nothing; the exit code is 1 when there is a problem.
10
+ - `test-gates mutation --affected` runs again only the gates that a change since the last run can affect, and keeps the results of the others. The unit is the gate: every mutant of a gate that may have changed is run again, and Stryker's judgement per mutant is not relied on. A gate depends on itself, its own spec, every spec that imports it or whose tests covered or killed one of its mutants, and what all of those import. A change of a config, a lock file, a tool or the arguments, a file whose imports cannot be followed or that reads files at run time, a run with `--force`, and a run in CI (`CI` set) run every mutant, and the first line says why; otherwise the first lines say how many gates were reused and which file made each of the others run again. A package of the same repository linked into `node_modules` also runs every mutant: its files are not followed. A component (`.vue`, `.svelte`, `.astro`, `.mdx`) that a gate or a spec loads runs every mutant as well: it can use others that it does not import. A gate with an unallowed survivor or timeout always runs again. A mutant that timed out in the last run is run again on its own (`--mutate` with its range), also in a reused gate, since a timeout on a loaded machine can stand for a survivor. It is an option: without it a change of any compared file still runs every mutant. See "`--affected`" in the README.
11
+
12
+ ### Changed
13
+
14
+ - `createStrykerGatesConfig` sets `concurrency` when the options do not: half of the cores (`os.availableParallelism()`), at least 1 and at most 8, and 2 when `CI` is set. Stryker's own default was one fewer than the cores. A `concurrency` in the options wins, and `--concurrency` on Stryker's command line wins over both. See "How many test runners Stryker starts" in the README.
15
+ - `test-gates selfcheck --jobs <n>` decides for each control whether it runs next to others. Before, one file anywhere in the gate run that reached outside of its process (the clock, the environment, a built-in module, a package not vouched for) made every control run one at a time. Now only the control of a gate is kept apart that a spec of another gate loads while that spec, or a file it loads, reaches outside, or that a spec may load because its imports cannot all be followed; a file the gate config names that reaches outside still keeps every control apart. The controls kept apart run one at a time after the others, and the first lines name every one of them with every reason, instead of the first reason only. A spec that reaches outside but loads only its own gate keeps nothing apart. The other checks (the gate command, the gate config, `failurePattern`, and the judgement of each run next to others) are unchanged, and the default stays 1.
16
+ - `test-gates mutation` no longer takes over a timeout from the last run after no change: the mutants that timed out are run again on their own, the other results are reused as before.
17
+ - In a git hook of a linked worktree (`GIT_DIR` set, `GIT_WORK_TREE` not), `test-gates mutation` lists the files from the top of the repository. Before, git took the subproject for it, a path to a sibling directory led "outside of the repository", and every run was a full one.
18
+ - The record next to the incremental file is of a new format (2), with what a run with `--affected` writes down about each gate. The first run after the update is one of every mutant.
19
+ - `test-gates mutation` compares fewer files outside the subproject before it reuses the results of the last run, so that a change elsewhere in a repository no longer turns every run into a full one:
20
+ - A path that leads up from a file and back into the subproject (`'../helper'` from `src/` to a file at the top of the subproject) no longer makes every file of the repository part of the comparison. It was taken for a path to a directory above the subproject.
21
+ - A path that starts at the directory of its own file is followed: `` `${__dirname}/../shared` ``, and `__dirname, '..', 'shared'` handed to `path.join` or `path.resolve` (also with `import.meta.dirname`). Before, every file of the repository was compared. When such a path ends in a directory above the subproject, only the files directly in it are compared, as they always are; when an unknown part follows it (`join(__dirname, '..', name)`, or something added to what the call returns), every file still is. A way up after a placeholder (`` `../x/${name}/../..` ``) now also makes every file part of the comparison; before, only the part before the placeholder was followed.
22
+ - Paths in `.md`, `.markdown`, `.txt`, `.rst` and `.adoc` files are no longer followed. The files themselves are still compared.
23
+ - A plain relative path to a file or a directory above the subproject (`'../tsconfig.base.json'`), a path whose start is not known, and `'..'` after anything but the directory of the file still make every file of the repository part of the comparison.
24
+ - In a project where the comparison is now smaller, the first run after the update is one of every mutant: the record of the last run holds files that are no longer compared, and the message lists them as changed.
25
+ - When a path leads outside of the repository and the results are therefore not reused, the message names the path as it is written, next to the file it is in.
26
+ - With a runner config made by `createJestGatesConfig` or `createVitestGatesConfig`, `test-gates mutation` follows the paths only in the files of the subproject that the run loads: the configs and the files they name, the gates, their specs, the configs that the tools read by name (`tsconfig*.json`, Babel, SWC), mocks, and everything those import. The other files, `package.json` among them, are still compared, but a `'../'` in a lint config, a script in `package.json`, `$PROJECT_DIR$/..` in the settings of an editor or `<rootDir>/../shared` in the config of another test run no longer makes every file of the repository part of the comparison. An alias that `settings.importAliases` does not name loads the files of the subproject whose paths end as the import does. A loaded file that imports a module whose path is not written out, and a runner config made otherwise, keep the search in every file.
27
+ - A path that leads outside of the repository to nothing (one meant for the sandbox of Stryker, such as `path.resolve(__dirname, '../../../shared')` in a runner config) is compared as missing, instead of turning reuse off. Once something is there, reuse is off as before.
28
+
29
+ ## 0.3.4
30
+
31
+ One change of behaviour: `test-gates mutation` no longer lets Stryker reuse old results after a change. The rest is new options of `selfcheck`; without them it runs as before.
32
+
33
+ ### Changed
34
+
35
+ - `test-gates mutation` lets Stryker reuse the results of the last run only when nothing the run depends on has changed since. Stryker's incremental mode reuses the result of a mutant when the text at its place and a test that killed it are the same; a change on another line of the gate, or in a file the spec imports, could turn a killed mutant into a survivor and the old "Killed" was reused. A record next to the incremental file (`reports/stryker-incremental.json.test-gates.json`) now holds a hash of every file of the subproject and of what it leads to, the versions of the tools and the arguments for Stryker. When anything differs, or there is no record, the run gets `--force` and the first line says why. The first run after the update is therefore one of every mutant. A run after no change is reused as before. See "When the results of the last run are reused" in the README for what is compared and what is not seen.
36
+ - `test-gates mutation` takes `--gate` for itself. Before, it was handed to Stryker like every unknown argument (Stryker has no such option).
37
+ - `test-gates check`, `lcov` and the config helpers read each directory once while they look for the gates and their specs, instead of once for every gate and every place its spec may be in. What they report is unchanged.
38
+
39
+ ### Added
40
+
41
+ - `test-gates selfcheck --gate <path>` (may be given more than once) and `--shard <index>/<total>` run the negative control of a part of the gates: one gate while working on it, or one of several parts for parallel CI jobs. The last line says how many gates were not selected. More parts than gates, a path that is not a gate, and either option after `--` are errors (exit code 2).
42
+ - `test-gates selfcheck --quick` uses one run of the gate command for all the gates that only their own spec touches, as far as a full gate run with the config helpers shows it (Jest 30.5; Vitest 3.2 with the istanbul provider). It is for work in progress and not the final check: it takes for granted that a spec executes the same code whichever other specs run with it. Controls judged in the shared run are marked in the output, the last line asks for a run without the option, and the option is refused when `CI` is set (exit code 2).
43
+ - README: Dart and Flutter projects are pointed to the package for Dart; `lcov --file` is shown as optional.
44
+ - `test-gates mutation --gate <path>` runs and judges the mutants of the named gates only, for the time a gate is being worked on. It is not the final check: a change in one gate can make a mutant of another gate survive. The last line says so, the run takes nothing over from an earlier one and leaves neither a record nor a JSON report behind, and the option is refused when `CI` is set.
45
+ - `test-gates selfcheck --jobs <n>` runs `<n>` negative controls at the same time. The default stays 1. A control is taken from a run next to others only when the runner sums that run up as undisturbed (every other spec file passed, every test passed, none skipped, no threshold but that of its gate, no line that speaks of a failure); any other control is run again on its own, and that run counts, so a control that does not hold is always reported from a run of its own. The controls are started at the same time only when the gate command is `jest` or `vitest run` with `--config` and options for the number of workers, the gate config uses the helper, and no file a gate run loads (specs, scripts the gate config names, and the project files those import) is seen to use the environment, the clock, the network, a built-in module of Node.js or a package the project does not vouch for; otherwise the first line says why and the run is one control at a time. Each line of runs writes its coverage and the cache of the runner to a directory of its own outside of the project. See the README for what the search by text cannot see, which is why it is an option.
46
+
3
47
  ## 0.3.3
4
48
 
5
49
  Documentation only; the commands and the helpers behave as in 0.3.2.
package/README.md CHANGED
@@ -105,11 +105,18 @@ export default createStrykerGatesConfig({
105
105
  testRunner: 'jest',
106
106
  jest: { projectType: 'custom', configFile: 'jest.gates.config.js' },
107
107
  // testRunner: 'vitest', vitest: { configFile: 'vitest.gates.config.ts' },
108
- concurrency: 2,
109
108
  });
110
109
  ```
111
110
 
112
- Add `reports/` and `.stryker-tmp/` to `.gitignore`.
111
+ **How many test runners Stryker starts (`concurrency`).** When neither the config nor the command line says, the helper sets half of the cores (`os.availableParallelism()`), at least 1 and at most 8, and 2 when `CI` is set. Stryker's own default is one fewer than the cores, but each of its runners is a Jest or Vitest that does work besides the tests (transforms, coverage), and the machine of a developer is rarely free for the run alone: half of the cores leaves room, and the cap of 8 keeps the memory of the runners in bounds on large machines. On a machine with 12 cores that is 6; going from 2 to 6 took about half the time for a full run on two projects, and on this repository's own gates (about 2,400 mutants, other jobs running) 143 and 184 s instead of 307 and 389 s, each time with the same numbers of killed, survived and timed-out mutants. In CI 2 is the number of cores of the usual hosted runner; more runners than cores only make mutants run out of time. What wins, from the first:
112
+
113
+ 1. `--concurrency <n>` after `--` on the command line (`npm run test:gates:mutation -- --concurrency 1`), which Stryker takes over its config;
114
+ 2. `concurrency` in the options of `createStrykerGatesConfig`;
115
+ 3. the default above.
116
+
117
+ A config that sets `concurrency: 2` keeps 2 everywhere; remove the key to get the default. Setting it to `undefined` gives Stryker's own default instead.
118
+
119
+ Add `reports/`, `.stryker-tmp/` and `.test-gates-touch-*` (see `test-gates selfcheck`) to `.gitignore`.
113
120
 
114
121
  ### 4. Scripts
115
122
 
@@ -157,7 +164,7 @@ done
157
164
 
158
165
  for project in backend frontend; do
159
166
  printf '%s\n' "$changed" | grep -q "^$project/" || continue
160
- (cd "$project" && npm run --silent test:gates && npm run --silent test:gates:mutation) || exit 1
167
+ (cd "$project" && npm run --silent test:gates && npm run --silent test:gates:mutation -- --affected) || exit 1
161
168
  done
162
169
  ```
163
170
 
@@ -182,9 +189,28 @@ test-gates:
182
189
  - run: npm run test:gates:mutation
183
190
  ```
184
191
 
192
+ When the negative control takes too long with many gates, run it as a job of its own, in parts (`--shard`, see `test-gates selfcheck` under [Commands](#commands)):
193
+
194
+ ```yaml
195
+ test-gates-selfcheck:
196
+ runs-on: ubuntu-latest
197
+ strategy:
198
+ fail-fast: false
199
+ matrix:
200
+ part: [1, 2, 3, 4]
201
+ defaults:
202
+ run:
203
+ working-directory: backend
204
+ steps:
205
+ # checkout, setup-node and npm ci as above
206
+ - run: npm run test:gates:selfcheck -- --shard ${{ matrix.part }}/4
207
+ ```
208
+
209
+ The number of parts is written twice, in `matrix.part` and after the `/`; keep the two in step. The `test:gates:selfcheck` script must not itself end in `-- <gate command>`, or the option would land behind it (which is refused); put the command into `settings.gateCommand` instead.
210
+
185
211
  ## Commands
186
212
 
187
- All commands take `--dir <subproject>` (default: the current directory).
213
+ All commands take `--dir <subproject>` (default: the current directory). `test-gates -h` (`--help`) prints the usage and `test-gates -v` (`--version`) the version.
188
214
 
189
215
  | Exit code | Meaning |
190
216
  | --------- | -------------------------------------------------------------------------- |
@@ -208,10 +234,121 @@ Static checks, before the gate run. Reports every violation as `file:line: reaso
208
234
  - The Stryker config does not use `excludedMutations`, `ignoreStatic` or `ignorers`.
209
235
  - A Vitest gate run below Vitest 4 does not use the plain v8 coverage provider (since 0.3.0), see [Vitest: v8 or istanbul](#vitest-v8-or-istanbul).
210
236
 
211
- ### `test-gates mutation [-- <stryker arguments>]`
237
+ ### `test-gates mutation [--gate <path>…] [--affected] [-- <stryker arguments>]`
212
238
 
213
239
  Runs `stryker run <config>` from the project's own `node_modules`, then judges the JSON report (see `mutation-result`). Unknown arguments, and everything after `--`, are handed to Stryker. If Stryker itself fails, the exit code is 1 and nothing is judged. A report from an earlier run is deleted first, so a run that writes none cannot pass on old results.
214
240
 
241
+ #### When the results of the last run are reused
242
+
243
+ In Stryker's incremental mode (the helper turns it on) a run reuses the old result of a mutant when the text at the place of the mutant is the same and a test that killed it is the same. That is less than it sounds. Measured with Stryker 10, with Jest and with Vitest, in projects written for this; each time the incremental run reused the old "Killed" and passed, and `--force` found survivors:
244
+
245
+ - an assertion weakened in a file that the spec imports (111 of 111 results reused; three survivors),
246
+ - **the gate itself changed, on another line than the mutant**: a helper in the same file that the mutated comparison goes through (10 of 12 results reused; two survivors).
247
+
248
+ So the incremental file can only be trusted when nothing at all changed. `test-gates mutation` keeps a record next to it (`reports/stryker-incremental.json.test-gates.json`) of everything the run depended on, and compares it before the next run. When something differs, or there is no record, it adds `--force` and says why:
249
+
250
+ ```
251
+ test-gates mutation: running every mutant again (--force), the results of the last run are not reused: changed since the last run: src/expect-true.ts
252
+ ```
253
+
254
+ What is left of incremental mode is the run after no change: a push that does not touch the subproject, a run repeated after a failure elsewhere. That one takes seconds.
255
+
256
+ What is compared:
257
+
258
+ - Every file of the subproject: gates, specs, other sources, helpers of the specs, configs, `test-gates.json`, the lock file.
259
+ - The files directly in the directories above the subproject, up to the root of the repository: the lock file and the shared configs of a monorepo.
260
+ - Packages of the same repository that are linked into `node_modules`, and directories outside the subproject that a file leads to, with a link or with a relative path (`../../pricing-kit/rounding/half-up`). A path that starts at the directory of its own file is followed as well, whether it is written as a template (`` `${__dirname}/../pricing-kit` ``) or as parts handed to `path.join` or `path.resolve` (`__dirname, '..', 'pricing-kit'`, also with `import.meta.dirname`). A path that leads back into the subproject adds nothing.
261
+ - When a path cannot be followed, every file of the repository is compared: when its start is not known (`<rootDir>/../pricing-kit`, an alias such as `@/../shared`, `'..'` after anything but the directory of the file), when a part after it is not known (`join(__dirname, '..', name)`, `join(resolve(__dirname, '..'), name)`, a way up after a placeholder), and when a plain relative path leads to a file or a directory above the subproject (`'../tsconfig.base.json'`).
262
+ - Files written for people to read (`.md`, `.markdown`, `.txt`, `.rst`, `.adoc`) are compared, but the paths in them are not followed. Every other file is searched, whatever its extension.
263
+ - When the config of the test runner is made with this package's helper (`createJestGatesConfig` or `createVitestGatesConfig`, which run the specs of the gates and no others), only the files of the subproject that the run loads are searched: the Stryker config, the runner config and the files it names (a setup file), the gates and their specs, `test-gates.json`, `tsconfig*.json`, `jsconfig*.json`, the Babel and SWC configs, the files below a root `__mocks__/` and the mock next to a loaded file, what those configs name (in a script, any path as text, such as a setup file; in `tsconfig.json` or `.babelrc`, what it extends and the plugins and presets it names, and what those name in turn), and everything those import, also what a component (`.vue`, `.svelte`, `.astro`, `.mdx`) imports. The other files of the subproject are compared, but a path in them is not followed: `package.json` (its scripts, and a runner config in it that the helper's config replaces), a lint config, the settings of an editor, the config of another test run, a component no gate loads. An import of an alias that `settings.importAliases` does not name (one that the runner config maps) loads every file of the subproject whose path ends as the import does (`@shared/money/fee` loads `src/money/fee.ts`); when none does, it leads out of the subproject, to where the config that maps it points. An import of an alias with the extension of the compiled file (`@shared/fee.js`) leads to `fee.ts` as well, and one of a single part (`@helper`, `#config`) to the files of that name. Every file is searched when a loaded file imports a module whose path is not written out, when the runner config is not made with the helper, when what the runner config exports is not what the helper returns (merged with another config after it, changed after the call), and when the runner config or a file it is put together from names `projects` or `workspace` outside of a comment: then it may run more than the specs of the gates. Another config spread into the helper's options is fine: the helper sets the specs after them.
264
+ - A path that leads outside of the repository to nothing is compared as missing: a path from a config that is meant for the sandbox of Stryker (`path.resolve(__dirname, '../../../shared')`, which from the subproject itself leads above the repository). Once something is there, reuse is off, as for any path outside the repository.
265
+ - What is installed (`node_modules/.package-lock.json`, or what yarn and pnpm write in its place), the versions of Node.js, of this package, of Stryker and of the test runners, and the arguments handed to Stryker.
266
+ - The incremental file itself: it has to be the one the recorded run wrote.
267
+
268
+ With git, "every file" is what git tracks plus the untracked files it does not ignore. In a git hook of a linked worktree, git sets `GIT_DIR` and not `GIT_WORK_TREE`; then git would take the subproject for the top of the repository, so test-gates calls git without `GIT_DIR` in that case and lets it find the repository from the subproject. Without git, or when git ignores the subproject, every file is read except `node_modules`, `.git` and what the run writes itself, up to the nearest directory that has a `.git` entry, else the topmost one that declares workspaces, else only the subproject. Such a listing also contains what git would ignore (coverage output, an HTML report, logs), so a file that changes between two runs makes every run a full one; reuse is meant for a checkout with git.
269
+
270
+ The record is removed before each run and written only after a run that Stryker ended without an error and in which it wrote the incremental file, so a run that failed, was interrupted or was a dry run is followed by a full one. A run that is given `--force` is not changed. When Stryker does not run in incremental mode, nothing is recorded or compared.
271
+
272
+ The paths that leave the subproject are found by text, in every file that is compared but those written for people and, with the helper's runner config, those of the subproject that the run does not load. The search errs towards comparing too much: text that merely looks like such a path widens the comparison, and a path or a link to a directory that exists outside the repository turns reuse off, with the file and the path as it is written named in the message. There is one exception, where the path is known: a path from the directory of the file that ends in a directory above the subproject (`path.resolve(__dirname, '..')` in a file at the top of the subproject) adds only the files directly in that directory, which are compared anyway (see the last point of the list below).
273
+
274
+ The comparison loads the Stryker config in a process of its own, to see where the incremental file is, and reads and hashes those files before each run. Timed with a stand-in for Stryker, so that only this tool is measured, in a project of 5,200 files written for the measurement: 0.13 s without the comparison, 0.44 to 0.55 s with it (medians of three rounds; other jobs were running on the machine).
275
+
276
+ What it does not see, and what therefore still needs `-- --force` by hand, or CI, where the run should always be a full one:
277
+
278
+ - files git ignores (build output that a spec or a config loads, `.env`),
279
+ - environment variables, the clock, the network, and a Stryker config that reads them or the command line to decide about incremental mode,
280
+ - files outside the subproject that are reached without a link and without `..` in a path: an absolute path, an alias defined in a config above the subproject (in that case every file of the repository is compared only if the subproject points at that config with a relative path),
281
+ - changes inside `node_modules` that leave the files of the package manager as they were,
282
+ - paths in files written for people (`.md`, `.txt` and the like): a script that reads a path out of a README,
283
+ - with the helper's runner config, paths in a file of the subproject that the run does not load as a module: a file that a loaded one reads as data (`readFileSync`) and takes a path out of, and a config that a package reads by its name and that is not among those listed above (`next.config.js` read by `next/jest`, for example, or a Babel config kept in the `babel` key of `package.json`),
284
+ - with the helper's runner config, what a component uses without importing it (a component that a plugin registers), and the paths in it,
285
+ - a path outside the repository to nothing, to which something is added that starts with the same text and continues the last part of its name (`'../../../shared' + '-kit'`),
286
+ - what is read below a directory above the subproject when the path to that directory is kept in a variable and the rest is added elsewhere: `const top = path.resolve(__dirname, '..')` in one place and `path.join(top, 'shared', name)` in another, or a path to such a directory handed to a tool that reads below it.
287
+
288
+ #### `--gate <path>`: only the mutants of one gate
289
+
290
+ While working on one gate, `test-gates mutation --gate src/pricing/shipping-fee.ts` runs the mutants of that gate only (it hands the path to Stryker as `--mutate`) and judges that gate only. The option can be given more than once; the path is written as in `test-gates.json`.
291
+
292
+ **It is not the final check, and the last line says so.** A change in one gate, in its spec or in a file they import can make a mutant of **another** gate survive, and the mutants of that other gate are not run. `test/incremental-reuse.test.ts` has such a project, with a real Stryker: the spec of one gate compares its result through a function of another gate; that function is changed to tell less apart, together with its own spec. Every mutant of the changed gate is still killed. Running only those and taking over the old results of the other gate from Stryker's incremental file passes. The run of every gate finds the survivor in the other gate.
293
+
294
+ So a run with `--gate`
295
+
296
+ - judges the named gates and no other (the report of such a run can hold old results of the other gates, which Stryker takes over from its incremental file),
297
+ - takes nothing over from an earlier run: it adds `--force`, and removes the record of the last run, so the next run without `--gate` is one of every mutant,
298
+ - removes the JSON report after it is judged, so that `mutation-result` cannot take it for the report of a full run,
299
+ - ends its last line with `Only 1 of 9 gate(s) were run and judged (--gate), and a change in one gate can make a mutant of another survive: run without --gate for the final check`,
300
+ - **is refused when `CI` is set** in the environment (to anything but an empty value, `false` or `0`), with exit code 2, like `selfcheck --quick`.
301
+
302
+ `--gate` is written before `--`, and cannot be combined with a `--mutate` of your own.
303
+
304
+ #### `--affected`: run again only the gates a change can affect
305
+
306
+ Without the option, a change to any file that is compared makes the run one of every mutant. That is the safe answer, but in a pre-push hook that only runs for the subprojects a push touches, it is the answer every time. `test-gates mutation --affected` (`npm run test:gates:mutation -- --affected`) decides gate by gate instead: a gate whose results cannot have changed keeps them, and every mutant of a gate that may have changed is run again. It never relies on Stryker's judgement per mutant, which is what reused the old "Killed" in the measurements above.
307
+
308
+ The results of a gate depend on
309
+
310
+ - the gate and every file it imports,
311
+ - its own spec, every spec that imports it (directly or through other files), and every spec whose tests covered or killed one of its mutants in the last run, with every file those specs import. A spec that does not load the gate cannot see its mutants: Jest and Vitest give each spec file a module registry of its own. This is the way a change reaches another gate: when the spec of `next` checks its result through the gate `same`, a change of `same` runs `next` again, and so does a weakened spec of `next` for `same`,
312
+ - snapshots next to a spec (`__snapshots__/<spec>.snap`) and data a spec or a gate imports.
313
+
314
+ Imports are followed when they are relative, absolute, or start with a prefix of `settings.importAliases`; type imports count too. Paths written as text for the runner (`vi.mock('./rates')`) are followed as well. A package is taken to be what the lock file and the versions of the tools say it is, unless it is a link to a package of the same repository: see below.
315
+
316
+ A change elsewhere decides for every gate:
317
+
318
+ - **Every mutant is run** after a change of a config (the Stryker config and the config of the test runner, what they import, the setup files and other files they name, `package.json`, `test-gates.json`, `tsconfig*.json`, `*.config.*`, Babel configs, dotfiles, declaration files `.d.ts`, anything below `__mocks__`), of a lock file or of what is installed, of the tools or of the arguments; after a change of a file at the top of the subproject or outside of it that no gate depends on; and after a change of a file whose kind is not known (`.yaml`, `.html` and the like) that no gate depends on. Such a file that a gate does depend on runs that gate again, as any other file does.
319
+ - **Nothing is run again** for a change of a source, data, a snapshot, a style or an image below the subproject that no gate depends on, or of a document (`.md`, `.txt` and the like).
320
+
321
+ When what a gate depends on cannot be told from the text, every mutant is run, and the first line says why: an import whose path is not written out (`import(name)`, `require(name)`), an import that leads to no file, a specifier that is neither a file nor an installed package (an alias defined only in a config: name it in `settings.importAliases`), a file read at run time (`node:fs`, `readFileSync`, `__dirname`, `import.meta.url`, `process.cwd`, `toMatchFileSnapshot`), a file that is loaded but not compared (git ignores it), a gate loaded for every spec by a setup file, a package of the same repository linked into `node_modules` (`@acme/shared`, also when it lies in the subproject), a component (`.vue`, `.svelte`, `.astro`, `.mdx`), which can use others that it does not import (registered by a plugin), and a test runner other than Jest and Vitest. Every mutant is also run when there is no record of a run with `--affected`, when the record cannot be read, with `--force`, and **when `CI` is set**: CI keeps running every mutant, and is the final check.
322
+
323
+ A gate with a survivor or a timeout that the allow lists do not cover is not reused: it runs again until it is clean. A mutant that timed out in the last run is never taken over either, also not in a gate whose other results are, and also not in a run without `--affected` after no change: on a loaded machine a mutant that would survive can time out instead, and a reused timeout would keep that. Those mutants alone are run again (`--mutate` with their ranges); the first lines count them. The run is judged as one of every gate, and its first lines say what was reused:
324
+
325
+ ```
326
+ test-gates mutation --affected: 1 of 3 gate(s) reused, 2 run again
327
+ src/same.ts: changed src/same.ts
328
+ src/next.ts: changed src/same.ts
329
+ ```
330
+
331
+ To run only some gates, `--affected` hands them to Stryker as `--mutate` with `--force`, and Stryker takes the results of the others over from its incremental file, which has to be the one the recorded run wrote. `--affected` cannot be combined with `--gate` or with a `--mutate` of your own, and is written before `--`. `test/affected-reuse.test.ts` runs each of these cases with a real Stryker.
332
+
333
+ What it does not see, besides what is listed for the comparison above: a file that a spec or a gate reaches in a way the text does not show and that is not a config by its name (a script that a tool loads by a name built at run time), and a spec that changes what another spec sees (Vitest with `isolate: false`, a shared global set by one spec file and read by another). Keep the CI run without the option.
334
+
335
+ ### `test-gates doctor`
336
+
337
+ Tells why `test-gates mutation` would run every mutant where it need not, and what to change. It runs neither Stryker nor a test and changes no file: it takes the same snapshot as a mutation run, reads the record of the last run, and looks at the git hooks of the repository (`.husky/pre-push` and `pre-commit`, the hooks git runs, `lefthook.yml`).
338
+
339
+ ```
340
+ test-gates doctor: /work/shop/fee-rules (stryker.gates.config.mjs)
341
+ compared: 640 files, 16 outside the subproject, paths followed in the 31 files the run loads
342
+ next run: every mutant, changed since the last run: src/fee.ts
343
+ next run with --affected: 3 of 4 gate(s) reused, 1 run again
344
+ src/fee.ts: changed src/fee.ts
345
+ problem: .husky/pre-push runs test-gates mutation without --affected: a change of any file of the subproject runs every mutant
346
+ fix: pass --affected in the hook (npm run test:gates:mutation -- --affected); keep the run in CI without it
347
+ test-gates doctor: 1 problem(s) that make runs full ones
348
+ ```
349
+
350
+ A **problem** makes runs full ones that need not be: Stryker not in incremental mode, a path that turns reuse off (one that leads outside of the repository to something, or a Stryker config that cannot be loaded), a path that makes every file of the repository part of the comparison (it names the file and the path), a Stryker config that sets `force`, and a hook that runs `test-gates mutation` of the subproject without `--affected`, also through its `test:gates:mutation` script. A run in a hook is taken to be one of the subproject that its line names (`--dir`, `--prefix`, `-w`, `cd`), else the nearest `cd` before it; a name with a variable in it (`cd "$project"` in a loop over the subprojects), or none, may be one of any subproject. Each run is judged on its own line, and comments are left out. A **note** is worth knowing: that every file of the subproject is searched for paths, and why (a runner config that is not made with the helper, an import whose path is not written out), and that no hook runs a mutation run. The exit code is 1 when there is a problem, so that the doctor can run in CI next to the other checks.
351
+
215
352
  ### `test-gates mutation-result [--report <path>]`
216
353
 
217
354
  Judges an existing Stryker JSON report without running Stryker.
@@ -237,7 +374,7 @@ The run also fails when
237
374
  On failure every unallowed survivor is listed as `file:line:column / mutator / original → replacement / occurrence`, followed by a JSON fragment that can be pasted into `equivalentMutants`. The last line gives the real numbers, never a percentage. For the gates of this repository:
238
375
 
239
376
  ```
240
- test-gates mutation: OK (mutants 1017 / detected 1017 (timeout 2) / allowed equivalent 0 / not evaluable 0 / unallowed survivors 0)
377
+ test-gates mutation: OK (mutants 1702 / detected 1702 (timeout 2) / allowed equivalent 0 / not evaluable 0 / unallowed survivors 0)
241
378
  ```
242
379
 
243
380
  #### Timeouts
@@ -284,11 +421,123 @@ When the run fails on a timeout, first run again with less load (`npm run test:g
284
421
 
285
422
  0.3.1 set `timeoutMS` to 30000 instead. That was the wrong fix and 0.3.2 takes it back: the longer limit let most slow mutants finish, but the same full run then took about three times as long as without it, and in another project mutants that exhaust the heap crashed the test runner before the limit and came back as `RuntimeError`, which is only counted, not failed. `timeoutMS` is Stryker's default (5000) again unless the project sets it.
286
423
 
287
- ### `test-gates selfcheck [--all | --first] [-- <gate command>]`
424
+ ### `test-gates selfcheck [--all | --first | --gate <path>… | --shard <index>/<total>] [--jobs <n>] [--quick] [-- <gate command>]`
288
425
 
289
426
  Negative control. For each gate, runs the gate with that gate's spec left out and requires the run to **fail on the coverage threshold of that very file**. A gate that stays green without its spec measures nothing; a run that fails for another reason (a config that does not load) proves nothing either.
290
427
 
291
- The spec to leave out is passed in the environment variable `TEST_GATES_EXCLUDE_SPEC`. The config helpers read it. The gate command is `jest --config jest.gates.config.*` or `vitest run --config vitest.gates.config.*`, whichever config exists; override it with `settings.gateCommand` or after `--`. `--all` (default) checks every gate in turn, `--first` only the first.
428
+ The spec to leave out is passed in the environment variable `TEST_GATES_EXCLUDE_SPEC`. The config helpers read it. The gate command is `jest --config jest.gates.config.*` or `vitest run --config vitest.gates.config.*`, whichever config exists; override it with `settings.gateCommand` or after `--`. `--all` (default) checks every gate, `--first` only the first.
429
+
430
+ **`--quick`: one run for the gates that only their own spec touches.** A run of the gate command for every gate executes the specs of all the other gates each time, and takes minutes with many gates (see [Cost](#cost-and-how-to-use-strykers-incremental-mode)). With `--quick` and three gates or more, `selfcheck` starts with a full gate run in which the config helpers write down, for each spec file, what it executed of each gate. Leaving out the spec of a gate changes the coverage of that gate only by what its spec executed of it. So for a gate that no other spec touched, the result does not depend on which other specs are left out with it, and all those gates lose their specs in **one** run. Each of them is judged in that run as it is in a run of its own: the run has to fail, and it has to report the coverage threshold of that very file.
431
+
432
+ **It is an option, not the default, because it takes one thing for granted: that a spec executes the same code whichever other specs run with it.** A spec that covers another gate only while that gate's spec is away is not caught by any note of a full run; the run per gate catches it, because it really leaves that one spec out. Specs of pure modules do not behave that way, and the ones that could (they read `process.env`, also the variables of `selfcheck` itself, or files, a child process, the network) are recognised by their text and switch the shared run off. That is a search by text in the spec files only, not in what they import, and it cannot be made complete. Two hunts for counterexamples found projects in which `--quick` reports every control as holding and the plain run does not, with nothing in the text of a spec: a helper that keeps state in the process (it changes a setting of a Node.js core module once; with Jest that state is shared by the spec files of one worker, so whether a later spec has to do it again, and thereby executes another gate, depends on which spec ran before it, and with the default number of workers even on Jest's cache of run times), and a helper that keeps a cache on disk which one spec fills and another reads. In each of them a spec covers a gate only while that gate's spec is away. No way of taking notes from one full run can see that.
433
+
434
+ So a control that `--quick` reports as holding is not the last word; one that it reports as not holding is, because that one was run on its own. The output says so: such a line ends in `, in the shared run)`, and the last line names how many there were and asks for a run without `--quick`. **With `CI` set in the environment (to anything but an empty value, `false` or `0`), `--quick` is refused** (exit code 2): where the run is the gate itself, it has to be the full one. Most hosted CI services set `CI`; some CI systems do not set it at all, and there the job has to export `CI=true` itself, or simply not use the option.
435
+
436
+ | Where | Run |
437
+ | ------------------------------------------------------- | ------------------------------------------------------------------------------------- |
438
+ | while working on one gate | `selfcheck --gate <path>` |
439
+ | before a push, when CI runs the gates too | `selfcheck --quick` |
440
+ | before a push, when that hook is the only gate there is | `selfcheck` |
441
+ | CI | `selfcheck`, with `--shard <index>/<total>` over parallel jobs when it takes too long |
442
+
443
+ The notes are taken from what the runner itself counts for a spec file, after the file has run to its end: with Jest, the coverage a reporter is given for the file; with Vitest, what the worker hands to the istanbul coverage provider. All of it rests on the notes being complete: an execution that is missing from the note of a spec could make a gate look untouched. So the notes are used only within three limits, and outside of them every gate gets a run of its own, as before, with the reason on the first line (`test-gates selfcheck: a run of the gate command for every gate (…)`).
444
+
445
+ 1. **Only for the runs it is verified with.** This table is the list; it is also `VERIFIED_RUNS` in the code, and `test/selfcheck-runners-*.test.ts` compares the shared run with a run per gate for every row.
446
+
447
+ | Runner | Versions | Coverage | Pool | Other |
448
+ | ------ | -------- | ------------------------------ | ---------------------------- | -------------------------------------- |
449
+ | Jest | 30.5 | `babel` (what the helper sets) | — | one project |
450
+ | Vitest | 3.2 | `istanbul` | `forks` (default), `threads` | `isolate: true` (default), one project |
451
+
452
+ Another minor version, another provider or pool, spec files that share their modules, a workspace with several projects: a run per gate. The Vitest wrapper also checks what it relies on while it runs (the provider has `onAfterSuiteRun` and `initialize`; every hand-over of coverage names exactly one spec file) and reports a deviation, which has the same effect. Vitest 4.1 was tried by hand on three of the cases below and agreed; it is not in the table because the tests of this repository do not run it.
453
+
454
+ 2. **Only when the notes account for what the runner counted.** The same run writes the runner's own total (its JSON coverage report). For every gate, the executions in the notes of all specs are added up and compared with that total: every function, every branch arm and every statement. An execution that happened after a note was taken, or that no note has, shows as a total above the sum; then no note is trusted. (Checked with notes taken too early, the way the first version of this took them: a cleanup function returned by `beforeAll`, and an `afterAll` with the hooks in the order written, are both refused.) Where the two cannot be compared for a gate, because the report puts the counters back onto the source through a source map and the functions do not line up, that gate gets its own run.
455
+
456
+ 3. **Only on a gate run that passes.** A failing test or a gate below 100% in the full run: a run per gate.
457
+
458
+ A gate also keeps a run of its own when another spec touches it (it imports the gate, a setup file calls it, an `afterAll` or a cleanup function calls it), when its own spec is not seen to touch it, or when the shared run does not confirm it. And every gate does when
459
+
460
+ - the gate config does not use the helpers (the list of specs to leave out arrives in `TEST_GATES_EXCLUDE_SPECS` as a JSON list, the directory for the notes in `TEST_GATES_TOUCH_DIR`),
461
+ - Vitest measures with the v8 provider, which the helper cannot wrap. **v8 is the default of `createVitestGatesConfig`**: the shared run needs `coverageProvider: 'istanbul'` (see [Vitest: v8 or istanbul](#vitest-v8-or-istanbul), which recommends it for another reason),
462
+ - `settings.selfcheck.failurePattern` does not contain `{gate}`, or a gate has no single spec.
463
+
464
+ When the gate command is `jest` or `vitest` and the version installed is outside of the table, or the gate command is the one derived from the gate config and that config does not name the helper (or, for Vitest, `istanbul`), this is known beforehand and the run for the notes is not started. Otherwise that run was one run for nothing, also in every part of `--shard`.
465
+
466
+ With Jest and Vitest, and specs that do not depend on each other, the lines printed and the exit code are those of the run per gate. (A gate command of your own whose exit code says what failed would show the code of the shared run in the `✓` lines.) The two are compared with a real Jest and a real Vitest in `test/selfcheck-runners-*.test.ts`, for a gate that the spec of another gate covers, one that two other specs cover between them, one that another spec covers in part, one whose top-level code runs when another spec loads it, one that a setup file calls, one that another spec replaces with a mock, one that another spec calls in `afterAll` (with Vitest's hooks in the order written), and one that another spec calls in a cleanup function returned by `beforeAll`.
467
+
468
+ During the first run a directory `.test-gates-touch-…` exists in the subproject (Vitest loads the wrapped provider from there). It is removed when the run ends. A run that is interrupted leaves it behind; a later `selfcheck` removes what is older than an hour. Add `.test-gates-touch-*` to `.gitignore`.
469
+
470
+ **`--jobs <n>`: `<n>` negative controls at the same time.** The default is 1, one at a time. With `--jobs 4`, four runs of the gate command are under way at any moment, each for another gate. A control stays what it is: a run of the gate command with the spec of one gate left out, and of that one only. What `--jobs` adds is other runs on the same machine, and a run can be disturbed by them. Two things are therefore asked, one before the runs start and one of each run.
471
+
472
+ _Before the runs start_, the gate command and its config must be known, and for each control the files of its run that can change what it reports must not be seen to reach outside of their process. A spec whose helper takes a lock on a file, listens on a fixed port or looks at the clock can quietly do less while another run is there, and then a gate that this spec covers looks as if only its own spec covered it. Nothing in the output of such a run tells. A spec can only cover a gate it loads, and the control of a gate leaves out the spec of that gate. So the control of a gate is started next to others unless, in its run,
473
+
474
+ - a spec of another gate loads the gate (directly or through other files), and that spec or a file it loads reaches outside;
475
+ - a spec of another gate may load any gate: one of its imports cannot be followed, or one of its files loads a module whose name is computed;
476
+ - a script the gate config names (a setup file, a reporter; also in the project files the config imports) reaches outside or cannot be followed: those run with every spec, so this keeps every control on its own.
477
+
478
+ A spec that reaches outside but loads no other gate, which is what the spec of a gate that needs a clock, a file or a package usually is, keeps no control from running next to others; it is left out of the run of its own gate. A file reaches outside when it
479
+
480
+ - mentions `process.`, `globalThis`, `global.` or `import.meta.env`; the clock or a wait (`Date.now`, `new Date()`, `performance.now`, `setTimeout`, `setInterval`, `setImmediate`, `waitFor`, `waitUntil`, `expect.poll`, `retryTimes`, `retry:`); the network (`fetch(`, `WebSocket`, `XMLHttpRequest`); or `navigator`, `Atomics`, `WeakRef`, `FinalizationRegistry`, `eval(`, `Function(`,
481
+ - imports a built-in module of Node.js, also one that only computes (`path`): with Jest the spec files of one worker share the objects of the built-in modules, so a setting that one spec changes there is seen by the next, and which spec comes next depends on Jest's cache of run times,
482
+ - imports a package other than the runner's (`vitest`, `@jest/globals`). Packages are not looked into. With `settings.imports.mode: "allowlist"`, the packages that `settings.imports.allow` lists by name are taken too: the project already vouches for them as imports of a gate,
483
+ - loads a module whose name is computed.
484
+
485
+ The controls kept apart run one at a time after the others. The first lines name every one of them: a line for each spec (or the gate config) that keeps controls apart, with the gates whose controls it keeps apart and every file of it that reaches outside:
486
+
487
+ ```
488
+ test-gates selfcheck: 2 of 30 negative control(s) run one at a time, since in their runs something that loads the gate also loads a file that may meet what another run is doing:
489
+ src/tax.ts, src/age.ts: loaded by src/fee.spec.ts, in which src/support/lock.ts imports node:fs
490
+ ```
491
+
492
+ Every control runs on its own, and the first line says why, when the gate command is anything but `jest` or `vitest run` with `--config` and options for the number of workers (`--runInBand`, `-i`, `--ci`, `--maxWorkers`, `-w` for Jest; `--no-file-parallelism`, `--fileParallelism=false`, `--maxWorkers`, `--minWorkers`, `--pool=forks`, `--pool=threads` for Vitest), when the gate config does not use the helper, and with `settings.selfcheck.failurePattern`:
493
+
494
+ ```
495
+ test-gates selfcheck: one negative control at a time (the gate config does not use createVitestGatesConfig)
496
+ ```
497
+
498
+ This is a search by text: a comment that names `setTimeout` counts, and `new Date('2024-01-01')` does not. Which spec loads which gate is also read from the text: the imports, also those of a component (`.vue`, `.svelte`, `.astro`, `.mdx`), and paths handed to the runner (`vi.mock('./rates')`). A spec that loads modules by a name it computes, or by `import.meta.glob`, or that loads a component (which can use others that it does not import), may load any gate. A package that loads files of the project by itself (by a glob, for one) is not seen to load them.
499
+
500
+ _Of each run next to others_, the result is taken only when the control held and the runner itself sums the run up as undisturbed: every spec file but the one left out passed, every test passed and none was skipped (` Test Files 13 passed (13)` and `Tests 14 passed (14)` of Vitest, `Test Suites: 13 passed, 13 total` and `Tests: 14 passed, 14 total` of Jest), every threshold the run reports is that of this gate, the process ended by itself, and no other line speaks of a failure, a timeout, an error or a retry. Anything less (a test that timed out under the load, a reporter that prints no summary) and the control is run again on its own after the others have ended, and that run counts. So a control that does **not** hold is always reported from a run of its own, with the output of that run. The first line says how many controls were run again:
501
+
502
+ ```
503
+ test-gates selfcheck: 4 of 14 negative control(s) did not hold cleanly next to others and run again on their own
504
+ ```
505
+
506
+ Each of the `<n>` lines of runs has a directory of its own, outside of the project, for what the coverage tool writes and for the cache of the runner. It is given on the command line (`--coverage.reportsDirectory` and `--no-cache` for Vitest, `--coverageDirectory` and `--cacheDirectory` for Jest), which wins over the gate config, and removed when the command ends. Vitest keeps the coverage of every spec file in that directory until the run ends, and empties it when a run starts: two runs in one directory read each other's files and report a gate at 0% whose spec ran. That is why a gate command with any other argument is not run at the same time; it could name such a place itself.
507
+
508
+ **Why the default stays 1.** What `--jobs` reports was compared with one control at a time in `test/selfcheck-runners-*.test.ts`, with a real Jest and a real Vitest (both pools), for the project described above for `--quick`: the same lines and the same exit code, TBD-STRESS. A hunt for projects in which the two differ found six, all of the same kind: a control that does not hold on its own, reported as holding with `--jobs`.
509
+
510
+ | What the project does | What now keeps it from being reported as holding |
511
+ | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
512
+ | a helper of a spec takes a lock on a file and, when another run has it, leaves out the calls that cover another gate | the helper imports `fs`: that control on its own |
513
+ | the same with a server on a fixed port | the helper imports `net`: that control on its own |
514
+ | the same in a setup file named in the gate config | the files the gate config names are read too: every control on its own |
515
+ | a test skips itself when it does not get the lock (the lock is taken inside a package) | the summary of the run says `1 skipped`: that control runs again on its own |
516
+ | with Jest, a test fails there, and a reporter of the project prints no failures | there is no summary that says all passed: that control runs again on its own |
517
+ | with Vitest, the gate command names the coverage directory, so all runs share it | a gate command with such an argument: one control at a time |
518
+
519
+ The first three and the last are in `test/cli.test.ts`, the other two in `test/selfcheck-runners-*.test.ts` with the real runners. One kind is left, and it is the reason for the default: **a package the project vouches for that reaches outside of its process after all**, and quietly. With the lock of the fourth row taken inside a package listed in `settings.imports.allow`, and the test neither skipped nor failed but simply doing less, `--jobs` still reports the control as holding. Code that hides what it uses (a property of `globalThis` is found, a name put together from strings is not) is the same kind. No search by text closes that, so `--jobs` is for projects whose specs are what the specs of pure modules usually are: calls of the gate with fixed values.
520
+
521
+ A second kind came out of a check afterwards. `--jobs` leaves out of the shared runs the specs that use the clock or timers, but it finds them by a list of names, so a spec that measures elapsed time in a way that is not on the list (`+new Date`, `AbortSignal.timeout`, `performance.mark`, or a package the project vouches for) is not seen. If which path such a spec takes depends on how long something took, the lines it covers change with the load, and a control that does not hold one at a time can be counted as holding with `--jobs`. Do the final check without `--jobs`.
522
+
523
+ That the controls kept apart are chosen one by one, rather than all of them for any file that reaches outside, does not make `--jobs` safer or less safe for a control that runs next to others: what can disturb it is looked for in the same files as before, those of the specs that load its gate and those the gate config names. It makes the option work in projects where some specs need a clock, a file or a package. Before, a single such file anywhere kept every control on its own. `test/selfcheck-runners-*.test.ts` has a project of five gates, with a real Jest and a real Vitest, in which a spec that reads files loads another gate: that control runs on its own, the others next to each other, and the lines and the exit code are those of one control at a time, also for a gate that another spec covers.
524
+
525
+ **The number of workers is the gate command's.** Every run starts its own workers, and both runners take about as many as the machine has cores: `--jobs 4` is then four times that many processes. Say how many a run may take in the gate command: `"gateCommand": ["jest", "--config", "jest.gates.config.js", "--runInBand"]` (or `--maxWorkers=2`), and for Vitest `--no-file-parallelism` or `--maxWorkers=2`. `selfcheck` does not add such an option on its own: one worker per run is slower for a single run, and which is faster in sum depends on the project. TBD-SPEED
526
+
527
+ **With the other options.** `--jobs` applies to the gates that are selected: to the part of `--shard`, to the gates of `--gate`. With `--first` or a single `--gate` there is one control and nothing to run at the same time. With `--quick`, the shared run comes first and `--jobs` applies to the controls it leaves. Unlike `--quick`, `--jobs` is not refused in CI: within the limits above a control is judged from a run that left out its spec and nothing else. On machines of a hosted CI with two cores, `--shard` over parallel jobs gains more.
528
+
529
+ **A part of the gates.** Two options run only a part. A gate gets the same verdict in a part as in the whole run.
530
+
531
+ - `--shard <index>/<total>` splits the gates into `<total>` parts and checks part `<index>` (from 1). The parts are disjoint, and together they are every gate: the gates are dealt out in the order of `test-gates.json`. This is for parallel CI jobs; **every index has to run**, a single part says nothing about the gates of the others. More parts than gates is an error (exit code 2), because a part without a gate would pass without checking anything.
532
+ - `--gate <path>` checks only that gate and can be given more than once. The path is written as in `test-gates.json`; a path that is not a gate there is an error (exit code 2).
533
+
534
+ `--all`, `--first`, `--gate` and `--shard` cannot be combined, and `--shard` can be given only once. `--gate` and `--shard` win over `settings.selfcheck.mode`. Both are written before `--`: after it they would go to the gate command (Jest and Vitest have a `--shard` of their own, which would leave specs out of every run), so that is an error too (exit code 2). The last line of a run with `--gate` or `--shard` says how many gates were left out, and by what:
535
+
536
+ ```
537
+ test-gates selfcheck: OK (2 negative control(s) failed as they should, 6 gate(s) not selected by --shard 1/4)
538
+ ```
539
+
540
+ `--gate` is meant for a quick look while working on one gate, not as the check before a push: the negative control of a gate also changes when the spec of **another** gate starts to cover it (see the next paragraph), and that is a change in a file `--gate` was not pointed at. Run every gate in CI.
292
541
 
293
542
  **When a gate imports another gate.** If `delivery-price.ts` imports `shipping-fee.ts` and both are gates, the spec of `delivery-price` executes every line of `shipping-fee` as well. Leaving out `shipping-fee.spec.ts` then does not lower its coverage, and `selfcheck` fails for `shipping-fee` ("the gate passed although the spec … was left out"), naming the importing gate in a hint. The coverage floor of `shipping-fee` is being held up by somebody else's spec. Fix it in the importer's spec: mock the imported gate and assert **how it is called**, so that each spec covers only its own gate:
294
543
 
@@ -298,9 +547,11 @@ jest.mock('./shipping-fee');
298
547
  expect(shippingFeeCents).toHaveBeenCalledWith(weightGrams); // called with the weight, not with the price
299
548
  ```
300
549
 
301
- ### `test-gates lcov --file <lcov.info>`
550
+ ### `test-gates lcov [--file <lcov.info>]`
551
+
552
+ A line coverage floor from an lcov tracefile, without a mutation test: requires `LH == LF` for every gate in the tracefile. `--file` names the tracefile and can be left out when `settings.lcov.file` is set; with neither, the command exits with code 2. A gate without a record, or with no instrumented line, fails. Paths are compared as written in the tracefile (`lib/shipping_fee.dart`); absolute paths under the subproject are made relative.
302
553
 
303
- For stacks without mutation testing (Flutter / Dart): requires `LH == LF` for every gate in an lcov tracefile. A gate without a record, or with no instrumented line, fails. Paths are compared as written in the tracefile (`lib/shipping_fee.dart`); absolute paths under the subproject are made relative.
554
+ For Dart and Flutter, use [`test_gates`](https://github.com/t09tanaka/test-gates-dart) instead. It reads the same `test-gates.json`, requires 100% of lines and of branches, has a negative control and judges a mutation test. The example below shows what this command alone does with a Dart package.
304
555
 
305
556
  ```bash
306
557
  flutter test --coverage && npx test-gates lcov --file coverage/lcov.info
@@ -492,7 +743,7 @@ Why these choices:
492
743
  - Jest measures with istanbul (`babel`), because the v8 provider does not count the untaken side of an `if` without `else`.
493
744
  - Jest fails on a `coverageThreshold` path key that matches no file, so a typo in `test-gates.json` cannot pass. Vitest does the opposite: a glob or path key in `thresholds` that matches nothing passes. The Vitest helper therefore narrows `coverage.include` to the gates and uses `perFile`.
494
745
  - Stryker never fails on its own score (`break: null`). The verdict comes from `test-gates mutation`, which knows the allow list.
495
- - The Stryker helper also sets defaults that can be overridden: `coverageAnalysis: 'perTest'`, `incremental` with its file under `reports/`, and `maxTestRunnerReuse: 50` (see [Timeouts](#timeouts)).
746
+ - The Stryker helper also sets defaults that can be overridden: `coverageAnalysis: 'perTest'`, `incremental` with its file under `reports/` (reused by `test-gates mutation` only after no change, see [When the results of the last run are reused](#when-the-results-of-the-last-run-are-reused)), and `maxTestRunnerReuse: 50` (see [Timeouts](#timeouts)).
496
747
 
497
748
  `loadGates(dir)` returns the validated manifest: `gates` (each with its resolved `spec`), `candidates` and the resolved `settings`. It throws when the manifest has any problem.
498
749
 
@@ -539,16 +790,28 @@ Mutation testing is the expensive step. Numbers for this repository's own gates:
539
790
 
540
791
  The time grows with the number of mutants and depends heavily on cores and on `concurrency`. Twenty gates of this size on a slow machine are well past ten minutes for a full run. The setup that has held up in practice:
541
792
 
542
- - **pre-push: incremental.** `test-gates mutation` as is. Stryker's incremental file (`reports/stryker-incremental.json`, not committed) makes the second and later runs take seconds; the first run after a clone is a full one.
793
+ - **pre-push: incremental.** `test-gates mutation --affected` (or `test-gates mutation` as is, which runs every mutant after any change). Stryker's incremental file (`reports/stryker-incremental.json`, not committed) makes a run after no change take seconds; the first run after a clone is a full one, and so is every run after a change in the subproject or in what it depends on (see [When the results of the last run are reused](#when-the-results-of-the-last-run-are-reused)).
543
794
  - **CI: full.** `npm run test:gates:mutation -- --force`. CI has no incremental file to start from anyway, and the full run is the one that counts.
544
795
  - If even CI is too slow, split by subproject (each has its own `test-gates.json`) before you think about sampling.
545
796
 
546
- How the incremental file and this tool interact (covered by `test/incremental.test.ts`, which runs a real Stryker):
797
+ The negative control (`selfcheck`) is the step that grows fastest: a run of the gate command per gate, each executing the specs of all the other gates, grows up to the square of the number of gates. With `--quick` it uses one run for the gates that only their own spec touches (see [`selfcheck`](#commands) for what that takes for granted). Measured on a project written for this measurement: N gates of 20 lines each, one spec of five tests per gate, Vitest 3.2 with istanbul or Jest 30 with ts-jest, Stryker 10 with its own default `concurrency` (one fewer than the cores, before the helper set half of them); an Apple M2 Max (12 cores), Node 22.22, other jobs running. Read the growth, not the seconds:
798
+
799
+ | Gates | Runner | Gate run | `selfcheck` | `selfcheck --quick` | `mutation -- --force` |
800
+ | ----- | ------ | -------- | ----------- | ------------------- | --------------------- |
801
+ | 5 | Vitest | 0.9 s | 3.8 s | 1.3 s | 13 s |
802
+ | 30 | Vitest | 2.0 s | 49 s | 2.1 s | 38 s |
803
+ | 100 | Vitest | 5.1 s | 8 min 4 s | 5.0 s | 1 min 55 s |
804
+ | 5 | Jest | 0.8 s | 6.3 s | 1.4 s | 8 s |
805
+ | 30 | Jest | 2.4 s | 1 min 19 s | 3.1 s | 22 s |
806
+ | 100 | Jest | 3.1 s | 6 min 2 s | 4.5 s | 1 min 8 s |
547
807
 
548
- - `test-gates mutation` deletes the JSON **report** before each run. It does not touch the incremental file; they are different files.
808
+ In this project no spec touches the gate of another, so `selfcheck --quick` is two runs of the gate command. (That column was measured later, with fewer other jobs running than for the other columns; the difference in load is far smaller than the difference between the two columns.) Every gate that another spec touches adds a run of its own. For the plain `selfcheck`, which is the one for CI, `--shard <index>/<total>` splits the runs over parallel jobs: each part runs its share of the gates. That does not reduce the total work; only the waiting time gets shorter.
809
+
810
+ How the incremental file and this tool interact (covered by `test/incremental-*.test.ts`, which runs a real Stryker):
811
+
812
+ - `test-gates mutation` deletes the JSON **report** before each run. It does not touch the incremental file; they are different files. It decides whether Stryker may read it: see [When the results of the last run are reused](#when-the-results-of-the-last-run-are-reused).
549
813
  - A run in which every result is reused still writes a complete JSON report, and the verdict is the same as that of the run it reuses.
550
- - **A stale "Survived" can be reused.** After adding a test that kills a _static_ mutant (code that runs when the module is loaded, such as a module-level constant), the incremental run kept reporting the mutant as survived; `--force` reported it killed. The error is on the safe side, a failure that is not real, never a pass that is not real. When a survivor makes no sense after you fixed the test, run once with `-- --force`.
551
- - **After removing a gate, its old results stay in the incremental file** and come back in the report. They are ignored: only the gates of `test-gates.json` are judged. Delete the incremental file (or run with `--force`) when you change the list of gates, so that it does not carry files that are no longer mutated.
814
+ - **After removing a gate, its old results stay in the incremental file** and come back in the report. They are ignored: only the gates of `test-gates.json` are judged. (A change of `test-gates.json` makes the next run a full one, and the old results still come back.) Delete the incremental file when you change the list of gates, so that it does not carry files that are no longer mutated.
552
815
 
553
816
  ## Limitations and things measured
554
817
 
@@ -560,13 +823,23 @@ How the incremental file and this tool interact (covered by `test/incremental.te
560
823
  - **`process.env` is found by text too.** A comment that mentions it is reported, and reading the environment through another name (`const { env } = process`) is not. The clock (`Date.now()`, `new Date()`) and `Math.random()` are not checked: a default argument such as `now = new Date()` is a legitimate way to pass them in, and text cannot tell it from a direct read. Add a `forbiddenSource` rule if the project wants one.
561
824
  - **Imports are found by text, not by parsing.** The checks run without the project's dependencies installed, so an import inside a comment is reported too, and only the gate's own imports are looked at, not what those modules import in turn.
562
825
  - **An ordinary `import { Request } from 'express'` used only as a type is reported.** Write `import type`, or exempt the module with `impureImports.allow`.
563
- - **`test-gates lcov` checks lines only.** lcov from Flutter carries no branch data, and Dart has no established mutation testing tool; a Dart gate is guarded by coverage alone.
826
+ - **`test-gates lcov` checks lines only.** It does not read the branch entries (`BRDA:`) of a tracefile and runs no mutation test, so a gate that only this command checks is guarded by line coverage alone. Dart and Flutter code is guarded by [`test_gates`](https://github.com/t09tanaka/test-gates-dart), which checks lines and branches and judges a mutation test.
564
827
  - **knip reports the Stryker runner as unused** once no npm script calls `stryker run` directly. Tell knip where the config is: `"stryker": { "config": ["stryker.gates.config.mjs"] }` in `knip.json`.
565
828
  - **A directory or worktree whose name contains `test-`** (for example a checkout called `test-gates-migration`) makes `@darraghor/eslint-plugin-nestjs-typed` treat every file as a test file, because it matches the whole path against a test pattern. Not caused by this package, but a name like that is easy to choose while adopting it.
566
829
  - **A comment line that starts with `@t09tanaka/…`** in a config file can be read by ESLint's JSDoc rules as a tag. Put the package name in the middle of the sentence.
567
830
  - **Vitest 4's `text` reporter can print only the summary line** and no per-file table for the gate run. The thresholds and the negative control still work.
568
831
  - **The checks do not judge what belongs in `gates`.** Whether every important decision has been extracted and registered is for people to review; `candidates` is where the known gaps are written down.
569
832
 
833
+ ## Related tools
834
+
835
+ - [test_gates](https://github.com/t09tanaka/test-gates-dart) (pub.dev): the same gates for Dart and Flutter.
836
+ - [dart_mutant](https://github.com/t09tanaka/dart_mutant): mutation testing for Dart, the tool that writes the mutation report those gates judge.
837
+ - [contract-fixtures](https://github.com/t09tanaka/contract-fixtures) (TypeScript and Dart): shared request and response fixtures that keep an API and its clients in step.
838
+
839
+ ## Contributing
840
+
841
+ Run `npm run check:quick` (lint, typecheck, format check and the light tests; about 20 seconds) before you push. CI runs the same checks as a separate fast job, and the heavy tests (spread over four shards, on Node 22 and on Node 20), gates and mutation test as parallel jobs. `npm run test:heavy` runs all of them in one process.
842
+
570
843
  ## License
571
844
 
572
845
  MIT