@t09tanaka/test-gates 0.2.0 → 0.3.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ Three defaults move to the strict side. A project that passed with 0.2.0 can fail with 0.3.0 without any change of its own; each case below says what to do.
6
+
7
+ ### Changed (breaking)
8
+
9
+ - `settings.mutation.maxTimeouts` defaults to `0` (was: no limit). A mutant that times out outside `expectedTimeouts` now fails `test-gates mutation`. Run again with less load (`-- --concurrency 1`) first. For a mutant that can never finish, add it to `expectedTimeouts` of the gate with the reason. To tolerate some for now, set `settings.mutation.maxTimeouts` to a number.
10
+ - `test-gates check` fails, instead of warning, when the gate run uses Vitest below 4 with the v8 provider and neither `istanbul` nor `experimentalAstAwareRemapping: true`. Use `createVitestGatesConfig({ coverageProvider: 'istanbul' })` (needs `@vitest/coverage-istanbul`), or move to Vitest 4.
11
+ - A gate that reads `process.env` fails `test-gates check` (new built-in `forbiddenSource` rule). Take the value as an argument and read the environment in the caller. `settings.forbiddenSource.defaults: false` turns the built-in rules off, including the `'use client'` / `'use server'` one.
12
+
13
+ ### Removed
14
+
15
+ - The `warning:` line of `test-gates check`. Nothing is reported as a warning any more.
16
+
3
17
  ## 0.2.0
4
18
 
5
19
  Everything added here is opt-in. With an unchanged `test-gates.json` and unchanged config files, 0.2.0 gives the same verdicts, exit codes and summary line as 0.1.0. The one visible difference is a warning on stderr from `test-gates check` for Vitest 3 with the v8 provider (see below); it does not change the exit code.
package/README.md CHANGED
@@ -199,12 +199,14 @@ Static checks, before the gate run. Reports every violation as `file:line: reaso
199
199
  - Every gate exists, and so does its spec. **The check is case-sensitive** even on macOS and Windows, because CI on Linux is.
200
200
  - No path is listed twice, or in both `gates` and `candidates`. Candidates exist.
201
201
  - A gate has no decorator (`@Name(` at the start of a line) and no runtime import of a framework, database client or SDK. `import type` is ignored.
202
+ - A gate does not read `process.env` (since 0.3.0). What comes from the environment is an input: take it as an argument.
202
203
  - A gate and its spec have no coverage ignore directive (`istanbul ignore`, `c8 ignore`, `v8 ignore`).
203
204
  - A spec has no `.skip`, `.only`, `.todo`, `xit`, `xdescribe`, `fit`, `fdescribe`, `.skipIf`, `.runIf`, `.fails`.
204
205
  - A spec does not `jest.mock` / `vi.mock` the module it is supposed to test.
205
206
  - A gate has no `Stryker disable` / `Stryker restore` comment. Such a comment turns off a whole line for a whole mutator, which takes non-equivalent mutants out with it.
206
207
  - `equivalentMutants` entries are well formed and each has a `reason`.
207
208
  - The Stryker config does not use `excludedMutations`, `ignoreStatic` or `ignorers`.
209
+ - 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).
208
210
 
209
211
  ### `test-gates mutation [-- <stryker arguments>]`
210
212
 
@@ -228,9 +230,9 @@ The run also fails when
228
230
  - two allowances point at the same mutant, or an allowance has no `reason`,
229
231
  - a gate is missing from the report or has no mutants, or no mutant was evaluated at all,
230
232
  - the report was made from a different version of a gate than the one on disk.
231
- - more mutants timed out than `settings.mutation.maxTimeouts` allows (only when that setting is present, see [Timeouts](#timeouts)).
233
+ - a mutant timed out outside `expectedTimeouts`, or more of them than `settings.mutation.maxTimeouts` allows (see [Timeouts](#timeouts)).
232
234
 
233
- `Timeout` counts as detected, but its count is always printed on its own (`detected N (timeout M)`). On a loaded machine mutants time out that would otherwise survive, so a rising timeout count means the run may be hiding survivors: rerun with lower `--concurrency`.
235
+ `Timeout` counts as detected, but its count is always printed on its own (`detected N (timeout M)`). On a loaded machine mutants time out that would otherwise survive, so a timeout nobody has explained fails the run.
234
236
 
235
237
  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:
236
238
 
@@ -240,7 +242,7 @@ test-gates mutation: OK (mutants 317 / detected 308 (timeout 0) / allowed equiva
240
242
 
241
243
  #### Timeouts
242
244
 
243
- A timeout is a detection only if the mutant really cannot finish. On a loaded machine a mutant that would have survived can time out instead, and the run looks better than it is. Two opt-in controls (since 0.2.0):
245
+ A timeout is a detection only if the mutant really cannot finish. On a loaded machine a mutant that would have survived can time out instead, and the run looks better than it is. Since 0.3.0 a timeout therefore fails the run unless the gate lists it:
244
246
 
245
247
  ```json
246
248
  {
@@ -258,13 +260,12 @@ A timeout is a detection only if the mutant really cannot finish. On a loaded ma
258
260
  }
259
261
  ]
260
262
  }
261
- ],
262
- "settings": { "mutation": { "maxTimeouts": 0 } }
263
+ ]
263
264
  }
264
265
  ```
265
266
 
266
- - `settings.mutation.maxTimeouts`: how many timeouts outside `expectedTimeouts` are tolerated. Above it the run fails with exit code 1 and lists every such mutant as `file:line:column / mutator / original → replacement`, with a fragment to paste. Not set means no limit, which is the 0.1.0 behaviour.
267
267
  - `expectedTimeouts` (per gate): mutants that turn a loop into one that never ends. Same key as `equivalentMutants` (`mutator` + `original` + `replacement`, `occurrence` when ambiguous, `reason` required).
268
+ - `settings.mutation.maxTimeouts`: how many timeouts outside `expectedTimeouts` are tolerated, default `0`. Above it the run fails with exit code 1 and lists every such mutant as `file:line:column / mutator / original → replacement`, with a fragment to paste. Up to 0.2.0 the default was no limit; a project that cannot get its timeouts to zero yet sets a number here and lowers it.
268
269
 
269
270
  How an `expectedTimeouts` entry is judged:
270
271
 
@@ -277,7 +278,7 @@ How an `expectedTimeouts` entry is judged:
277
278
 
278
279
  `Killed` is accepted on purpose, unlike a stale `equivalentMutants` entry. A mutant that hangs is reported as `Timeout` when Stryker's timer fires first and as `Killed` when the test runner's own per-test timeout fires first; which one wins depends on the load. Failing on `Killed` would make the list flap between two runs of the same code. And nothing is hidden by accepting it: `Killed` is the best possible outcome.
279
280
 
280
- When the limit is exceeded, first run again with less load (`npm run test:gates:mutation -- --concurrency 1`). Add an entry only for a mutant that can never finish.
281
+ When the run fails on a timeout, first run again with less load (`npm run test:gates:mutation -- --concurrency 1`). Add an entry only for a mutant that can never finish.
281
282
 
282
283
  ### `test-gates selfcheck [--all | --first] [-- <gate command>]`
283
284
 
@@ -360,7 +361,7 @@ All optional. Patterns are regular expressions written as JSON strings; a rule i
360
361
  | `impureImports.allow` | none | Module patterns exempt from every rule (e.g. `^express$` where `express` is imported only for its types without `import type`) |
361
362
  | `impureNamedImports` | `PrismaClient` from `@prisma/client` | `{ "defaults", "add": [{ "module", "names", "reason" }] }`: names banned from a module that is otherwise fine |
362
363
  | `impurePaths` | `.vue` `.jsx` `.tsx`, `.d.ts` | `{ "defaults", "add" }`: gate paths that are rejected (`\\.service\\.ts$`, `^src/hooks/`) |
363
- | `forbiddenSource` | `'use client'` / `'use server'` | `{ "defaults", "add" }`: lines that must not appear in a gate, tested one line at a time |
364
+ | `forbiddenSource` | `'use client'` / `'use server'`, `process.env` (0.3.0) | `{ "defaults", "add" }`: lines that must not appear in a gate, tested one line at a time |
364
365
  | `importAliases` | none | `[{ "prefix": "@/", "target": "src/" }]`, to recognise `jest.mock('@/x')` of the module under test |
365
366
  | `gateCommand` | derived from `jest.gates.config.*` / `vitest.gates.config.*` | Command `selfcheck` runs, as an array (`["jest", "--config", "jest.gates.config.js", "--maxWorkers=2"]`) |
366
367
  | `selfcheck.mode` | `all` | `all` or `first` |
@@ -371,7 +372,7 @@ All optional. Patterns are regular expressions written as JSON strings; a rule i
371
372
  | `lcov.summaryExclude` | none | Paths left out of the reference total (`\\.g\\.dart$`) |
372
373
  | `imports.mode` (0.2.0) | `blocklist` | `allowlist` turns the import check around, see [Allowlist mode](#allowlist-mode) |
373
374
  | `imports.allow` (0.2.0) | none | Packages a gate may import in allowlist mode: `"decimal.js"`, `{ "pattern": "^@acme/pure-" }`, `{ "module": "@prisma/client", "names": ["Prisma"] }` |
374
- | `mutation.maxTimeouts` (0.2.0) | none (no limit) | Timeouts tolerated outside `expectedTimeouts`, see [Timeouts](#timeouts) |
375
+ | `mutation.maxTimeouts` (0.2.0) | `0` (0.3.0; no limit before) | Timeouts tolerated outside `expectedTimeouts`, see [Timeouts](#timeouts) |
375
376
 
376
377
  Built-in impure imports: `@nestjs/*`, `class-validator`, `class-transformer`, `typeorm`, `sequelize`, `mongoose`, `knex`, `pg`, `mysql`, `mysql2`, `ioredis`, `redis`, `@prisma/adapter-*`, `react`, `react-dom`, `next`, `server-only`, `client-only`, `vue`, `vue-router`, `vue-i18n`, `pinia`, `nuxt`, `#app`, `#imports`, `*.vue`, `express`, `express-jwt`, `fastify`, `koa`, `rxjs`, `passport`, `passport-*`, `axios`, `openapi-fetch`, `stripe`, `nodemailer`, `firebase-admin`, `@aws-sdk/*`, `@sentry/*`, `@slack/*`, `@stripe/*`, and the Node.js modules `fs`, `http`, `https`, `http2`, `net`, `tls`, `dgram`, `dns`, `child_process`, `worker_threads`.
377
378
 
@@ -490,7 +491,7 @@ export default defineConfig(
490
491
  );
491
492
  ```
492
493
 
493
- The default stays `v8` because changing it would break every project that has only `@vitest/coverage-v8` installed. Instead `test-gates check` prints a warning (the exit code does not change) when it finds a `vitest.gates.config.*`, an installed Vitest below 4, and neither `istanbul` nor `experimentalAstAwareRemapping: true` in that config.
494
+ The helper's default stays `v8` because changing it would break every project that has only `@vitest/coverage-v8` installed. Instead `test-gates check` fails (since 0.3.0; a warning in 0.2.0) when it finds a `vitest.gates.config.*`, an installed Vitest below 4, and neither `istanbul` nor `experimentalAstAwareRemapping: true` in that config. On Vitest 4 the v8 provider passes the check.
494
495
 
495
496
  ## Cost, and how to use Stryker's incremental mode
496
497
 
@@ -523,6 +524,7 @@ How the incremental file and this tool interact (covered by `test/incremental.te
523
524
  - **Stryker 10 does not start on Node.js 22.10** (`ERR_REQUIRE_ESM`). It runs on 22.14 and 22.22. On Node.js below 22.12 use Stryker 9.
524
525
  - **Do not call the gate while the spec file is being collected** (in the body of `describe`, or at module level). The mutants reached that way become _static_ mutants, and with Stryker 10 and Vitest 3 a spec file that throws during collection was reported as `Survived`, not `Killed`. Call the gate inside `it` / `beforeEach`.
525
526
  - **Jest treats an empty `testMatch` as its default pattern** and would run every test of the project. The helpers never produce an empty list; if you write the gate config by hand, do not either.
527
+ - **`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.
526
528
  - **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.
527
529
  - **An ordinary `import { Request } from 'express'` used only as a type is reported.** Write `import type`, or exempt the module with `impureImports.allow`.
528
530
  - **`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.
@@ -174,8 +174,13 @@ var DEFAULT_FORBIDDEN_SOURCE = [
174
174
  {
175
175
  pattern: `^\\s*(['"])use (client|server)\\1`,
176
176
  reason: "'use client' / 'use server' directive (not a pure module)"
177
+ },
178
+ {
179
+ pattern: "\\bprocess\\??\\.env\\b",
180
+ reason: "reads process.env (not a pure module). Take the value as an argument"
177
181
  }
178
182
  ];
183
+ var DEFAULT_MAX_TIMEOUTS = 0;
179
184
  var SettingsError = class extends Error {
180
185
  };
181
186
  function isRecord2(value) {
@@ -437,7 +442,7 @@ function resolveSettings(raw) {
437
442
  strykerReportFile: optionalString(stryker.reportFile, "settings.stryker.reportFile") ?? DEFAULT_REPORT_FILE,
438
443
  importMode: importsSection.mode ?? "blocklist",
439
444
  importAllow,
440
- maxTimeouts: mutation.maxTimeouts ?? null,
445
+ maxTimeouts: mutation.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS,
441
446
  lcovFile: optionalString(lcov.file, "settings.lcov.file"),
442
447
  lcovSummaryExclude: lcov.summaryExclude === void 0 ? [] : stringArray(lcov.summaryExclude, "settings.lcov.summaryExclude").map(
443
448
  (pattern, index) => compile(pattern, void 0, `settings.lcov.summaryExclude[${index}]`)
package/dist/cli.js CHANGED
@@ -542,7 +542,7 @@ function looksLikeSpec(gatePath, settings) {
542
542
  }
543
543
 
544
544
  // src/core/vitest-coverage.ts
545
- function vitestCoverageWarning(input) {
545
+ function vitestCoverageViolation(input) {
546
546
  if (input.vitestVersion === null || input.vitestVersion === "") {
547
547
  return null;
548
548
  }
@@ -649,8 +649,13 @@ var DEFAULT_FORBIDDEN_SOURCE = [
649
649
  {
650
650
  pattern: `^\\s*(['"])use (client|server)\\1`,
651
651
  reason: "'use client' / 'use server' directive (not a pure module)"
652
+ },
653
+ {
654
+ pattern: "\\bprocess\\??\\.env\\b",
655
+ reason: "reads process.env (not a pure module). Take the value as an argument"
652
656
  }
653
657
  ];
658
+ var DEFAULT_MAX_TIMEOUTS = 0;
654
659
  var SettingsError = class extends Error {
655
660
  };
656
661
  function isRecord2(value) {
@@ -912,7 +917,7 @@ function resolveSettings(raw) {
912
917
  strykerReportFile: optionalString(stryker.reportFile, "settings.stryker.reportFile") ?? DEFAULT_REPORT_FILE,
913
918
  importMode: importsSection.mode ?? "blocklist",
914
919
  importAllow,
915
- maxTimeouts: mutation.maxTimeouts ?? null,
920
+ maxTimeouts: mutation.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS,
916
921
  lcovFile: optionalString(lcov.file, "settings.lcov.file"),
917
922
  lcovSummaryExclude: lcov.summaryExclude === void 0 ? [] : stringArray(lcov.summaryExclude, "settings.lcov.summaryExclude").map(
918
923
  (pattern, index) => compile(pattern, void 0, `settings.lcov.summaryExclude[${index}]`)
@@ -1086,6 +1091,7 @@ function checkGates(manifest) {
1086
1091
  violations.push(...scanStrykerConfig(strykerConfig, readText(dir, strykerConfig)));
1087
1092
  }
1088
1093
  }
1094
+ violations.push(...checkVitestCoverage(manifest));
1089
1095
  return violations;
1090
1096
  }
1091
1097
  var VITEST_GATE_CONFIGS = ["ts", "mts", "cts", "js", "mjs", "cjs"].map(
@@ -1108,23 +1114,20 @@ function installedVitestVersion(startDir) {
1108
1114
  }
1109
1115
  }
1110
1116
  }
1111
- function checkWarnings(manifest) {
1117
+ function checkVitestCoverage(manifest) {
1112
1118
  const config = VITEST_GATE_CONFIGS.find((name) => fs3.existsSync(path3.join(manifest.dir, name)));
1113
1119
  if (!config) {
1114
1120
  return [];
1115
1121
  }
1116
- const warning = vitestCoverageWarning({
1122
+ const message = vitestCoverageViolation({
1117
1123
  vitestVersion: installedVitestVersion(manifest.dir),
1118
1124
  configSource: readText(manifest.dir, config)
1119
1125
  });
1120
- return warning === null ? [] : [`${config}: ${warning}`];
1126
+ return message === null ? [] : [{ file: config, message }];
1121
1127
  }
1122
1128
  function runCheck(dir, io2) {
1123
1129
  const manifest = readManifest(dir);
1124
1130
  const violations = checkGates(manifest);
1125
- for (const warning of checkWarnings(manifest)) {
1126
- io2.err(`test-gates check: warning: ${warning}`);
1127
- }
1128
1131
  if (violations.length > 0) {
1129
1132
  printViolations(io2, "check", violations);
1130
1133
  return 1;
@@ -1531,8 +1534,8 @@ function judgeMutationReport(input) {
1531
1534
  message: "no mutants were evaluated (the mutation test ran on nothing)"
1532
1535
  });
1533
1536
  }
1534
- const maxTimeouts = input.maxTimeouts ?? null;
1535
- if (maxTimeouts !== null && unexpectedTimeouts.length > maxTimeouts) {
1537
+ const maxTimeouts = input.maxTimeouts ?? 0;
1538
+ if (unexpectedTimeouts.length > maxTimeouts) {
1536
1539
  violations.push({
1537
1540
  file: MANIFEST_FILE,
1538
1541
  message: `${unexpectedTimeouts.length} mutant(s) timed out outside expectedTimeouts; settings.mutation.maxTimeouts allows ${maxTimeouts}`
@@ -1614,7 +1617,7 @@ function runMutationResult(dir, reportOverride, io2) {
1614
1617
  io2.err("");
1615
1618
  }
1616
1619
  const maxTimeouts = manifest.settings.maxTimeouts;
1617
- if (maxTimeouts !== null && verdict.unexpectedTimeouts.length > maxTimeouts) {
1620
+ if (verdict.unexpectedTimeouts.length > maxTimeouts) {
1618
1621
  io2.err(
1619
1622
  `test-gates mutation: ${verdict.unexpectedTimeouts.length} mutant(s) timed out (settings.mutation.maxTimeouts: ${maxTimeouts})`
1620
1623
  );
@@ -1625,7 +1628,8 @@ function runMutationResult(dir, reportOverride, io2) {
1625
1628
  io2.err("A timeout counts as detected, but on a loaded machine a mutant that would survive");
1626
1629
  io2.err("can time out instead. Run again with less load (-- --concurrency 1). Only for a");
1627
1630
  io2.err("mutant that can never finish (a loop that no longer ends), add it to");
1628
- io2.err('"expectedTimeouts" of the gate in test-gates.json and fill in "reason":');
1631
+ io2.err('"expectedTimeouts" of the gate in test-gates.json and fill in "reason"');
1632
+ io2.err("(settings.mutation.maxTimeouts raises the limit for the whole project):");
1629
1633
  for (const mutant of verdict.unexpectedTimeouts) {
1630
1634
  io2.err(` ${mutant.file}: ${JSON.stringify(mutant.allowance)}`);
1631
1635
  }
package/dist/index.cjs CHANGED
@@ -236,8 +236,13 @@ var DEFAULT_FORBIDDEN_SOURCE = [
236
236
  {
237
237
  pattern: `^\\s*(['"])use (client|server)\\1`,
238
238
  reason: "'use client' / 'use server' directive (not a pure module)"
239
+ },
240
+ {
241
+ pattern: "\\bprocess\\??\\.env\\b",
242
+ reason: "reads process.env (not a pure module). Take the value as an argument"
239
243
  }
240
244
  ];
245
+ var DEFAULT_MAX_TIMEOUTS = 0;
241
246
  var SettingsError = class extends Error {
242
247
  };
243
248
  function isRecord2(value) {
@@ -499,7 +504,7 @@ function resolveSettings(raw) {
499
504
  strykerReportFile: optionalString(stryker.reportFile, "settings.stryker.reportFile") ?? DEFAULT_REPORT_FILE,
500
505
  importMode: importsSection.mode ?? "blocklist",
501
506
  importAllow,
502
- maxTimeouts: mutation.maxTimeouts ?? null,
507
+ maxTimeouts: mutation.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS,
503
508
  lcovFile: optionalString(lcov.file, "settings.lcov.file"),
504
509
  lcovSummaryExclude: lcov.summaryExclude === void 0 ? [] : stringArray(lcov.summaryExclude, "settings.lcov.summaryExclude").map(
505
510
  (pattern, index) => compile(pattern, void 0, `settings.lcov.summaryExclude[${index}]`)
@@ -918,6 +923,21 @@ function scanStrykerConfig(configPath, source) {
918
923
  return violations;
919
924
  }
920
925
 
926
+ // src/core/vitest-coverage.ts
927
+ function vitestCoverageViolation(input) {
928
+ if (input.vitestVersion === null || input.vitestVersion === "") {
929
+ return null;
930
+ }
931
+ const major = Number(input.vitestVersion.split(".")[0]);
932
+ if (!Number.isInteger(major) || major >= 4) {
933
+ return null;
934
+ }
935
+ if (/\bistanbul\b/.test(input.configSource) || /\bexperimentalAstAwareRemapping\s*:\s*true\b/.test(input.configSource)) {
936
+ return null;
937
+ }
938
+ return `Vitest ${input.vitestVersion} with the v8 coverage provider does not count the skipped side of an "if" without "else", so a gate can show 100% branches with that side untested. Use coverageProvider: 'istanbul' (needs @vitest/coverage-istanbul), set coverage.experimentalAstAwareRemapping: true, or move to Vitest 4`;
939
+ }
940
+
921
941
  // src/commands/check.ts
922
942
  var STRYKER_CONFIG_NAMES = [
923
943
  "stryker.gates.config.mjs",
@@ -1008,11 +1028,40 @@ function checkGates(manifest) {
1008
1028
  violations.push(...scanStrykerConfig(strykerConfig, readText(dir, strykerConfig)));
1009
1029
  }
1010
1030
  }
1031
+ violations.push(...checkVitestCoverage(manifest));
1011
1032
  return violations;
1012
1033
  }
1013
1034
  var VITEST_GATE_CONFIGS = ["ts", "mts", "cts", "js", "mjs", "cjs"].map(
1014
1035
  (extension) => `vitest.gates.config.${extension}`
1015
1036
  );
1037
+ function installedVitestVersion(startDir) {
1038
+ let current = startDir;
1039
+ for (; ; ) {
1040
+ try {
1041
+ const pkg = JSON.parse(
1042
+ import_node_fs3.default.readFileSync(import_node_path3.default.join(current, "node_modules", "vitest", "package.json"), "utf8")
1043
+ );
1044
+ return typeof pkg.version === "string" ? pkg.version : null;
1045
+ } catch {
1046
+ const parent = import_node_path3.default.dirname(current);
1047
+ if (parent === current) {
1048
+ return null;
1049
+ }
1050
+ current = parent;
1051
+ }
1052
+ }
1053
+ }
1054
+ function checkVitestCoverage(manifest) {
1055
+ const config = VITEST_GATE_CONFIGS.find((name) => import_node_fs3.default.existsSync(import_node_path3.default.join(manifest.dir, name)));
1056
+ if (!config) {
1057
+ return [];
1058
+ }
1059
+ const message = vitestCoverageViolation({
1060
+ vitestVersion: installedVitestVersion(manifest.dir),
1061
+ configSource: readText(manifest.dir, config)
1062
+ });
1063
+ return message === null ? [] : [{ file: config, message }];
1064
+ }
1016
1065
 
1017
1066
  // src/core/mutation-result.ts
1018
1067
  var DETECTED = /* @__PURE__ */ new Set(["Killed", "Timeout"]);
@@ -1243,8 +1292,8 @@ function judgeMutationReport(input) {
1243
1292
  message: "no mutants were evaluated (the mutation test ran on nothing)"
1244
1293
  });
1245
1294
  }
1246
- const maxTimeouts = input.maxTimeouts ?? null;
1247
- if (maxTimeouts !== null && unexpectedTimeouts.length > maxTimeouts) {
1295
+ const maxTimeouts = input.maxTimeouts ?? 0;
1296
+ if (unexpectedTimeouts.length > maxTimeouts) {
1248
1297
  violations.push({
1249
1298
  file: MANIFEST_FILE,
1250
1299
  message: `${unexpectedTimeouts.length} mutant(s) timed out outside expectedTimeouts; settings.mutation.maxTimeouts allows ${maxTimeouts}`
package/dist/index.d.cts CHANGED
@@ -134,8 +134,8 @@ interface ResolvedSettings {
134
134
  regex?: RegExp;
135
135
  names?: string[];
136
136
  }[];
137
- /** `null`: no limit. */
138
- maxTimeouts: number | null;
137
+ /** Timeouts tolerated outside `expectedTimeouts`. */
138
+ maxTimeouts: number;
139
139
  }
140
140
  /** One finding. Printed as `file:line: message`. */
141
141
  interface Violation {
@@ -279,8 +279,8 @@ interface MutationVerdict {
279
279
  violations: Violation[];
280
280
  survivors: UnallowedSurvivor[];
281
281
  /**
282
- * Timed-out mutants that no `expectedTimeouts` entry covers. Informational unless
283
- * `maxTimeouts` is set and exceeded, in which case `violations` says so.
282
+ * Timed-out mutants that no `expectedTimeouts` entry covers. When there are more of them
283
+ * than `maxTimeouts`, `violations` says so.
284
284
  */
285
285
  unexpectedTimeouts: UnallowedSurvivor[];
286
286
  summary: MutationSummary;
@@ -312,9 +312,9 @@ declare function judgeMutationReport(input: {
312
312
  readSource: (gatePath: string) => string | undefined;
313
313
  /**
314
314
  * `settings.mutation.maxTimeouts`: how many timeouts outside `expectedTimeouts` are
315
- * tolerated. `null` or absent means no limit.
315
+ * tolerated. Absent means 0.
316
316
  */
317
- maxTimeouts?: number | null;
317
+ maxTimeouts?: number;
318
318
  }): MutationVerdict;
319
319
  /** `file:line:column / mutator / original → replacement / occurrence n of m`. */
320
320
  declare function formatSurvivor(survivor: UnallowedSurvivor): string;
package/dist/index.d.ts CHANGED
@@ -134,8 +134,8 @@ interface ResolvedSettings {
134
134
  regex?: RegExp;
135
135
  names?: string[];
136
136
  }[];
137
- /** `null`: no limit. */
138
- maxTimeouts: number | null;
137
+ /** Timeouts tolerated outside `expectedTimeouts`. */
138
+ maxTimeouts: number;
139
139
  }
140
140
  /** One finding. Printed as `file:line: message`. */
141
141
  interface Violation {
@@ -279,8 +279,8 @@ interface MutationVerdict {
279
279
  violations: Violation[];
280
280
  survivors: UnallowedSurvivor[];
281
281
  /**
282
- * Timed-out mutants that no `expectedTimeouts` entry covers. Informational unless
283
- * `maxTimeouts` is set and exceeded, in which case `violations` says so.
282
+ * Timed-out mutants that no `expectedTimeouts` entry covers. When there are more of them
283
+ * than `maxTimeouts`, `violations` says so.
284
284
  */
285
285
  unexpectedTimeouts: UnallowedSurvivor[];
286
286
  summary: MutationSummary;
@@ -312,9 +312,9 @@ declare function judgeMutationReport(input: {
312
312
  readSource: (gatePath: string) => string | undefined;
313
313
  /**
314
314
  * `settings.mutation.maxTimeouts`: how many timeouts outside `expectedTimeouts` are
315
- * tolerated. `null` or absent means no limit.
315
+ * tolerated. Absent means 0.
316
316
  */
317
- maxTimeouts?: number | null;
317
+ maxTimeouts?: number;
318
318
  }): MutationVerdict;
319
319
  /** `file:line:column / mutator / original → replacement / occurrence n of m`. */
320
320
  declare function formatSurvivor(survivor: UnallowedSurvivor): string;
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  readText,
21
21
  resolveSettings,
22
22
  validateEquivalentMutants
23
- } from "./chunk-XEKQKLKY.js";
23
+ } from "./chunk-F7WBEFIL.js";
24
24
 
25
25
  // src/commands/check.ts
26
26
  import fs from "fs";
@@ -301,6 +301,21 @@ function scanStrykerConfig(configPath, source) {
301
301
  return violations;
302
302
  }
303
303
 
304
+ // src/core/vitest-coverage.ts
305
+ function vitestCoverageViolation(input) {
306
+ if (input.vitestVersion === null || input.vitestVersion === "") {
307
+ return null;
308
+ }
309
+ const major = Number(input.vitestVersion.split(".")[0]);
310
+ if (!Number.isInteger(major) || major >= 4) {
311
+ return null;
312
+ }
313
+ if (/\bistanbul\b/.test(input.configSource) || /\bexperimentalAstAwareRemapping\s*:\s*true\b/.test(input.configSource)) {
314
+ return null;
315
+ }
316
+ return `Vitest ${input.vitestVersion} with the v8 coverage provider does not count the skipped side of an "if" without "else", so a gate can show 100% branches with that side untested. Use coverageProvider: 'istanbul' (needs @vitest/coverage-istanbul), set coverage.experimentalAstAwareRemapping: true, or move to Vitest 4`;
317
+ }
318
+
304
319
  // src/commands/check.ts
305
320
  var STRYKER_CONFIG_NAMES = [
306
321
  "stryker.gates.config.mjs",
@@ -391,11 +406,40 @@ function checkGates(manifest) {
391
406
  violations.push(...scanStrykerConfig(strykerConfig, readText(dir, strykerConfig)));
392
407
  }
393
408
  }
409
+ violations.push(...checkVitestCoverage(manifest));
394
410
  return violations;
395
411
  }
396
412
  var VITEST_GATE_CONFIGS = ["ts", "mts", "cts", "js", "mjs", "cjs"].map(
397
413
  (extension) => `vitest.gates.config.${extension}`
398
414
  );
415
+ function installedVitestVersion(startDir) {
416
+ let current = startDir;
417
+ for (; ; ) {
418
+ try {
419
+ const pkg = JSON.parse(
420
+ fs.readFileSync(path.join(current, "node_modules", "vitest", "package.json"), "utf8")
421
+ );
422
+ return typeof pkg.version === "string" ? pkg.version : null;
423
+ } catch {
424
+ const parent = path.dirname(current);
425
+ if (parent === current) {
426
+ return null;
427
+ }
428
+ current = parent;
429
+ }
430
+ }
431
+ }
432
+ function checkVitestCoverage(manifest) {
433
+ const config = VITEST_GATE_CONFIGS.find((name) => fs.existsSync(path.join(manifest.dir, name)));
434
+ if (!config) {
435
+ return [];
436
+ }
437
+ const message = vitestCoverageViolation({
438
+ vitestVersion: installedVitestVersion(manifest.dir),
439
+ configSource: readText(manifest.dir, config)
440
+ });
441
+ return message === null ? [] : [{ file: config, message }];
442
+ }
399
443
 
400
444
  // src/core/mutation-result.ts
401
445
  var DETECTED = /* @__PURE__ */ new Set(["Killed", "Timeout"]);
@@ -626,8 +670,8 @@ function judgeMutationReport(input) {
626
670
  message: "no mutants were evaluated (the mutation test ran on nothing)"
627
671
  });
628
672
  }
629
- const maxTimeouts = input.maxTimeouts ?? null;
630
- if (maxTimeouts !== null && unexpectedTimeouts.length > maxTimeouts) {
673
+ const maxTimeouts = input.maxTimeouts ?? 0;
674
+ if (unexpectedTimeouts.length > maxTimeouts) {
631
675
  violations.push({
632
676
  file: MANIFEST_FILE,
633
677
  message: `${unexpectedTimeouts.length} mutant(s) timed out outside expectedTimeouts; settings.mutation.maxTimeouts allows ${maxTimeouts}`
package/dist/jest.cjs CHANGED
@@ -212,8 +212,13 @@ var DEFAULT_FORBIDDEN_SOURCE = [
212
212
  {
213
213
  pattern: `^\\s*(['"])use (client|server)\\1`,
214
214
  reason: "'use client' / 'use server' directive (not a pure module)"
215
+ },
216
+ {
217
+ pattern: "\\bprocess\\??\\.env\\b",
218
+ reason: "reads process.env (not a pure module). Take the value as an argument"
215
219
  }
216
220
  ];
221
+ var DEFAULT_MAX_TIMEOUTS = 0;
217
222
  var SettingsError = class extends Error {
218
223
  };
219
224
  function isRecord2(value) {
@@ -475,7 +480,7 @@ function resolveSettings(raw) {
475
480
  strykerReportFile: optionalString(stryker.reportFile, "settings.stryker.reportFile") ?? DEFAULT_REPORT_FILE,
476
481
  importMode: importsSection.mode ?? "blocklist",
477
482
  importAllow,
478
- maxTimeouts: mutation.maxTimeouts ?? null,
483
+ maxTimeouts: mutation.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS,
479
484
  lcovFile: optionalString(lcov.file, "settings.lcov.file"),
480
485
  lcovSummaryExclude: lcov.summaryExclude === void 0 ? [] : stringArray(lcov.summaryExclude, "settings.lcov.summaryExclude").map(
481
486
  (pattern, index) => compile(pattern, void 0, `settings.lcov.summaryExclude[${index}]`)
package/dist/jest.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  NO_SPEC_PATTERN,
7
7
  gateSpecs,
8
8
  loadGates
9
- } from "./chunk-XEKQKLKY.js";
9
+ } from "./chunk-F7WBEFIL.js";
10
10
 
11
11
  // src/jest.ts
12
12
  import path from "path";
package/dist/stryker.cjs CHANGED
@@ -211,8 +211,13 @@ var DEFAULT_FORBIDDEN_SOURCE = [
211
211
  {
212
212
  pattern: `^\\s*(['"])use (client|server)\\1`,
213
213
  reason: "'use client' / 'use server' directive (not a pure module)"
214
+ },
215
+ {
216
+ pattern: "\\bprocess\\??\\.env\\b",
217
+ reason: "reads process.env (not a pure module). Take the value as an argument"
214
218
  }
215
219
  ];
220
+ var DEFAULT_MAX_TIMEOUTS = 0;
216
221
  var SettingsError = class extends Error {
217
222
  };
218
223
  function isRecord2(value) {
@@ -474,7 +479,7 @@ function resolveSettings(raw) {
474
479
  strykerReportFile: optionalString(stryker.reportFile, "settings.stryker.reportFile") ?? DEFAULT_REPORT_FILE,
475
480
  importMode: importsSection.mode ?? "blocklist",
476
481
  importAllow,
477
- maxTimeouts: mutation.maxTimeouts ?? null,
482
+ maxTimeouts: mutation.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS,
478
483
  lcovFile: optionalString(lcov.file, "settings.lcov.file"),
479
484
  lcovSummaryExclude: lcov.summaryExclude === void 0 ? [] : stringArray(lcov.summaryExclude, "settings.lcov.summaryExclude").map(
480
485
  (pattern, index) => compile(pattern, void 0, `settings.lcov.summaryExclude[${index}]`)
package/dist/stryker.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-4HAJIKDZ.js";
4
4
  import {
5
5
  loadGates
6
- } from "./chunk-XEKQKLKY.js";
6
+ } from "./chunk-F7WBEFIL.js";
7
7
 
8
8
  // src/stryker.ts
9
9
  var WEAKENING_OPTIONS = ["ignoreStatic", "ignorers"];
package/dist/vitest.cjs CHANGED
@@ -211,8 +211,13 @@ var DEFAULT_FORBIDDEN_SOURCE = [
211
211
  {
212
212
  pattern: `^\\s*(['"])use (client|server)\\1`,
213
213
  reason: "'use client' / 'use server' directive (not a pure module)"
214
+ },
215
+ {
216
+ pattern: "\\bprocess\\??\\.env\\b",
217
+ reason: "reads process.env (not a pure module). Take the value as an argument"
214
218
  }
215
219
  ];
220
+ var DEFAULT_MAX_TIMEOUTS = 0;
216
221
  var SettingsError = class extends Error {
217
222
  };
218
223
  function isRecord2(value) {
@@ -474,7 +479,7 @@ function resolveSettings(raw) {
474
479
  strykerReportFile: optionalString(stryker.reportFile, "settings.stryker.reportFile") ?? DEFAULT_REPORT_FILE,
475
480
  importMode: importsSection.mode ?? "blocklist",
476
481
  importAllow,
477
- maxTimeouts: mutation.maxTimeouts ?? null,
482
+ maxTimeouts: mutation.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS,
478
483
  lcovFile: optionalString(lcov.file, "settings.lcov.file"),
479
484
  lcovSummaryExclude: lcov.summaryExclude === void 0 ? [] : stringArray(lcov.summaryExclude, "settings.lcov.summaryExclude").map(
480
485
  (pattern, index) => compile(pattern, void 0, `settings.lcov.summaryExclude[${index}]`)
package/dist/vitest.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  NO_SPEC_PATTERN,
7
7
  gateSpecs,
8
8
  loadGates
9
- } from "./chunk-XEKQKLKY.js";
9
+ } from "./chunk-F7WBEFIL.js";
10
10
 
11
11
  // src/vitest.ts
12
12
  function createVitestGatesConfig(options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t09tanaka/test-gates",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Test gates for the code that must not be wrong: per-file 100% coverage as the floor, mutation testing with a per-mutant allow list as the real check.",
5
5
  "author": "t09tanaka",
6
6
  "license": "MIT",
@@ -263,7 +263,7 @@
263
263
  "$ref": "#/definitions/ruleList"
264
264
  },
265
265
  "forbiddenSource": {
266
- "description": "Lines that must not appear in a gate (regular expressions, tested one line at a time). Default: 'use client' / 'use server'.",
266
+ "description": "Lines that must not appear in a gate (regular expressions, tested one line at a time). Default: 'use client' / 'use server', and process.env (since 0.3.0).",
267
267
  "$ref": "#/definitions/ruleList"
268
268
  },
269
269
  "importAliases": {
@@ -397,7 +397,7 @@
397
397
  "additionalProperties": false,
398
398
  "properties": {
399
399
  "maxTimeouts": {
400
- "description": "How many timed-out mutants outside expectedTimeouts are tolerated. Above it the run fails. Not set: no limit.",
400
+ "description": "How many timed-out mutants outside expectedTimeouts are tolerated. Above it the run fails. Default: 0 (since 0.3.0; before that, not set meant no limit).",
401
401
  "type": "integer",
402
402
  "minimum": 0
403
403
  }