@t09tanaka/test-gates 0.3.3 → 0.3.4

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4
4
+
5
+ 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.
6
+
7
+ ### Changed
8
+
9
+ - `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.
10
+ - `test-gates mutation` takes `--gate` for itself. Before, it was handed to Stryker like every unknown argument (Stryker has no such option).
11
+ - `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.
12
+
13
+ ### Added
14
+
15
+ - `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).
16
+ - `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).
17
+ - README: Dart and Flutter projects are pointed to the package for Dart; `lcov --file` is shown as optional.
18
+ - `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.
19
+ - `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.
20
+
3
21
  ## 0.3.3
4
22
 
5
23
  Documentation only; the commands and the helpers behave as in 0.3.2.
package/README.md CHANGED
@@ -109,7 +109,7 @@ export default createStrykerGatesConfig({
109
109
  });
110
110
  ```
111
111
 
112
- Add `reports/` and `.stryker-tmp/` to `.gitignore`.
112
+ Add `reports/`, `.stryker-tmp/` and `.test-gates-touch-*` (see `test-gates selfcheck`) to `.gitignore`.
113
113
 
114
114
  ### 4. Scripts
115
115
 
@@ -182,9 +182,28 @@ test-gates:
182
182
  - run: npm run test:gates:mutation
183
183
  ```
184
184
 
185
+ 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)):
186
+
187
+ ```yaml
188
+ test-gates-selfcheck:
189
+ runs-on: ubuntu-latest
190
+ strategy:
191
+ fail-fast: false
192
+ matrix:
193
+ part: [1, 2, 3, 4]
194
+ defaults:
195
+ run:
196
+ working-directory: backend
197
+ steps:
198
+ # checkout, setup-node and npm ci as above
199
+ - run: npm run test:gates:selfcheck -- --shard ${{ matrix.part }}/4
200
+ ```
201
+
202
+ 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.
203
+
185
204
  ## Commands
186
205
 
187
- All commands take `--dir <subproject>` (default: the current directory).
206
+ All commands take `--dir <subproject>` (default: the current directory). `test-gates -h` (`--help`) prints the usage and `test-gates -v` (`--version`) the version.
188
207
 
189
208
  | Exit code | Meaning |
190
209
  | --------- | -------------------------------------------------------------------------- |
@@ -208,10 +227,64 @@ Static checks, before the gate run. Reports every violation as `file:line: reaso
208
227
  - The Stryker config does not use `excludedMutations`, `ignoreStatic` or `ignorers`.
209
228
  - 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
229
 
211
- ### `test-gates mutation [-- <stryker arguments>]`
230
+ ### `test-gates mutation [--gate <path>…] [-- <stryker arguments>]`
212
231
 
213
232
  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
233
 
234
+ #### When the results of the last run are reused
235
+
236
+ 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:
237
+
238
+ - an assertion weakened in a file that the spec imports (111 of 111 results reused; three survivors),
239
+ - **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).
240
+
241
+ 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:
242
+
243
+ ```
244
+ 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
245
+ ```
246
+
247
+ 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.
248
+
249
+ What is compared:
250
+
251
+ - Every file of the subproject: gates, specs, other sources, helpers of the specs, configs, `test-gates.json`, the lock file.
252
+ - 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.
253
+ - 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`). When a path cannot be followed (`<rootDir>/../pricing-kit`, a way up handed to `path.join` as separate `'..'` arguments, a path to a file or a directory above the subproject), every file of the repository is compared.
254
+ - 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.
255
+ - The incremental file itself: it has to be the one the recorded run wrote.
256
+
257
+ With git, "every file" is what git tracks plus the untracked files it does not ignore. 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.
258
+
259
+ 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.
260
+
261
+ The paths that leave the subproject are found by text, in every file that is compared. 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 named in the message.
262
+
263
+ 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).
264
+
265
+ What it does not see, and what therefore still needs `-- --force` by hand, or CI, where the run should always be a full one:
266
+
267
+ - files git ignores (build output that a spec or a config loads, `.env`),
268
+ - environment variables, the clock, the network, and a Stryker config that reads them or the command line to decide about incremental mode,
269
+ - 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),
270
+ - changes inside `node_modules` that leave the files of the package manager as they were.
271
+
272
+ #### `--gate <path>`: only the mutants of one gate
273
+
274
+ 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`.
275
+
276
+ **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.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.
277
+
278
+ So a run with `--gate`
279
+
280
+ - 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),
281
+ - 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,
282
+ - removes the JSON report after it is judged, so that `mutation-result` cannot take it for the report of a full run,
283
+ - 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`,
284
+ - **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`.
285
+
286
+ `--gate` is written before `--`, and cannot be combined with a `--mutate` of your own.
287
+
215
288
  ### `test-gates mutation-result [--report <path>]`
216
289
 
217
290
  Judges an existing Stryker JSON report without running Stryker.
@@ -237,7 +310,7 @@ The run also fails when
237
310
  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
311
 
239
312
  ```
240
- test-gates mutation: OK (mutants 1017 / detected 1017 (timeout 2) / allowed equivalent 0 / not evaluable 0 / unallowed survivors 0)
313
+ test-gates mutation: OK (mutants 1702 / detected 1702 (timeout 2) / allowed equivalent 0 / not evaluable 0 / unallowed survivors 0)
241
314
  ```
242
315
 
243
316
  #### Timeouts
@@ -284,11 +357,108 @@ When the run fails on a timeout, first run again with less load (`npm run test:g
284
357
 
285
358
  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
359
 
287
- ### `test-gates selfcheck [--all | --first] [-- <gate command>]`
360
+ ### `test-gates selfcheck [--all | --first | --gate <path>… | --shard <index>/<total>] [--jobs <n>] [--quick] [-- <gate command>]`
288
361
 
289
362
  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
363
 
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.
364
+ 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.
365
+
366
+ **`--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.
367
+
368
+ **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.
369
+
370
+ 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.
371
+
372
+ | Where | Run |
373
+ | ------------------------------------------------------- | ------------------------------------------------------------------------------------- |
374
+ | while working on one gate | `selfcheck --gate <path>` |
375
+ | before a push, when CI runs the gates too | `selfcheck --quick` |
376
+ | before a push, when that hook is the only gate there is | `selfcheck` |
377
+ | CI | `selfcheck`, with `--shard <index>/<total>` over parallel jobs when it takes too long |
378
+
379
+ 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 (…)`).
380
+
381
+ 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.
382
+
383
+ | Runner | Versions | Coverage | Pool | Other |
384
+ | ------ | -------- | ------------------------------ | ---------------------------- | -------------------------------------- |
385
+ | Jest | 30.5 | `babel` (what the helper sets) | — | one project |
386
+ | Vitest | 3.2 | `istanbul` | `forks` (default), `threads` | `isolate: true` (default), one project |
387
+
388
+ 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.
389
+
390
+ 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.
391
+
392
+ 3. **Only on a gate run that passes.** A failing test or a gate below 100% in the full run: a run per gate.
393
+
394
+ 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
395
+
396
+ - 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`),
397
+ - 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),
398
+ - `settings.selfcheck.failurePattern` does not contain `{gate}`, or a gate has no single spec.
399
+
400
+ 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`.
401
+
402
+ 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`.
403
+
404
+ 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`.
405
+
406
+ **`--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.
407
+
408
+ _Before the runs start_, the files a gate run loads 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. The files are: the spec of every gate, every script the gate config names (a setup file, a reporter; also in the project files the config imports), and all the project files those import, the gates among them. When one of them
409
+
410
+ - 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(`,
411
+ - 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,
412
+ - 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,
413
+ - loads a module whose name is computed, or imports something that is neither a file nor an installed package (an alias that `settings.importAliases` does not have),
414
+
415
+ every control runs on its own, and the first line says why:
416
+
417
+ ```
418
+ test-gates selfcheck: one negative control at a time (src/support/lock.ts imports node:fs, and may meet what another run is doing)
419
+ ```
420
+
421
+ The same happens 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`. This is a search by text: a comment that names `setTimeout` counts, and `new Date('2024-01-01')` does not.
422
+
423
+ _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:
424
+
425
+ ```
426
+ test-gates selfcheck: 4 of 14 negative control(s) did not hold cleanly next to others and run again on their own
427
+ ```
428
+
429
+ 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.
430
+
431
+ **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`.
432
+
433
+ | What the project does | What now keeps it from being reported as holding |
434
+ | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
435
+ | 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`: one control at a time |
436
+ | the same with a server on a fixed port | the helper imports `net`: one control at a time |
437
+ | the same in a setup file named in the gate config | the files the gate config names are read too |
438
+ | 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 |
439
+ | 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 |
440
+ | 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 |
441
+
442
+ 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.
443
+
444
+ 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`.
445
+
446
+ **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
447
+
448
+ **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.
449
+
450
+ **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.
451
+
452
+ - `--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.
453
+ - `--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).
454
+
455
+ `--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:
456
+
457
+ ```
458
+ test-gates selfcheck: OK (2 negative control(s) failed as they should, 6 gate(s) not selected by --shard 1/4)
459
+ ```
460
+
461
+ `--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
462
 
293
463
  **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
464
 
@@ -298,9 +468,11 @@ jest.mock('./shipping-fee');
298
468
  expect(shippingFeeCents).toHaveBeenCalledWith(weightGrams); // called with the weight, not with the price
299
469
  ```
300
470
 
301
- ### `test-gates lcov --file <lcov.info>`
471
+ ### `test-gates lcov [--file <lcov.info>]`
472
+
473
+ 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
474
 
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.
475
+ 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
476
 
305
477
  ```bash
306
478
  flutter test --coverage && npx test-gates lcov --file coverage/lcov.info
@@ -492,7 +664,7 @@ Why these choices:
492
664
  - Jest measures with istanbul (`babel`), because the v8 provider does not count the untaken side of an `if` without `else`.
493
665
  - 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
666
  - 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)).
667
+ - 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
668
 
497
669
  `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
670
 
@@ -539,16 +711,28 @@ Mutation testing is the expensive step. Numbers for this repository's own gates:
539
711
 
540
712
  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
713
 
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.
714
+ - **pre-push: incremental.** `test-gates mutation` as is. 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
715
  - **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
716
  - If even CI is too slow, split by subproject (each has its own `test-gates.json`) before you think about sampling.
545
717
 
718
+ 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 without a `concurrency` setting; an Apple M2 Max (12 cores), Node 22.22, other jobs running. Read the growth, not the seconds:
719
+
720
+ | Gates | Runner | Gate run | `selfcheck` | `selfcheck --quick` | `mutation -- --force` |
721
+ | ----- | ------ | -------- | ----------- | ------------------- | --------------------- |
722
+ | 5 | Vitest | 0.9 s | 3.8 s | 1.3 s | 13 s |
723
+ | 30 | Vitest | 2.0 s | 49 s | 2.1 s | 38 s |
724
+ | 100 | Vitest | 5.1 s | 8 min 4 s | 5.0 s | 1 min 55 s |
725
+ | 5 | Jest | 0.8 s | 6.3 s | 1.4 s | 8 s |
726
+ | 30 | Jest | 2.4 s | 1 min 19 s | 3.1 s | 22 s |
727
+ | 100 | Jest | 3.1 s | 6 min 2 s | 4.5 s | 1 min 8 s |
728
+
729
+ 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.
730
+
546
731
  How the incremental file and this tool interact (covered by `test/incremental.test.ts`, which runs a real Stryker):
547
732
 
548
- - `test-gates mutation` deletes the JSON **report** before each run. It does not touch the incremental file; they are different files.
733
+ - `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
734
  - 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.
735
+ - **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
736
 
553
737
  ## Limitations and things measured
554
738
 
@@ -560,13 +744,23 @@ How the incremental file and this tool interact (covered by `test/incremental.te
560
744
  - **`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
745
  - **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
746
  - **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.
747
+ - **`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
748
  - **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
749
  - **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
750
  - **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
751
  - **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
752
  - **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
753
 
754
+ ## Related tools
755
+
756
+ - [test_gates](https://github.com/t09tanaka/test-gates-dart) (pub.dev): the same gates for Dart and Flutter.
757
+ - [dart_mutant](https://github.com/t09tanaka/dart_mutant): mutation testing for Dart, the tool that writes the mutation report those gates judge.
758
+ - [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.
759
+
760
+ ## Contributing
761
+
762
+ 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, gates and mutation test as parallel jobs.
763
+
570
764
  ## License
571
765
 
572
766
  MIT
@@ -0,0 +1,163 @@
1
+ // src/touch.ts
2
+ import { createHash } from "crypto";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { fileURLToPath } from "url";
6
+ var TOUCH_ENV = "TEST_GATES_TOUCH_DIR";
7
+ function isObject(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+ function countersOf(coverage) {
11
+ return {
12
+ s: Object.values(coverage.s ?? {}),
13
+ f: Object.values(coverage.f ?? {}),
14
+ b: Object.values(coverage.b ?? {}).flat()
15
+ };
16
+ }
17
+ function countersOfAll(coverage) {
18
+ if (!isObject(coverage)) {
19
+ return null;
20
+ }
21
+ const files = {};
22
+ for (const [file, counters] of Object.entries(coverage)) {
23
+ if (!isObject(counters)) {
24
+ return null;
25
+ }
26
+ files[file] = countersOf(counters);
27
+ }
28
+ return files;
29
+ }
30
+ function add(before, now) {
31
+ return before.length === now.length ? before.map((count, index) => count + (now[index] ?? 0)) : [-1];
32
+ }
33
+ function writeSpecNote(directory, spec, coverage) {
34
+ const files = countersOfAll(coverage) ?? {};
35
+ const specs = path.join(directory, "specs");
36
+ fs.mkdirSync(specs, { recursive: true });
37
+ const file = path.join(specs, `${createHash("sha256").update(spec).digest("hex")}.json`);
38
+ let before = {};
39
+ try {
40
+ before = JSON.parse(fs.readFileSync(file, "utf8")).files;
41
+ } catch {
42
+ }
43
+ for (const [name, counters] of Object.entries(before)) {
44
+ const now = files[name];
45
+ files[name] = now === void 0 ? counters : { s: add(counters.s, now.s), f: add(counters.f, now.f), b: add(counters.b, now.b) };
46
+ }
47
+ const note = { spec, files };
48
+ fs.writeFileSync(file, JSON.stringify(note));
49
+ }
50
+ function writeRunInfo(directory, run) {
51
+ fs.mkdirSync(directory, { recursive: true });
52
+ fs.writeFileSync(path.join(directory, "run.json"), JSON.stringify(run));
53
+ }
54
+ function jestReporterFile(moduleUrl) {
55
+ const directory = path.dirname(fileURLToPath(moduleUrl));
56
+ const found = [".cjs", ".ts"].map((extension) => path.join(directory, `jest-touch-reporter${extension}`)).find((file) => fs.existsSync(file));
57
+ if (found === void 0) {
58
+ throw new Error(`test-gates: jest-touch-reporter is missing in ${directory}`);
59
+ }
60
+ return found;
61
+ }
62
+ var VITEST_PROVIDER = `import { createHash } from 'node:crypto';
63
+ import fs from 'node:fs';
64
+ import path from 'node:path';
65
+ import istanbul from '@vitest/coverage-istanbul';
66
+
67
+ const directory = "__DIRECTORY__";
68
+ const run = { runner: 'vitest', version: 'unknown', provider: 'istanbul', pool: 'unknown', isolate: false, projects: 0, problem: null };
69
+ const save = () => fs.writeFileSync(path.join(directory, 'run.json'), JSON.stringify(run));
70
+ const problem = (text) => {
71
+ run.problem ??= text;
72
+ save();
73
+ };
74
+
75
+ const counts = (coverage) =>
76
+ Object.fromEntries(
77
+ Object.entries(coverage).map(([file, counters]) => [
78
+ file,
79
+ {
80
+ s: Object.values(counters.s ?? {}),
81
+ f: Object.values(counters.f ?? {}),
82
+ b: Object.values(counters.b ?? {}).flat(),
83
+ },
84
+ ])
85
+ );
86
+ const add = (before, now) =>
87
+ before.length === now.length ? before.map((count, index) => count + now[index]) : [-1];
88
+
89
+ function note(spec, files) {
90
+ const file = path.join(directory, 'specs', createHash('sha256').update(spec).digest('hex') + '.json');
91
+ let before = {};
92
+ try {
93
+ before = JSON.parse(fs.readFileSync(file, 'utf8')).files;
94
+ } catch {
95
+ // the first note of this spec
96
+ }
97
+ const merged = { ...files };
98
+ for (const [name, counters] of Object.entries(before)) {
99
+ const now = files[name];
100
+ merged[name] =
101
+ now === undefined
102
+ ? counters
103
+ : { s: add(counters.s, now.s), f: add(counters.f, now.f), b: add(counters.b, now.b) };
104
+ }
105
+ fs.writeFileSync(file, JSON.stringify({ spec, files: merged }));
106
+ }
107
+
108
+ export default {
109
+ ...istanbul,
110
+ async getProvider() {
111
+ const provider = await istanbul.getProvider();
112
+ fs.mkdirSync(path.join(directory, 'specs'), { recursive: true });
113
+ save();
114
+ if (typeof provider.onAfterSuiteRun !== 'function' || typeof provider.initialize !== 'function') {
115
+ problem('the coverage provider of this Vitest has no onAfterSuiteRun or no initialize');
116
+ return provider;
117
+ }
118
+ const initialize = provider.initialize.bind(provider);
119
+ provider.initialize = (ctx) => {
120
+ // A project can set its own pool and isolation: all of them have to say the same.
121
+ const projects = Array.isArray(ctx?.projects) ? ctx.projects : [];
122
+ const configs = [ctx?.config ?? {}, ...projects.map((project) => project?.config ?? {})];
123
+ const pools = [...new Set(configs.map((config) => (typeof config.pool === 'string' ? config.pool : 'unknown')))];
124
+ run.version = typeof ctx?.version === 'string' ? ctx.version : 'unknown';
125
+ run.pool = pools.length === 1 ? pools[0] : 'mixed';
126
+ run.isolate = configs.every(
127
+ (config) => (config.poolOptions?.[config.pool]?.isolate ?? config.isolate) === true
128
+ );
129
+ run.projects = projects.length;
130
+ save();
131
+ return initialize(ctx);
132
+ };
133
+ const handOver = provider.onAfterSuiteRun.bind(provider);
134
+ provider.onAfterSuiteRun = (meta) => {
135
+ const coverage = meta?.coverage;
136
+ if (coverage !== undefined && coverage !== null) {
137
+ const specs = meta.testFiles;
138
+ if (typeof coverage !== 'object' || !Array.isArray(specs) || specs.length !== 1 || typeof specs[0] !== 'string') {
139
+ problem('Vitest handed over coverage that is not that of exactly one spec file');
140
+ } else {
141
+ note(specs[0], counts(coverage));
142
+ }
143
+ }
144
+ return handOver(meta);
145
+ };
146
+ return provider;
147
+ },
148
+ };
149
+ `;
150
+ function vitestProviderFile(directory) {
151
+ const file = path.join(directory, "vitest-provider.mjs");
152
+ fs.mkdirSync(directory, { recursive: true });
153
+ fs.writeFileSync(file, VITEST_PROVIDER.replace('"__DIRECTORY__"', JSON.stringify(directory)));
154
+ return file;
155
+ }
156
+
157
+ export {
158
+ TOUCH_ENV,
159
+ writeSpecNote,
160
+ writeRunInfo,
161
+ jestReporterFile,
162
+ vitestProviderFile
163
+ };
@@ -452,6 +452,24 @@ function resolveSettings(raw) {
452
452
 
453
453
  // src/core/selfcheck.ts
454
454
  var EXCLUDE_ENV = "TEST_GATES_EXCLUDE_SPEC";
455
+ var EXCLUDE_ALL_ENV = "TEST_GATES_EXCLUDE_SPECS";
456
+ function excludedSpecs(env) {
457
+ const one = env[EXCLUDE_ENV];
458
+ const several = env[EXCLUDE_ALL_ENV];
459
+ const excluded = one === void 0 || one === "" ? [] : [one];
460
+ if (several === void 0 || several === "") {
461
+ return excluded;
462
+ }
463
+ let listed = null;
464
+ try {
465
+ listed = JSON.parse(several);
466
+ } catch {
467
+ }
468
+ if (!Array.isArray(listed) || !listed.every((spec) => typeof spec === "string")) {
469
+ throw new Error(`test-gates: ${EXCLUDE_ALL_ENV} must be a JSON list of spec paths`);
470
+ }
471
+ return [...excluded, ...listed];
472
+ }
455
473
  var THRESHOLD_MESSAGE = /threshold|coverage data for/i;
456
474
  function escapeRegExp(text) {
457
475
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -503,19 +521,22 @@ function looksLikeSpec(gatePath, settings) {
503
521
  // src/fs.ts
504
522
  import fs from "fs";
505
523
  import path from "path";
506
- function existsExact(rootDir, relativePath, kind = "file") {
524
+ function existsExact(rootDir, relativePath, kind = "file", listing = /* @__PURE__ */ new Map()) {
507
525
  let current = rootDir;
508
526
  for (const segment of relativePath.split("/")) {
509
527
  if (segment === "" || segment === ".") {
510
528
  continue;
511
529
  }
512
- let entries;
513
- try {
514
- entries = fs.readdirSync(current);
515
- } catch {
516
- return false;
530
+ let entries = listing.get(current);
531
+ if (entries === void 0) {
532
+ try {
533
+ entries = fs.readdirSync(current);
534
+ } catch {
535
+ entries = null;
536
+ }
537
+ listing.set(current, entries);
517
538
  }
518
- if (!entries.includes(segment)) {
539
+ if (entries === null || !entries.includes(segment)) {
519
540
  return false;
520
541
  }
521
542
  current = path.join(current, segment);
@@ -553,9 +574,12 @@ function readManifest(dir) {
553
574
  }
554
575
  throw error;
555
576
  }
577
+ const listing = /* @__PURE__ */ new Map();
556
578
  const gates = parsed.gates.map((gate) => {
557
579
  const specCandidates = specCandidatesFor(gate.path, settings);
558
- const existingSpecs = specCandidates.filter((candidate) => existsExact(absoluteDir, candidate));
580
+ const existingSpecs = specCandidates.filter(
581
+ (candidate) => existsExact(absoluteDir, candidate, "file", listing)
582
+ );
559
583
  return {
560
584
  ...gate,
561
585
  specCandidates,
@@ -582,8 +606,8 @@ function loadGates(dir = process.cwd()) {
582
606
  return manifest;
583
607
  }
584
608
  function gateSpecs(manifest, env = process.env) {
585
- const excluded = env[EXCLUDE_ENV];
586
- return manifest.gates.map((gate) => gate.spec).filter((spec) => spec !== null && spec !== excluded);
609
+ const excluded = excludedSpecs(env);
610
+ return manifest.gates.map((gate) => gate.spec).filter((spec) => spec !== null && !excluded.includes(spec));
587
611
  }
588
612
  var NO_SPEC_PATTERN = "__test_gates_no_spec_left__";
589
613