@t09tanaka/test-gates 0.3.1 → 0.3.3

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,10 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3
4
+
5
+ Documentation only; the commands and the helpers behave as in 0.3.2.
6
+
7
+ - The paths and outputs shown in the README are those of a small example written for it and of this repository's own gates.
8
+
9
+ ## 0.3.2
10
+
11
+ ### Changed
12
+
13
+ - `createStrykerGatesConfig` sets Stryker's `maxTestRunnerReuse` to 50 unless the project passes its own (Stryker's default is 0: one test runner process for the whole run). In a project with mutants that never end, the runner became about three times slower from the first of them on, and mutants that are killed in seconds on their own timed out. Starting a new process every 50 mutants kept the speed: a full run of about 800 mutants went from about a quarter of an hour with stray timeouts to less than half of that with none.
14
+ - `timeoutMS` is no longer set by the helper; Stryker's default (5000) applies again. The 30000 of 0.3.1 was the wrong fix. It rested on the idea that a restarted runner makes the next mutant slow, when in fact a runner that is not restarted stays slow. With 30000 the same full run took about three times as long and still failed, and mutants that exhaust the heap crashed the runner and were reported as `RuntimeError` instead of `Timeout`.
15
+
16
+ A project that set `timeoutMS` itself to work around stray timeouts can remove it.
17
+
3
18
  ## 0.3.1
4
19
 
5
20
  ### Changed
6
21
 
7
- - `createStrykerGatesConfig` sets Stryker's `timeoutMS` to 30000 unless the project passes its own (Stryker's default is 5000). With `maxTimeouts` at 0 since 0.3.0, a mutant that would be killed but ran out of time fails the run. That happened on every full run in two projects with mutants that really never end: after each of them Stryker starts a new test runner, and the next mutant timed out while it was starting. A full run takes longer by up to 25 seconds for each mutant that hangs without tripping Stryker's loop counter.
22
+ - `createStrykerGatesConfig` sets Stryker's `timeoutMS` to 30000 unless the project passes its own (Stryker's default is 5000). With `maxTimeouts` at 0 since 0.3.0, a mutant that would be killed but ran out of time fails the run. That happened on every full run in projects with mutants that really never end: after each of them Stryker starts a new test runner, and the next mutant timed out while it was starting. A full run takes longer by up to 25 seconds for each mutant that hangs without tripping Stryker's loop counter.
8
23
 
9
24
  ## 0.3.0
10
25
 
@@ -35,7 +50,7 @@ A manifest that uses the new settings needs 0.2.0 or later, because 0.1.0 reject
35
50
  - `test-gates check` warns when the gate run uses Vitest below 4 with the v8 provider, which does not count the skipped side of an `if` without `else`.
36
51
  - `test-gates selfcheck` names the gates that import a gate whose spec could be left out without the gate failing.
37
52
  - CI for this repository (lint, tests, gates, negative control, full mutation run; build and tests on Node 20).
38
- - README: when the package is worth using, timeouts, allowlist mode, the Vitest v8 / istanbul measurements, cost and incremental mode, settings for Dart, and notes from migrating six repositories.
53
+ - README: when the package is worth using, timeouts, allowlist mode, the Vitest v8 / istanbul measurements, cost and incremental mode, settings for Dart, and notes from adopting it in several repositories.
39
54
 
40
55
  ### Changed
41
56
 
package/README.md CHANGED
@@ -44,22 +44,22 @@ Everything below is per subproject (the directory that has a `package.json`: `ba
44
44
  "$schema": "./node_modules/@t09tanaka/test-gates/schema/test-gates.schema.json",
45
45
  "gates": [
46
46
  {
47
- "path": "src/common/utils/money.ts",
48
- "decides": "Validation and rounding of amounts",
49
- "impact": "Wrong invoice and refund amounts"
47
+ "path": "src/shipping-fee.ts",
48
+ "decides": "The fee for sending a parcel, by weight, and its maximum",
49
+ "impact": "A heavy parcel is sent for too little, or the customer pays more than the advertised maximum"
50
50
  }
51
51
  ],
52
52
  "candidates": [
53
53
  {
54
- "path": "src/admin/guards/admin-role.guard.ts",
55
- "decides": "Who is an administrator",
54
+ "path": "src/warehouse/dispatch.guard.ts",
55
+ "decides": "Who may release an order for dispatch",
56
56
  "blocker": "Has decorators. The decision has to be extracted into a pure module"
57
57
  }
58
58
  ]
59
59
  }
60
60
  ```
61
61
 
62
- - `gates`: pure modules that hold an important decision. Each one needs a spec next to it (`money.spec.ts` or `money.test.ts`), 100% on statements, branches, functions and lines, and no surviving mutant outside its allow list.
62
+ - `gates`: pure modules that hold an important decision. Each one needs a spec next to it (`shipping-fee.spec.ts` or `shipping-fee.test.ts`), 100% on statements, branches, functions and lines, and no surviving mutant outside its allow list.
63
63
  - `candidates`: important logic that cannot be a gate yet, with what blocks it. Nothing is enforced for these except that the path exists.
64
64
  - `settings`: optional project conventions, see [Settings](#settings).
65
65
 
@@ -234,10 +234,10 @@ The run also fails when
234
234
 
235
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.
236
236
 
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:
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. For the gates of this repository:
238
238
 
239
239
  ```
240
- test-gates mutation: OK (mutants 317 / detected 308 (timeout 0) / allowed equivalent 8 / not evaluable 1 / unallowed survivors 0)
240
+ test-gates mutation: OK (mutants 1017 / detected 1017 (timeout 2) / allowed equivalent 0 / not evaluable 0 / unallowed survivors 0)
241
241
  ```
242
242
 
243
243
  #### Timeouts
@@ -280,7 +280,9 @@ How an `expectedTimeouts` entry is judged:
280
280
 
281
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.
282
282
 
283
- `createStrykerGatesConfig` sets Stryker's `timeoutMS` to 30000 (since 0.3.1; Stryker's own default is 5000). After a mutant that really never ends, Stryker starts a new test runner, and with 5000 the mutant that comes next can run out of time while that runner is still starting: in one project with 11 such mutants, 2 to 7 others timed out on every full run, a different set each time, and none in a run with 30000. A longer limit hides nothing, because a timeout that is not listed fails the run anyway. The price is the wait for each mutant that hangs without tripping Stryker's loop counter. Pass `timeoutMS` to the helper to change it.
283
+ `createStrykerGatesConfig` sets Stryker's `maxTestRunnerReuse` to 50 (since 0.3.2; Stryker's own default is 0, one test runner process for the whole run). A mutant that would be killed can also time out for a reason that has nothing to do with the mutant: in a Jest project with a few mutants that never end, the test runner handled about a third as many mutants per minute from the first of them on, for the rest of the run. Mutants that are killed in seconds on their own then timed out, a different set on each full run, and the run took about a quarter of an hour. With a new process every 50 mutants the speed stayed the same to the end, nothing timed out that should not, and the run took less than half as long. Why the runner slows down was not found; the restart only cuts it short. Pass `maxTestRunnerReuse` to the helper to change it.
284
+
285
+ 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.
284
286
 
285
287
  ### `test-gates selfcheck [--all | --first] [-- <gate command>]`
286
288
 
@@ -288,17 +290,17 @@ Negative control. For each gate, runs the gate with that gate's spec left out an
288
290
 
289
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.
290
292
 
291
- **When a gate imports another gate.** If `api-key-auth.ts` imports `safe-compare.ts` and both are gates, the spec of `api-key-auth` executes every line of `safe-compare` as well. Leaving out `safe-compare.spec.ts` then does not lower its coverage, and `selfcheck` fails for `safe-compare` ("the gate passed although the spec … was left out"), naming the importing gate in a hint. The coverage floor of `safe-compare` 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:
293
+ **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:
292
294
 
293
295
  ```ts
294
- jest.mock('./safe-compare');
296
+ jest.mock('./shipping-fee');
295
297
  // …
296
- expect(safeCompare).toHaveBeenCalledWith(presentedKey, storedKey); // right arguments, right order
298
+ expect(shippingFeeCents).toHaveBeenCalledWith(weightGrams); // called with the weight, not with the price
297
299
  ```
298
300
 
299
301
  ### `test-gates lcov --file <lcov.info>`
300
302
 
301
- 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/models/coupon.dart`); absolute paths under the subproject are made relative.
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.
302
304
 
303
305
  ```bash
304
306
  flutter test --coverage && npx test-gates lcov --file coverage/lcov.info
@@ -308,7 +310,7 @@ Settings for a Dart package that uses only `lcov`:
308
310
 
309
311
  ```json
310
312
  {
311
- "gates": [{ "path": "lib/models/coupon.dart", "decides": "…", "impact": "…" }],
313
+ "gates": [{ "path": "lib/shipping_fee.dart", "decides": "…", "impact": "…" }],
312
314
  "candidates": [],
313
315
  "settings": {
314
316
  "gateExtensions": [".dart"],
@@ -324,19 +326,47 @@ Lines are counted from the `DA:` entries, not taken from `LH:` / `LF:`. A tracef
324
326
 
325
327
  ## Allowing an equivalent mutant
326
328
 
327
- An equivalent mutant changes the code without changing anything observable: same return value, same exception, same side effects, for every input. Only those may be allowed, in `test-gates.json`, with the reason:
329
+ An equivalent mutant changes the code without changing anything observable: same return value, same exception, same side effects, for every input. Only those may be allowed, in `test-gates.json`, with the reason.
330
+
331
+ An example. A gate limits a fee to a maximum:
332
+
333
+ ```ts
334
+ let fee = Math.ceil(weightGrams / 1000) * 500;
335
+ if (fee > MAX_FEE_CENTS) {
336
+ fee = MAX_FEE_CENTS;
337
+ }
338
+ return fee;
339
+ ```
340
+
341
+ The specs check every boundary, and one mutant still survives:
342
+
343
+ ```
344
+ test-gates mutation: 1 surviving mutant(s) not in the allow list
345
+ - src/shipping-fee.ts:9:7 / EqualityOperator / fee > MAX_FEE_CENTS → fee >= MAX_FEE_CENTS (Survived)
346
+
347
+ Fix the test so that it detects the mutant. Only when the mutant cannot be observed from
348
+ outside (same return value, exception and side effects for every input), add it to
349
+ "equivalentMutants" of the gate in test-gates.json and fill in "reason":
350
+
351
+ src/shipping-fee.ts
352
+ {"mutator":"EqualityOperator","original":"fee > MAX_FEE_CENTS","replacement":"fee >= MAX_FEE_CENTS","reason":""}
353
+
354
+ test-gates mutation: FAILED (mutants 14 / detected 13 (timeout 0) / allowed equivalent 0 / not evaluable 0 / unallowed survivors 1)
355
+ ```
356
+
357
+ With `>=` the only input that is treated differently is a fee equal to the maximum, and that fee is set to the value it already has. No test can tell the two apart, so the gate lists the mutant:
328
358
 
329
359
  ```json
330
360
  {
331
- "path": "src/lib/auth/safe-compare.ts",
332
- "decides": "Constant-time comparison of tokens",
333
- "impact": "Token comparison leaks timing or accepts a wrong token",
361
+ "path": "src/shipping-fee.ts",
362
+ "decides": "The fee for sending a parcel, by weight, and its maximum",
363
+ "impact": "A heavy parcel is sent for too little, or the customer pays more than the advertised maximum",
334
364
  "equivalentMutants": [
335
365
  {
336
- "mutator": "ConditionalExpression",
337
- "original": "a.length !== b.length",
338
- "replacement": "false",
339
- "reason": "timingSafeEqual below throws on a length mismatch and the catch returns false, so the result is the same"
366
+ "mutator": "EqualityOperator",
367
+ "original": "fee > MAX_FEE_CENTS",
368
+ "replacement": "fee >= MAX_FEE_CENTS",
369
+ "reason": "A fee that is equal to the maximum is set to the maximum, which is the value it has"
340
370
  }
341
371
  ]
342
372
  }
@@ -390,17 +420,17 @@ Project-specific layers go into `add`:
390
420
  "impureImports": {
391
421
  "add": [
392
422
  {
393
- "pattern": "(^|/)(repository|wrapper|handler)(/|$)",
394
- "reason": "DB / external API layer"
423
+ "pattern": "(^|/)(persistence|gateways)(/|$)",
424
+ "reason": "talks to the database or to a remote API"
395
425
  },
396
- { "pattern": "\\.(service|guard|controller|module)$", "reason": "class managed by DI" }
426
+ { "pattern": "\\.(service|controller)$", "reason": "class created by the DI container" }
397
427
  ]
398
428
  },
399
429
  "impurePaths": {
400
430
  "add": [
401
431
  {
402
- "pattern": "\\.(service|guard|controller|module|dto)\\.ts$",
403
- "reason": "NestJS class file"
432
+ "pattern": "\\.(service|controller|module)\\.ts$",
433
+ "reason": "a class with decorators"
404
434
  }
405
435
  ]
406
436
  }
@@ -462,7 +492,7 @@ Why these choices:
462
492
  - Jest measures with istanbul (`babel`), because the v8 provider does not count the untaken side of an `if` without `else`.
463
493
  - 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`.
464
494
  - Stryker never fails on its own score (`break: null`). The verdict comes from `test-gates mutation`, which knows the allow list.
465
- - The Stryker helper also sets defaults that can be overridden: `coverageAnalysis: 'perTest'`, `incremental` with its file under `reports/`, and `timeoutMS: 30000` (see [Timeouts](#timeouts)).
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)).
466
496
 
467
497
  `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.
468
498
 
package/dist/stryker.cjs CHANGED
@@ -30,7 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/stryker.ts
31
31
  var stryker_exports = {};
32
32
  __export(stryker_exports, {
33
- DEFAULT_TIMEOUT_MS: () => DEFAULT_TIMEOUT_MS,
33
+ DEFAULT_MAX_TEST_RUNNER_REUSE: () => DEFAULT_MAX_TEST_RUNNER_REUSE,
34
34
  createStrykerGatesConfig: () => createStrykerGatesConfig
35
35
  });
36
36
  module.exports = __toCommonJS(stryker_exports);
@@ -602,7 +602,7 @@ function resolveRootDir(rootDir) {
602
602
 
603
603
  // src/stryker.ts
604
604
  var WEAKENING_OPTIONS = ["ignoreStatic", "ignorers"];
605
- var DEFAULT_TIMEOUT_MS = 3e4;
605
+ var DEFAULT_MAX_TEST_RUNNER_REUSE = 50;
606
606
  function createStrykerGatesConfig(options = {}) {
607
607
  const { rootDir: rootDirOption, ...overrides } = options;
608
608
  for (const option of WEAKENING_OPTIONS) {
@@ -627,7 +627,7 @@ function createStrykerGatesConfig(options = {}) {
627
627
  incremental: true,
628
628
  incrementalFile: "reports/stryker-incremental.json",
629
629
  tempDirName: ".stryker-tmp",
630
- timeoutMS: DEFAULT_TIMEOUT_MS,
630
+ maxTestRunnerReuse: DEFAULT_MAX_TEST_RUNNER_REUSE,
631
631
  clearTextReporter: { logTests: false, reportTests: false },
632
632
  ...overrides,
633
633
  mutate: manifest.gates.map((gate) => gate.path),
@@ -638,6 +638,6 @@ function createStrykerGatesConfig(options = {}) {
638
638
  }
639
639
  // Annotate the CommonJS export names for ESM import in node:
640
640
  0 && (module.exports = {
641
- DEFAULT_TIMEOUT_MS,
641
+ DEFAULT_MAX_TEST_RUNNER_REUSE,
642
642
  createStrykerGatesConfig
643
643
  });
@@ -5,11 +5,13 @@ interface StrykerGatesOptions {
5
5
  [option: string]: unknown;
6
6
  }
7
7
  /**
8
- * Stryker's own default is 5000. After a mutant that really never ends, Stryker starts a new
9
- * test runner, and the next mutant then pays that start-up inside its time limit: with 5000 a
10
- * mutant that would be killed is reported as `Timeout`, which fails the run since 0.3.0.
8
+ * Stryker's own default is 0: one test runner process for the whole run. Measured on a Jest
9
+ * project with a few mutants that never end (`Hit limit reached`): from the first of them on
10
+ * the runner handled about a third as many mutants per minute for the rest of the run, and
11
+ * mutants that are killed in seconds on their own ran out of time. With a new process every 50
12
+ * mutants the speed stayed the same to the end.
11
13
  */
12
- declare const DEFAULT_TIMEOUT_MS = 30000;
14
+ declare const DEFAULT_MAX_TEST_RUNNER_REUSE = 50;
13
15
  /**
14
16
  * Stryker config for the gates: mutate exactly the gates, never fail on the score
15
17
  * (`thresholds.break: null`), and write the JSON report that `test-gates mutation` judges.
@@ -19,4 +21,4 @@ declare const DEFAULT_TIMEOUT_MS = 30000;
19
21
  */
20
22
  declare function createStrykerGatesConfig(options?: StrykerGatesOptions): Record<string, unknown>;
21
23
 
22
- export { DEFAULT_TIMEOUT_MS, type StrykerGatesOptions, createStrykerGatesConfig };
24
+ export { DEFAULT_MAX_TEST_RUNNER_REUSE, type StrykerGatesOptions, createStrykerGatesConfig };
package/dist/stryker.d.ts CHANGED
@@ -5,11 +5,13 @@ interface StrykerGatesOptions {
5
5
  [option: string]: unknown;
6
6
  }
7
7
  /**
8
- * Stryker's own default is 5000. After a mutant that really never ends, Stryker starts a new
9
- * test runner, and the next mutant then pays that start-up inside its time limit: with 5000 a
10
- * mutant that would be killed is reported as `Timeout`, which fails the run since 0.3.0.
8
+ * Stryker's own default is 0: one test runner process for the whole run. Measured on a Jest
9
+ * project with a few mutants that never end (`Hit limit reached`): from the first of them on
10
+ * the runner handled about a third as many mutants per minute for the rest of the run, and
11
+ * mutants that are killed in seconds on their own ran out of time. With a new process every 50
12
+ * mutants the speed stayed the same to the end.
11
13
  */
12
- declare const DEFAULT_TIMEOUT_MS = 30000;
14
+ declare const DEFAULT_MAX_TEST_RUNNER_REUSE = 50;
13
15
  /**
14
16
  * Stryker config for the gates: mutate exactly the gates, never fail on the score
15
17
  * (`thresholds.break: null`), and write the JSON report that `test-gates mutation` judges.
@@ -19,4 +21,4 @@ declare const DEFAULT_TIMEOUT_MS = 30000;
19
21
  */
20
22
  declare function createStrykerGatesConfig(options?: StrykerGatesOptions): Record<string, unknown>;
21
23
 
22
- export { DEFAULT_TIMEOUT_MS, type StrykerGatesOptions, createStrykerGatesConfig };
24
+ export { DEFAULT_MAX_TEST_RUNNER_REUSE, type StrykerGatesOptions, createStrykerGatesConfig };
package/dist/stryker.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  // src/stryker.ts
9
9
  var WEAKENING_OPTIONS = ["ignoreStatic", "ignorers"];
10
- var DEFAULT_TIMEOUT_MS = 3e4;
10
+ var DEFAULT_MAX_TEST_RUNNER_REUSE = 50;
11
11
  function createStrykerGatesConfig(options = {}) {
12
12
  const { rootDir: rootDirOption, ...overrides } = options;
13
13
  for (const option of WEAKENING_OPTIONS) {
@@ -32,7 +32,7 @@ function createStrykerGatesConfig(options = {}) {
32
32
  incremental: true,
33
33
  incrementalFile: "reports/stryker-incremental.json",
34
34
  tempDirName: ".stryker-tmp",
35
- timeoutMS: DEFAULT_TIMEOUT_MS,
35
+ maxTestRunnerReuse: DEFAULT_MAX_TEST_RUNNER_REUSE,
36
36
  clearTextReporter: { logTests: false, reportTests: false },
37
37
  ...overrides,
38
38
  mutate: manifest.gates.map((gate) => gate.path),
@@ -42,6 +42,6 @@ function createStrykerGatesConfig(options = {}) {
42
42
  };
43
43
  }
44
44
  export {
45
- DEFAULT_TIMEOUT_MS,
45
+ DEFAULT_MAX_TEST_RUNNER_REUSE,
46
46
  createStrykerGatesConfig
47
47
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t09tanaka/test-gates",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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",