@t09tanaka/test-gates 0.4.3 → 0.5.1

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Fixed
6
+
7
+ - `selfcheck --jobs` no longer keeps every other control apart because the word `require` appears inside a string, a template, a comment or a regular expression (`'sslmode=require'`, `// pass it to require`). Only code is looked at now. Handing `require` on under another name (`const r = require`) still counts as loading a module whose name is computed.
8
+
9
+ ## 0.5.0
10
+
11
+ This release makes the gates quicker to check in CI: `test-gates mutation --shard <index>/<total>` splits the mutation run over parallel jobs, and `selfcheck --jobs` runs more controls side by side. Nothing changes for a run without the new option:
12
+
13
+ - `--shard` deals the gates out in the same way as `selfcheck --shard`. A passing part says nothing about the others, so run every part and make each one a required check.
14
+ - `selfcheck --jobs` judges every control as before. Only the reason that kept a control apart because of an import of `path`, `url`, `crypto` and the like is gone.
15
+
16
+ ### Added
17
+
18
+ - `test-gates mutation --shard <index>/<total>` runs the mutants of one part of the gates, so that the mutation run can be split over parallel CI jobs. The assignment is the one of `selfcheck --shard`: the gates are dealt out in turn in the order of `test-gates.json`, so the same `--shard i/n` takes the same gates in both commands. More parts than gates is an error. One passing part is not a guarantee for the whole: run every part (the number of matrix entries has to be `<total>`) and make each one a required check. Unlike `--gate` it is accepted when `CI` is set, and it cannot be combined with `--gate`, `--affected` or `--mutate`.
19
+
20
+ ### Changed
21
+
22
+ - `selfcheck --jobs` no longer keeps a control apart because a file its run loads imports `path`, `url`, `querystring`, `string_decoder`, `punycode`, `assert`, `util/types` or `crypto` (with or without `node:`, and the subpaths `path/posix`, `path/win32` and `assert/strict`). They only compute. A use of `crypto.setFips`, `setEngine`, `setDefaultEncoding` or `DEFAULT_ENCODING` still counts. Every other built-in module (`fs`, `os`, `util`, `events`, `buffer`, `timers/promises`, ...) keeps its controls apart as before, and a project cannot vouch for one by its name. `--jobs` is still 1 by default, and `--quick` is still refused when `CI` is set.
23
+
3
24
  ## 0.4.3
4
25
 
5
26
  This release spares more full runs of `test-gates mutation`: `--affected` keeps telling the gates apart where only some of them read files, a new linked worktree starts from the record of the main worktree, and timeouts on a loaded machine are run again before they fail the run. Three things to know when updating:
package/README.md CHANGED
@@ -234,7 +234,7 @@ Static checks, before the gate run. Reports every violation as `file:line: reaso
234
234
  - The Stryker config does not use `excludedMutations`, `ignoreStatic` or `ignorers`.
235
235
  - A Vitest gate run below Vitest 4 does not use the plain v8 coverage provider (since 0.3.0), see [Vitest: v8 or istanbul](#vitest-v8-or-istanbul).
236
236
 
237
- ### `test-gates mutation [--gate <path>…] [--affected] [-- <stryker arguments>]`
237
+ ### `test-gates mutation [--gate <path>… | --shard <index>/<total>] [--affected] [-- <stryker arguments>]`
238
238
 
239
239
  Runs `stryker run <config>` from the project's own `node_modules`, then judges the JSON report (see `mutation-result`). Unknown arguments, and everything after `--`, are handed to Stryker. If Stryker itself fails, the exit code is 1 and nothing is judged. A report from an earlier run is deleted first, so a run that writes none cannot pass on old results.
240
240
 
@@ -323,6 +323,34 @@ So a run with `--gate`
323
323
 
324
324
  `--gate` is written before `--`, and cannot be combined with a `--mutate` of your own.
325
325
 
326
+ #### `--shard <index>/<total>`: the mutants of one part of the gates, for parallel CI jobs
327
+
328
+ When the mutation run takes too long for one CI job, split it: `test-gates mutation --shard 2/4` runs the mutants of part 2 of 4 (the index starts at 1) and judges those gates. Unlike `--gate` it is for the final check, so **it is accepted when `CI` is set**. **One part that passes says nothing about the gates of the other parts**: the check is complete only when every part has run and passed.
329
+
330
+ - The gates are dealt out in turn in the order of `test-gates.json`: the gate at position `k` (from 0) belongs to part `k % total + 1`. This is the assignment of `selfcheck --shard` too, so the same `--shard i/n` takes the same gates in both commands. The parts are disjoint and together every gate exactly once.
331
+ - More parts than gates is an error (exit code 2), as in `selfcheck`: a part without a gate would pass without checking anything.
332
+ - The gates of the part are handed to Stryker as `--mutate` and judged; the last line says which part it was (`Only part 2/4 (3 of 11 gate(s)) was run and judged (--shard)`). `--shard 1/1` is a run of every gate, and says so. Every mutant of the gates is run: the handling of an incremental file is the one of a run without `--shard`, which is not what `--gate` does. The timeouts of the part are run again one at a time (`--concurrency 1`) as in any run, and only for the gates of the part.
333
+ - `<index>/<total>` outside `1 <= index <= total`, or not written as two plain numbers, is an error (exit code 2). `--shard` cannot be combined with `--gate`, `--affected` or a `--mutate` of your own, and is written before `--`.
334
+
335
+ A matrix of a hosted CI, in parts:
336
+
337
+ ```yaml
338
+ test-gates-mutation:
339
+ runs-on: ubuntu-latest
340
+ strategy:
341
+ fail-fast: false
342
+ matrix:
343
+ part: [1, 2, 3, 4]
344
+ defaults:
345
+ run:
346
+ working-directory: backend
347
+ steps:
348
+ # checkout, setup-node and npm ci as above
349
+ - run: npm run test:gates:mutation -- --shard ${{ matrix.part }}/4
350
+ ```
351
+
352
+ The number of parts is written twice, in `matrix.part` and after the `/`: the two have to be the same, or some gates are run by no job. Make every part a required check (one for each entry of the matrix), so that no part can be left out.
353
+
326
354
  #### `--affected`: run again only the gates a change can affect
327
355
 
328
356
  Without the option, a change to any file that is compared makes the run one of every mutant. That is the safe answer, but in a pre-push hook that only runs for the subprojects a push touches, it is the answer every time. `test-gates mutation --affected` (`npm run test:gates:mutation -- --affected`) decides gate by gate instead: a gate whose results cannot have changed keeps them, and every mutant of a gate that may have changed is run again. It never relies on Stryker's judgement per mutant, which is what reused the old "Killed" in the measurements above.
@@ -519,7 +547,7 @@ _Before the runs start_, the gate command and its config must be known, and for
519
547
  A spec that reaches outside but loads no other gate, which is what the spec of a gate that needs a clock, a file or a package usually is, keeps no control from running next to others; it is left out of the run of its own gate. A file reaches outside when it
520
548
 
521
549
  - 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(`,
522
- - 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,
550
+ - imports a built-in module of Node.js other than `path`, `url`, `querystring`, `string_decoder`, `punycode`, `assert`, `util/types` and `crypto` (these only compute, and a use of `crypto.setFips`, `setEngine`, `setDefaultEncoding` or `DEFAULT_ENCODING` still counts; a project cannot vouch for another built-in module by its name): 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,
523
551
  - 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,
524
552
  - loads a module whose name is computed.
525
553
 
package/dist/cli.js CHANGED
@@ -28,7 +28,7 @@ function parseShard(value) {
28
28
  }
29
29
  var OPTIONS_BY_COMMAND = {
30
30
  check: ["--dir"],
31
- mutation: ["--dir", "--gate", "--affected"],
31
+ mutation: ["--dir", "--gate", "--shard", "--affected"],
32
32
  "mutation-result": ["--dir", "--report"],
33
33
  selfcheck: ["--dir", "--all", "--first", "--gate", "--shard", "--quick", "--jobs"],
34
34
  lcov: ["--dir", "--file"],
@@ -146,13 +146,18 @@ function parseArgs(argv) {
146
146
  "--affected and --gate cannot be combined: --gate runs the gates it names, --affected decides which gates run"
147
147
  );
148
148
  }
149
+ if (parsed.affected && parsed.shard !== null) {
150
+ throw new UsageError(
151
+ "--affected and --shard cannot be combined: --shard runs every mutant of its gates, --affected decides which gates run"
152
+ );
153
+ }
149
154
  const misplaced = parsed.rest.find((arg) => /^--(gate|shard|quick|jobs)(=|$)/.test(arg));
150
155
  if (parsed.command === "selfcheck" && misplaced !== void 0) {
151
156
  throw new UsageError(
152
157
  `${misplaced.split("=")[0]} after "--" would go to the gate command. Write it before "--"`
153
158
  );
154
159
  }
155
- const forStryker = parsed.rest.find((arg) => /^--(gate|affected)(=|$)/.test(arg));
160
+ const forStryker = parsed.rest.find((arg) => /^--(gate|shard|affected)(=|$)/.test(arg));
156
161
  if (forStryker !== void 0) {
157
162
  throw new UsageError(
158
163
  `${forStryker.split("=")[0]} after "--" would go to Stryker. Write it before "--"`
@@ -1083,6 +1088,12 @@ function gateRunRefused(env) {
1083
1088
  function affectedRefused(env) {
1084
1089
  return inCi(env) ? "CI is set, and there every mutant is run (the final check)" : null;
1085
1090
  }
1091
+ function shardRunCaveat(shard, judged, all) {
1092
+ if (shard.total === 1) {
1093
+ return `. All ${all} gate(s) were run and judged (--shard 1/1 is the only part)`;
1094
+ }
1095
+ return `. Only part ${shard.index}/${shard.total} (${judged} of ${all} gate(s)) was run and judged (--shard): every part has to pass for the final check`;
1096
+ }
1086
1097
  function gateRunCaveat(judged, all) {
1087
1098
  return `. Only ${judged} of ${all} 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`;
1088
1099
  }
@@ -4326,10 +4337,10 @@ import path9 from "path";
4326
4337
  function reportPathOf(manifest, override) {
4327
4338
  return path9.resolve(manifest.dir, override ?? manifest.settings.strykerReportFile);
4328
4339
  }
4329
- function runMutationResult(dir, reportOverride, io2, only = [], seen = () => void 0, retried = null) {
4340
+ function runMutationResult(dir, reportOverride, io2, only = [], seen = () => void 0, retried = null, shard = null) {
4330
4341
  const manifest = readManifest(dir);
4331
4342
  const gates = only.length === 0 ? manifest.gates : manifest.gates.filter((gate) => only.includes(gate.path));
4332
- const caveat = only.length === 0 ? "" : gateRunCaveat(gates.length, manifest.gates.length);
4343
+ const caveat = only.length === 0 ? "" : shard === null ? gateRunCaveat(gates.length, manifest.gates.length) : shardRunCaveat(shard, gates.length, manifest.gates.length);
4333
4344
  const reportPath = reportPathOf(manifest, reportOverride);
4334
4345
  const shown = path9.relative(manifest.dir, reportPath) || reportPath;
4335
4346
  let report;
@@ -4879,11 +4890,11 @@ function guardIncremental(manifest, config, strykerArgs, part, affected, found)
4879
4890
  }
4880
4891
  }
4881
4892
  }
4882
- function runMutation(dir, strykerArgs, io2, only = [], affected = false) {
4893
+ function runMutation(dir, strykerArgs, io2, only = [], affected = false, shard = null) {
4883
4894
  const part = only.length > 0;
4884
- if (affected && strykerArgs.some((arg) => /^(?:--mutate|-m)(?:=|$)/.test(arg))) {
4895
+ if ((affected || shard !== null) && strykerArgs.some((arg) => /^(?:--mutate|-m)(?:=|$)/.test(arg))) {
4885
4896
  throw new UsageError(
4886
- "--affected and --mutate cannot be combined: --affected sets --mutate itself"
4897
+ `${affected ? "--affected" : "--shard"} and --mutate cannot be combined: ${affected ? "--affected" : "--shard"} sets --mutate itself`
4887
4898
  );
4888
4899
  }
4889
4900
  if (part) {
@@ -4900,11 +4911,12 @@ function runMutation(dir, strykerArgs, io2, only = [], affected = false) {
4900
4911
  gates: manifest.gates.map((gate) => gate.path),
4901
4912
  mode: "all",
4902
4913
  only,
4903
- shard: null
4914
+ shard
4904
4915
  });
4905
4916
  if ("error" in chosen) {
4906
4917
  throw new UsageError(chosen.error);
4907
4918
  }
4919
+ const shardGates = shard === null ? null : chosen.selected;
4908
4920
  const config = findStrykerConfig(manifest);
4909
4921
  if (config === null) {
4910
4922
  throw new ManifestError(
@@ -4930,7 +4942,8 @@ function runMutation(dir, strykerArgs, io2, only = [], affected = false) {
4930
4942
  }
4931
4943
  removed.forEach((line) => io2.out(line));
4932
4944
  const guardArgs = guard.force === null && guard.mutate === null ? [] : ["--force"];
4933
- const partArgs = part ? ["--mutate", chosen.selected.join(",")] : guard.mutate !== null ? ["--mutate", guard.mutate.join(",")] : [];
4945
+ const partArgs = part || shardGates !== null ? ["--mutate", (shardGates ?? chosen.selected).join(",")] : guard.mutate !== null ? ["--mutate", guard.mutate.join(",")] : [];
4946
+ const judged = part ? chosen.selected : shardGates ?? [];
4934
4947
  const unmark = markRun(manifest.dir, guard.temp);
4935
4948
  let retried;
4936
4949
  try {
@@ -4954,24 +4967,25 @@ function runMutation(dir, strykerArgs, io2, only = [], affected = false) {
4954
4967
  [config, ...strykerArgs],
4955
4968
  manifest,
4956
4969
  reportFile,
4957
- part ? manifest.gates.filter((gate) => chosen.selected.includes(gate.path)) : manifest.gates,
4970
+ judged.length > 0 ? manifest.gates.filter((gate) => judged.includes(gate.path)) : manifest.gates,
4958
4971
  io2
4959
4972
  );
4960
4973
  } finally {
4961
4974
  unmark();
4962
4975
  }
4963
- let judged = null;
4976
+ let lastSeen = null;
4964
4977
  const status = runMutationResult(
4965
4978
  dir,
4966
4979
  null,
4967
4980
  io2,
4968
- part ? chosen.selected : [],
4981
+ judged,
4969
4982
  (seen) => {
4970
- judged = seen;
4983
+ lastSeen = seen;
4971
4984
  },
4972
- retried
4985
+ retried,
4986
+ shard
4973
4987
  );
4974
- guard.record(judged);
4988
+ guard.record(lastSeen);
4975
4989
  if (part) {
4976
4990
  fs10.rmSync(reportFile, { force: true });
4977
4991
  }
@@ -5420,6 +5434,11 @@ var REACHES = [
5420
5434
  "WebSocket",
5421
5435
  "XMLHttpRequest",
5422
5436
  "navigator",
5437
+ // What crypto keeps as a setting of its module, for every spec file of a worker after it.
5438
+ "setFips",
5439
+ "setEngine",
5440
+ "setDefaultEncoding",
5441
+ "DEFAULT_ENCODING",
5423
5442
  "Atomics",
5424
5443
  "WeakRef",
5425
5444
  "FinalizationRegistry",
@@ -5439,9 +5458,86 @@ function withoutStrings(source) {
5439
5458
  )
5440
5459
  );
5441
5460
  }
5461
+ var BEFORE_REGEX = /(?:^|[(,=:[!&|?{};+\-*%<>~^]|\b(?:return|typeof|case|in|of|void|delete|throw|new|yield|await))\s*$/;
5462
+ var COMMENT_OR_STRING = /\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)|(['"])(?:\\.|(?!\1)[^\\\n])*\1?/y;
5463
+ var REGEX = /\/(?:\\.|\[(?:\\.|[^\]\\\n])*\]?|[^/\\\n[])*\/?/y;
5464
+ var TEMPLATE_TEXT = /(?:\\[\s\S]|[^`\\$]|\$(?!\{))*/y;
5465
+ function withoutLiterals(source) {
5466
+ let out = "";
5467
+ let i = 0;
5468
+ const blank = (text) => text.replace(/[^\n]/g, " ");
5469
+ const enclosed = (literal) => {
5470
+ const closed = literal.length > 1 && literal.endsWith(literal.charAt(0));
5471
+ return literal.charAt(0) + blank(literal.slice(1, closed ? -1 : void 0)) + (closed ? literal.charAt(0) : "");
5472
+ };
5473
+ const take = (pattern) => {
5474
+ pattern.lastIndex = i;
5475
+ const found = pattern.exec(source)?.[0] ?? null;
5476
+ if (found !== null) {
5477
+ i += found.length;
5478
+ }
5479
+ return found;
5480
+ };
5481
+ const code = (inBraces) => {
5482
+ while (i < source.length) {
5483
+ const c = source[i];
5484
+ const literal = take(COMMENT_OR_STRING);
5485
+ if (literal !== null) {
5486
+ out += literal.startsWith("/") ? blank(literal) : enclosed(literal);
5487
+ } else if (c === "/" && BEFORE_REGEX.test(out)) {
5488
+ out += enclosed(take(REGEX));
5489
+ } else if (c === "`") {
5490
+ i += 1;
5491
+ out += "`";
5492
+ template();
5493
+ } else if (c === "}" && inBraces) {
5494
+ return;
5495
+ } else if (c === "{" && inBraces) {
5496
+ braces("{");
5497
+ } else {
5498
+ out += c;
5499
+ i += 1;
5500
+ }
5501
+ }
5502
+ };
5503
+ const braces = (open) => {
5504
+ i += open.length;
5505
+ out += open;
5506
+ code(true);
5507
+ if (i < source.length) {
5508
+ i += 1;
5509
+ out += "}";
5510
+ }
5511
+ };
5512
+ const template = () => {
5513
+ out += blank(take(TEMPLATE_TEXT));
5514
+ if (source.startsWith("${", i)) {
5515
+ braces("${");
5516
+ template();
5517
+ } else if (i < source.length) {
5518
+ i += 1;
5519
+ out += "`";
5520
+ }
5521
+ };
5522
+ code(false);
5523
+ return out;
5524
+ }
5525
+ var PURE_BUILTINS = [
5526
+ "assert",
5527
+ "assert/strict",
5528
+ "crypto",
5529
+ "path",
5530
+ "path/posix",
5531
+ "path/win32",
5532
+ "punycode",
5533
+ "querystring",
5534
+ "string_decoder",
5535
+ "url",
5536
+ "util/types"
5537
+ ];
5442
5538
  var RUNNER_PACKAGES = ["vitest", "@jest/globals"];
5443
5539
  function reachesOutside(file, trusted) {
5444
- const dense = file.source.replace(/\s/g, "");
5540
+ const dense = withoutLiterals(file.source).replace(/\s/g, "");
5445
5541
  const code = withoutStrings(file.source).replace(/\s/g, "");
5446
5542
  const mark = REACHES.find((text) => code.includes(text));
5447
5543
  if (mark !== void 0) {
@@ -5452,7 +5548,9 @@ function reachesOutside(file, trusted) {
5452
5548
  }
5453
5549
  for (const { specifier, builtin } of file.imports) {
5454
5550
  if (builtin) {
5455
- return `imports ${specifier}`;
5551
+ if (!PURE_BUILTINS.includes(specifier.replace(/^node:/, ""))) {
5552
+ return `imports ${specifier}`;
5553
+ }
5456
5554
  } else if (![...RUNNER_PACKAGES, ...trusted].some(
5457
5555
  (name) => specifier === name || specifier.startsWith(`${name}/`)
5458
5556
  )) {
@@ -6040,7 +6138,9 @@ Options:
6040
6138
  --affected mutation: run again only the gates that a change since the last
6041
6139
  run can affect, and reuse the results of the others. Every
6042
6140
  mutant is run when CI is set
6043
- --shard <index>/<total> selfcheck: one of <total> parts of the gates, for parallel CI jobs
6141
+ --shard <index>/<total> selfcheck, mutation: one of <total> parts of the gates, for parallel CI
6142
+ jobs. For mutation the gates are sorted and dealt out in turn; every
6143
+ part has to run. Accepted when CI is set
6044
6144
  --jobs <n> selfcheck: run <n> negative controls at the same time. One that does
6045
6145
  not hold cleanly there is run again on its own
6046
6146
  --quick selfcheck: one run for the gates only their own spec touches.
@@ -6075,7 +6175,7 @@ function main(argv) {
6075
6175
  case "doctor":
6076
6176
  return runDoctor(args.dir, io);
6077
6177
  case "mutation":
6078
- return runMutation(args.dir, args.rest, io, args.gates, args.affected);
6178
+ return runMutation(args.dir, args.rest, io, args.gates, args.affected, args.shard);
6079
6179
  case "mutation-result":
6080
6180
  return runMutationResult(args.dir, args.report, io);
6081
6181
  case "selfcheck":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t09tanaka/test-gates",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
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",