@t09tanaka/test-gates 0.1.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/dist/vitest.cjs CHANGED
@@ -70,17 +70,17 @@ function allowanceProblems(entry) {
70
70
  }
71
71
  return problems;
72
72
  }
73
- function validateEquivalentMutants(gatePath, value) {
73
+ function validateEquivalentMutants(gatePath, value, field = "equivalentMutants") {
74
74
  if (value === void 0) {
75
75
  return [];
76
76
  }
77
77
  if (!Array.isArray(value)) {
78
- return [{ file: MANIFEST_FILE, message: `${gatePath}: equivalentMutants must be an array` }];
78
+ return [{ file: MANIFEST_FILE, message: `${gatePath}: ${field} must be an array` }];
79
79
  }
80
80
  return value.flatMap(
81
81
  (entry, index) => allowanceProblems(entry).map((problem) => ({
82
82
  file: MANIFEST_FILE,
83
- message: `${gatePath}: equivalentMutants[${index}]${problem}`
83
+ message: `${gatePath}: ${field}[${index}]${problem}`
84
84
  }))
85
85
  );
86
86
  }
@@ -160,6 +160,9 @@ function parseManifest(text) {
160
160
  }
161
161
  for (const gate of gates) {
162
162
  violations.push(...validateEquivalentMutants(gate.path, gate.equivalentMutants));
163
+ violations.push(
164
+ ...validateEquivalentMutants(gate.path, gate.expectedTimeouts, "expectedTimeouts")
165
+ );
163
166
  }
164
167
  return { gates, candidates, settings: raw.settings, violations };
165
168
  }
@@ -208,8 +211,13 @@ var DEFAULT_FORBIDDEN_SOURCE = [
208
211
  {
209
212
  pattern: `^\\s*(['"])use (client|server)\\1`,
210
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"
211
218
  }
212
219
  ];
220
+ var DEFAULT_MAX_TIMEOUTS = 0;
213
221
  var SettingsError = class extends Error {
214
222
  };
215
223
  function isRecord2(value) {
@@ -309,7 +317,9 @@ function resolveSettings(raw) {
309
317
  "gateCommand",
310
318
  "selfcheck",
311
319
  "stryker",
312
- "lcov"
320
+ "lcov",
321
+ "imports",
322
+ "mutation"
313
323
  ],
314
324
  "settings"
315
325
  );
@@ -410,6 +420,42 @@ function resolveSettings(raw) {
410
420
  "settings.selfcheck.failurePattern"
411
421
  );
412
422
  }
423
+ const importsSection = section(settings.imports, ["mode", "allow"], "settings.imports");
424
+ if (importsSection.mode !== void 0 && importsSection.mode !== "blocklist" && importsSection.mode !== "allowlist") {
425
+ throw new SettingsError('settings.imports.mode: must be "blocklist" or "allowlist"');
426
+ }
427
+ const importAllowRaw = importsSection.allow ?? [];
428
+ if (!Array.isArray(importAllowRaw)) {
429
+ throw new SettingsError("settings.imports.allow: must be an array");
430
+ }
431
+ const importAllow = importAllowRaw.map((item, index) => {
432
+ const at = `settings.imports.allow[${index}]`;
433
+ if (typeof item === "string" && item !== "") {
434
+ return { module: item };
435
+ }
436
+ if (isRecord2(item) && typeof item.pattern === "string" && item.pattern !== "") {
437
+ rejectUnknownKeys(item, ["pattern", "flags"], at);
438
+ if (item.flags !== void 0 && typeof item.flags !== "string") {
439
+ throw new SettingsError(`${at}.flags: must be a string`);
440
+ }
441
+ return { regex: compile(item.pattern, item.flags, at) };
442
+ }
443
+ if (isRecord2(item) && typeof item.module === "string" && item.module !== "") {
444
+ rejectUnknownKeys(item, ["module", "names"], at);
445
+ const names = stringArray(item.names, `${at}.names`);
446
+ if (names.length === 0) {
447
+ throw new SettingsError(`${at}.names: must list at least one name`);
448
+ }
449
+ return { module: item.module, names };
450
+ }
451
+ throw new SettingsError(
452
+ `${at}: must be a package name, { pattern, flags? } or { module, names }`
453
+ );
454
+ });
455
+ const mutation = section(settings.mutation, ["maxTimeouts"], "settings.mutation");
456
+ if (mutation.maxTimeouts !== void 0 && !(Number.isInteger(mutation.maxTimeouts) && mutation.maxTimeouts >= 0)) {
457
+ throw new SettingsError("settings.mutation.maxTimeouts: must be an integer >= 0");
458
+ }
413
459
  const stryker = section(settings.stryker, ["configFile", "reportFile"], "settings.stryker");
414
460
  const lcov = section(settings.lcov, ["file", "summaryExclude"], "settings.lcov");
415
461
  return {
@@ -431,6 +477,9 @@ function resolveSettings(raw) {
431
477
  selfcheckFailurePattern,
432
478
  strykerConfigFile: optionalString(stryker.configFile, "settings.stryker.configFile"),
433
479
  strykerReportFile: optionalString(stryker.reportFile, "settings.stryker.reportFile") ?? DEFAULT_REPORT_FILE,
480
+ importMode: importsSection.mode ?? "blocklist",
481
+ importAllow,
482
+ maxTimeouts: mutation.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS,
434
483
  lcovFile: optionalString(lcov.file, "settings.lcov.file"),
435
484
  lcovSummaryExclude: lcov.summaryExclude === void 0 ? [] : stringArray(lcov.summaryExclude, "settings.lcov.summaryExclude").map(
436
485
  (pattern, index) => compile(pattern, void 0, `settings.lcov.summaryExclude[${index}]`)
@@ -561,7 +610,7 @@ function resolveRootDir(rootDir) {
561
610
 
562
611
  // src/vitest.ts
563
612
  function createVitestGatesConfig(options = {}) {
564
- const { rootDir: rootDirOption, test = {}, ...overrides } = options;
613
+ const { rootDir: rootDirOption, coverageProvider, test = {}, ...overrides } = options;
565
614
  const rootDir = resolveRootDir(rootDirOption);
566
615
  const manifest = loadGates(rootDir);
567
616
  const { coverage = {}, ...testOverrides } = test;
@@ -581,6 +630,7 @@ function createVitestGatesConfig(options = {}) {
581
630
  reporter: ["text"],
582
631
  reportsDirectory: "coverage/gates",
583
632
  ...coverage,
633
+ ...coverageProvider === void 0 ? {} : { provider: coverageProvider },
584
634
  enabled: true,
585
635
  include: manifest.gates.map((gate) => gate.path),
586
636
  thresholds: { perFile: true, ...FULL_COVERAGE }
package/dist/vitest.d.cts CHANGED
@@ -5,6 +5,12 @@ interface VitestGatesOptions {
5
5
  test?: Record<string, unknown> & {
6
6
  coverage?: Record<string, unknown>;
7
7
  };
8
+ /**
9
+ * Coverage provider. Default `v8`. `istanbul` (needs `@vitest/coverage-istanbul`) also counts
10
+ * the skipped side of an `if` without `else` on Vitest 3, and an unused default parameter on
11
+ * every version; see the README for the measurements. Same as `test.coverage.provider`.
12
+ */
13
+ coverageProvider?: 'v8' | 'istanbul';
8
14
  /** Any other Vite option: resolve.alias, esbuild, plugins, … */
9
15
  [option: string]: unknown;
10
16
  }
package/dist/vitest.d.ts CHANGED
@@ -5,6 +5,12 @@ interface VitestGatesOptions {
5
5
  test?: Record<string, unknown> & {
6
6
  coverage?: Record<string, unknown>;
7
7
  };
8
+ /**
9
+ * Coverage provider. Default `v8`. `istanbul` (needs `@vitest/coverage-istanbul`) also counts
10
+ * the skipped side of an `if` without `else` on Vitest 3, and an unused default parameter on
11
+ * every version; see the README for the measurements. Same as `test.coverage.provider`.
12
+ */
13
+ coverageProvider?: 'v8' | 'istanbul';
8
14
  /** Any other Vite option: resolve.alias, esbuild, plugins, … */
9
15
  [option: string]: unknown;
10
16
  }
package/dist/vitest.js CHANGED
@@ -6,11 +6,11 @@ import {
6
6
  NO_SPEC_PATTERN,
7
7
  gateSpecs,
8
8
  loadGates
9
- } from "./chunk-LMD3ANJ2.js";
9
+ } from "./chunk-F7WBEFIL.js";
10
10
 
11
11
  // src/vitest.ts
12
12
  function createVitestGatesConfig(options = {}) {
13
- const { rootDir: rootDirOption, test = {}, ...overrides } = options;
13
+ const { rootDir: rootDirOption, coverageProvider, test = {}, ...overrides } = options;
14
14
  const rootDir = resolveRootDir(rootDirOption);
15
15
  const manifest = loadGates(rootDir);
16
16
  const { coverage = {}, ...testOverrides } = test;
@@ -30,6 +30,7 @@ function createVitestGatesConfig(options = {}) {
30
30
  reporter: ["text"],
31
31
  reportsDirectory: "coverage/gates",
32
32
  ...coverage,
33
+ ...coverageProvider === void 0 ? {} : { provider: coverageProvider },
33
34
  enabled: true,
34
35
  include: manifest.gates.map((gate) => gate.path),
35
36
  thresholds: { perFile: true, ...FULL_COVERAGE }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t09tanaka/test-gates",
3
- "version": "0.1.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",
@@ -76,7 +76,8 @@
76
76
  },
77
77
  "files": [
78
78
  "dist",
79
- "schema"
79
+ "schema",
80
+ "CHANGELOG.md"
80
81
  ],
81
82
  "engines": {
82
83
  "node": ">=20"
@@ -103,7 +104,8 @@
103
104
  "peerDependencies": {
104
105
  "@stryker-mutator/core": ">=9",
105
106
  "jest": ">=29",
106
- "vitest": ">=3"
107
+ "vitest": ">=3",
108
+ "@vitest/coverage-istanbul": ">=3"
107
109
  },
108
110
  "peerDependenciesMeta": {
109
111
  "@stryker-mutator/core": {
@@ -114,6 +116,9 @@
114
116
  },
115
117
  "vitest": {
116
118
  "optional": true
119
+ },
120
+ "@vitest/coverage-istanbul": {
121
+ "optional": true
117
122
  }
118
123
  },
119
124
  "devDependencies": {
@@ -121,6 +126,7 @@
121
126
  "@stryker-mutator/core": "^10.0.0",
122
127
  "@stryker-mutator/vitest-runner": "^10.0.0",
123
128
  "@types/node": "^22.20.5",
129
+ "@vitest/coverage-istanbul": "^3.2.7",
124
130
  "@vitest/coverage-v8": "^3.2.7",
125
131
  "eslint": "^9.39.5",
126
132
  "eslint-config-prettier": "^10.1.8",
@@ -7,22 +7,34 @@
7
7
  "required": ["gates"],
8
8
  "additionalProperties": false,
9
9
  "properties": {
10
- "$schema": { "type": "string" },
10
+ "$schema": {
11
+ "type": "string"
12
+ },
11
13
  "gates": {
12
14
  "description": "Pure modules that hold an important decision. Each must reach 100% on all four coverage metrics and leave no surviving mutant outside its allow list.",
13
15
  "type": "array",
14
16
  "minItems": 1,
15
- "items": { "$ref": "#/definitions/gate" }
17
+ "items": {
18
+ "$ref": "#/definitions/gate"
19
+ }
16
20
  },
17
21
  "candidates": {
18
22
  "description": "Important logic that cannot be a gate yet. Nothing is enforced for these beyond their existence.",
19
23
  "type": "array",
20
- "items": { "$ref": "#/definitions/candidate" }
24
+ "items": {
25
+ "$ref": "#/definitions/candidate"
26
+ }
21
27
  },
22
- "settings": { "$ref": "#/definitions/settings" }
28
+ "settings": {
29
+ "$ref": "#/definitions/settings"
30
+ }
23
31
  },
24
32
  "definitions": {
25
- "nonEmptyString": { "type": "string", "minLength": 1, "pattern": "\\S" },
33
+ "nonEmptyString": {
34
+ "type": "string",
35
+ "minLength": 1,
36
+ "pattern": "\\S"
37
+ },
26
38
  "gate": {
27
39
  "type": "object",
28
40
  "required": ["path", "decides", "impact"],
@@ -43,7 +55,16 @@
43
55
  "equivalentMutants": {
44
56
  "description": "Surviving mutants that are allowed because they cannot be observed from outside. One entry allows exactly one mutant.",
45
57
  "type": "array",
46
- "items": { "$ref": "#/definitions/equivalentMutant" }
58
+ "items": {
59
+ "$ref": "#/definitions/equivalentMutant"
60
+ }
61
+ },
62
+ "expectedTimeouts": {
63
+ "description": "Since 0.2.0. Mutants that can never finish (a loop that no longer ends) and therefore always time out. Same key as equivalentMutants. They are not counted against settings.mutation.maxTimeouts. An entry whose mutant survives, or does not exist, fails the run; one that is killed is accepted.",
64
+ "type": "array",
65
+ "items": {
66
+ "$ref": "#/definitions/equivalentMutant"
67
+ }
47
68
  }
48
69
  }
49
70
  },
@@ -84,7 +105,9 @@
84
105
  "description": "File or directory, relative to the subproject.",
85
106
  "$ref": "#/definitions/nonEmptyString"
86
107
  },
87
- "decides": { "$ref": "#/definitions/nonEmptyString" },
108
+ "decides": {
109
+ "$ref": "#/definitions/nonEmptyString"
110
+ },
88
111
  "blocker": {
89
112
  "description": "What has to happen before this can become a gate.",
90
113
  "$ref": "#/definitions/nonEmptyString"
@@ -94,15 +117,23 @@
94
117
  "patternRule": {
95
118
  "description": "A regular expression, either as a string or with flags and a reason.",
96
119
  "oneOf": [
97
- { "$ref": "#/definitions/nonEmptyString" },
120
+ {
121
+ "$ref": "#/definitions/nonEmptyString"
122
+ },
98
123
  {
99
124
  "type": "object",
100
125
  "required": ["pattern"],
101
126
  "additionalProperties": false,
102
127
  "properties": {
103
- "pattern": { "$ref": "#/definitions/nonEmptyString" },
104
- "flags": { "type": "string" },
105
- "reason": { "type": "string" }
128
+ "pattern": {
129
+ "$ref": "#/definitions/nonEmptyString"
130
+ },
131
+ "flags": {
132
+ "type": "string"
133
+ },
134
+ "reason": {
135
+ "type": "string"
136
+ }
106
137
  }
107
138
  }
108
139
  ]
@@ -116,7 +147,12 @@
116
147
  "type": "boolean",
117
148
  "default": true
118
149
  },
119
- "add": { "type": "array", "items": { "$ref": "#/definitions/patternRule" } }
150
+ "add": {
151
+ "type": "array",
152
+ "items": {
153
+ "$ref": "#/definitions/patternRule"
154
+ }
155
+ }
120
156
  }
121
157
  },
122
158
  "settings": {
@@ -128,7 +164,10 @@
128
164
  "description": "File extensions a gate may have.",
129
165
  "type": "array",
130
166
  "minItems": 1,
131
- "items": { "type": "string", "pattern": "^\\." },
167
+ "items": {
168
+ "type": "string",
169
+ "pattern": "^\\."
170
+ },
132
171
  "default": [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"]
133
172
  },
134
173
  "spec": {
@@ -138,7 +177,9 @@
138
177
  "suffixes": {
139
178
  "description": "Replaces the gate's extension to form the spec path. Default: \".spec<ext>\" and \".test<ext>\". An empty array means the project has no spec convention.",
140
179
  "type": "array",
141
- "items": { "$ref": "#/definitions/nonEmptyString" }
180
+ "items": {
181
+ "$ref": "#/definitions/nonEmptyString"
182
+ }
142
183
  },
143
184
  "rewrite": {
144
185
  "description": "Regular expression replacements applied to the gate path before the suffix, for specs that live in another directory.",
@@ -148,8 +189,12 @@
148
189
  "required": ["from", "to"],
149
190
  "additionalProperties": false,
150
191
  "properties": {
151
- "from": { "$ref": "#/definitions/nonEmptyString" },
152
- "to": { "type": "string" }
192
+ "from": {
193
+ "$ref": "#/definitions/nonEmptyString"
194
+ },
195
+ "to": {
196
+ "type": "string"
197
+ }
153
198
  }
154
199
  }
155
200
  }
@@ -160,12 +205,22 @@
160
205
  "type": "object",
161
206
  "additionalProperties": false,
162
207
  "properties": {
163
- "defaults": { "type": "boolean", "default": true },
164
- "add": { "type": "array", "items": { "$ref": "#/definitions/patternRule" } },
208
+ "defaults": {
209
+ "type": "boolean",
210
+ "default": true
211
+ },
212
+ "add": {
213
+ "type": "array",
214
+ "items": {
215
+ "$ref": "#/definitions/patternRule"
216
+ }
217
+ },
165
218
  "allow": {
166
219
  "description": "Module specifiers exempt from every rule (regular expressions).",
167
220
  "type": "array",
168
- "items": { "$ref": "#/definitions/nonEmptyString" }
221
+ "items": {
222
+ "$ref": "#/definitions/nonEmptyString"
223
+ }
169
224
  }
170
225
  }
171
226
  },
@@ -174,7 +229,10 @@
174
229
  "type": "object",
175
230
  "additionalProperties": false,
176
231
  "properties": {
177
- "defaults": { "type": "boolean", "default": true },
232
+ "defaults": {
233
+ "type": "boolean",
234
+ "default": true
235
+ },
178
236
  "add": {
179
237
  "type": "array",
180
238
  "items": {
@@ -182,13 +240,19 @@
182
240
  "required": ["module", "names"],
183
241
  "additionalProperties": false,
184
242
  "properties": {
185
- "module": { "$ref": "#/definitions/nonEmptyString" },
243
+ "module": {
244
+ "$ref": "#/definitions/nonEmptyString"
245
+ },
186
246
  "names": {
187
247
  "type": "array",
188
248
  "minItems": 1,
189
- "items": { "$ref": "#/definitions/nonEmptyString" }
249
+ "items": {
250
+ "$ref": "#/definitions/nonEmptyString"
251
+ }
190
252
  },
191
- "reason": { "type": "string" }
253
+ "reason": {
254
+ "type": "string"
255
+ }
192
256
  }
193
257
  }
194
258
  }
@@ -199,7 +263,7 @@
199
263
  "$ref": "#/definitions/ruleList"
200
264
  },
201
265
  "forbiddenSource": {
202
- "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).",
203
267
  "$ref": "#/definitions/ruleList"
204
268
  },
205
269
  "importAliases": {
@@ -210,8 +274,12 @@
210
274
  "required": ["prefix", "target"],
211
275
  "additionalProperties": false,
212
276
  "properties": {
213
- "prefix": { "$ref": "#/definitions/nonEmptyString" },
214
- "target": { "type": "string" }
277
+ "prefix": {
278
+ "$ref": "#/definitions/nonEmptyString"
279
+ },
280
+ "target": {
281
+ "type": "string"
282
+ }
215
283
  }
216
284
  }
217
285
  },
@@ -219,7 +287,9 @@
219
287
  "description": "The command `test-gates selfcheck` runs. Default: `jest --config jest.gates.config.*` or `vitest run --config vitest.gates.config.*`, whichever config exists.",
220
288
  "type": "array",
221
289
  "minItems": 1,
222
- "items": { "$ref": "#/definitions/nonEmptyString" }
290
+ "items": {
291
+ "$ref": "#/definitions/nonEmptyString"
292
+ }
223
293
  },
224
294
  "selfcheck": {
225
295
  "type": "object",
@@ -262,7 +332,74 @@
262
332
  "summaryExclude": {
263
333
  "description": "Paths left out of the reference total (regular expressions).",
264
334
  "type": "array",
265
- "items": { "$ref": "#/definitions/nonEmptyString" }
335
+ "items": {
336
+ "$ref": "#/definitions/nonEmptyString"
337
+ }
338
+ }
339
+ }
340
+ },
341
+ "imports": {
342
+ "description": "Since 0.2.0. How runtime imports of a gate are judged.",
343
+ "type": "object",
344
+ "additionalProperties": false,
345
+ "properties": {
346
+ "mode": {
347
+ "description": "blocklist (default): only the impureImports rules apply. allowlist: in addition, a runtime import must be relative, go through an import alias, or be listed in allow; a project import must not point at a file matched by impurePaths; a computed require() / import() is rejected.",
348
+ "enum": ["blocklist", "allowlist"],
349
+ "default": "blocklist"
350
+ },
351
+ "allow": {
352
+ "description": "Packages a gate may import in allowlist mode. A string is a package name (its subpaths included). { pattern } is a regular expression tested against the specifier. { module, names } allows only those named imports.",
353
+ "type": "array",
354
+ "items": {
355
+ "oneOf": [
356
+ {
357
+ "$ref": "#/definitions/nonEmptyString"
358
+ },
359
+ {
360
+ "type": "object",
361
+ "required": ["pattern"],
362
+ "additionalProperties": false,
363
+ "properties": {
364
+ "pattern": {
365
+ "$ref": "#/definitions/nonEmptyString"
366
+ },
367
+ "flags": {
368
+ "type": "string"
369
+ }
370
+ }
371
+ },
372
+ {
373
+ "type": "object",
374
+ "required": ["module", "names"],
375
+ "additionalProperties": false,
376
+ "properties": {
377
+ "module": {
378
+ "$ref": "#/definitions/nonEmptyString"
379
+ },
380
+ "names": {
381
+ "type": "array",
382
+ "minItems": 1,
383
+ "items": {
384
+ "$ref": "#/definitions/nonEmptyString"
385
+ }
386
+ }
387
+ }
388
+ }
389
+ ]
390
+ }
391
+ }
392
+ }
393
+ },
394
+ "mutation": {
395
+ "description": "Since 0.2.0.",
396
+ "type": "object",
397
+ "additionalProperties": false,
398
+ "properties": {
399
+ "maxTimeouts": {
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
+ "type": "integer",
402
+ "minimum": 0
266
403
  }
267
404
  }
268
405
  }