@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/cli.js CHANGED
@@ -15,6 +15,54 @@ var OPTIONS_BY_COMMAND = {
15
15
  selfcheck: ["--dir", "--all", "--first"],
16
16
  lcov: ["--dir", "--file"]
17
17
  };
18
+ function consume(parsed, argv, index, allowed) {
19
+ const arg = argv[index];
20
+ const next = index + 1;
21
+ if (arg === "--") {
22
+ if (parsed.command !== "mutation" && parsed.command !== "selfcheck") {
23
+ throw new UsageError('"--" is only accepted by the mutation and selfcheck commands');
24
+ }
25
+ parsed.rest.push(...argv.slice(next));
26
+ return argv.length;
27
+ }
28
+ if (arg === "--help" || arg === "-h") {
29
+ parsed.help = true;
30
+ return next;
31
+ }
32
+ if (arg === "--version" || arg === "-v") {
33
+ parsed.version = true;
34
+ return next;
35
+ }
36
+ const equals = arg.indexOf("=");
37
+ const name = equals !== -1 ? arg.slice(0, equals) : arg;
38
+ if (allowed?.includes(name)) {
39
+ if (VALUE_OPTIONS.includes(name)) {
40
+ const value = equals !== -1 ? arg.slice(equals + 1) : argv[next];
41
+ if (value === void 0 || value === "") {
42
+ throw new UsageError(`${name} needs a value`);
43
+ }
44
+ const key = name.slice(2);
45
+ parsed[key] = value;
46
+ return equals !== -1 ? next : next + 1;
47
+ }
48
+ if (equals !== -1) {
49
+ throw new UsageError(`${name} does not take a value`);
50
+ }
51
+ const mode = name === "--all" ? "all" : "first";
52
+ if (parsed.mode !== null && parsed.mode !== mode) {
53
+ throw new UsageError("--all and --first cannot be combined");
54
+ }
55
+ parsed.mode = mode;
56
+ return next;
57
+ }
58
+ if (parsed.command === "mutation") {
59
+ parsed.rest.push(arg);
60
+ return next;
61
+ }
62
+ throw new UsageError(
63
+ parsed.command ? `unknown argument for ${parsed.command}: ${arg}` : `unknown argument: ${arg}`
64
+ );
65
+ }
18
66
  function parseArgs(argv) {
19
67
  const parsed = {
20
68
  command: null,
@@ -37,58 +85,7 @@ function parseArgs(argv) {
37
85
  }
38
86
  const allowed = parsed.command ? OPTIONS_BY_COMMAND[parsed.command] : void 0;
39
87
  while (index < argv.length) {
40
- const arg = argv[index];
41
- index += 1;
42
- if (arg === "--") {
43
- if (parsed.command !== "mutation" && parsed.command !== "selfcheck") {
44
- throw new UsageError('"--" is only accepted by the mutation and selfcheck commands');
45
- }
46
- parsed.rest.push(...argv.slice(index));
47
- break;
48
- }
49
- if (arg === "--help" || arg === "-h") {
50
- parsed.help = true;
51
- continue;
52
- }
53
- if (arg === "--version" || arg === "-v") {
54
- parsed.version = true;
55
- continue;
56
- }
57
- const equals = arg.indexOf("=");
58
- const name = equals !== -1 ? arg.slice(0, equals) : arg;
59
- if (allowed?.includes(name)) {
60
- if (VALUE_OPTIONS.includes(name)) {
61
- let value;
62
- if (equals !== -1) {
63
- value = arg.slice(equals + 1);
64
- } else {
65
- value = argv[index];
66
- index += 1;
67
- }
68
- if (value === void 0 || value === "") {
69
- throw new UsageError(`${name} needs a value`);
70
- }
71
- const key = name.slice(2);
72
- parsed[key] = value;
73
- continue;
74
- }
75
- if (equals !== -1) {
76
- throw new UsageError(`${name} does not take a value`);
77
- }
78
- const mode = name === "--all" ? "all" : "first";
79
- if (parsed.mode !== null && parsed.mode !== mode) {
80
- throw new UsageError("--all and --first cannot be combined");
81
- }
82
- parsed.mode = mode;
83
- continue;
84
- }
85
- if (parsed.command === "mutation") {
86
- parsed.rest.push(arg);
87
- continue;
88
- }
89
- throw new UsageError(
90
- parsed.command ? `unknown argument for ${parsed.command}: ${arg}` : `unknown argument: ${arg}`
91
- );
88
+ index = consume(parsed, argv, index, allowed);
92
89
  }
93
90
  if (parsed.command === "selfcheck" && parsed.rest.length === 0 && argv.includes("--")) {
94
91
  throw new UsageError('"--" must be followed by the gate command');
@@ -128,17 +125,17 @@ function allowanceProblems(entry) {
128
125
  }
129
126
  return problems;
130
127
  }
131
- function validateEquivalentMutants(gatePath, value) {
128
+ function validateEquivalentMutants(gatePath, value, field = "equivalentMutants") {
132
129
  if (value === void 0) {
133
130
  return [];
134
131
  }
135
132
  if (!Array.isArray(value)) {
136
- return [{ file: MANIFEST_FILE, message: `${gatePath}: equivalentMutants must be an array` }];
133
+ return [{ file: MANIFEST_FILE, message: `${gatePath}: ${field} must be an array` }];
137
134
  }
138
135
  return value.flatMap(
139
136
  (entry, index) => allowanceProblems(entry).map((problem) => ({
140
137
  file: MANIFEST_FILE,
141
- message: `${gatePath}: equivalentMutants[${index}]${problem}`
138
+ message: `${gatePath}: ${field}[${index}]${problem}`
142
139
  }))
143
140
  );
144
141
  }
@@ -221,6 +218,9 @@ function parseManifest(text) {
221
218
  }
222
219
  for (const gate of gates) {
223
220
  violations.push(...validateEquivalentMutants(gate.path, gate.equivalentMutants));
221
+ violations.push(
222
+ ...validateEquivalentMutants(gate.path, gate.expectedTimeouts, "expectedTimeouts")
223
+ );
224
224
  }
225
225
  return { gates, candidates, settings: raw.settings, violations };
226
226
  }
@@ -280,6 +280,83 @@ function parseImports(source) {
280
280
  }
281
281
  return found.sort((a, b) => a.line - b.line);
282
282
  }
283
+ var COMPUTED_CALL_IMPORT = /(?<![.\w$])(?:require|import)\s*\(\s*(?!['"`\s)])/g;
284
+ function findUncheckableImports(source) {
285
+ return [...source.matchAll(COMPUTED_CALL_IMPORT)].map((match) => lineAt(source, match.index));
286
+ }
287
+
288
+ // src/core/import-policy.ts
289
+ var SCRIPT_EXTENSION = /\.[cm]?[jt]sx?$/;
290
+ function isProjectImport(specifier, aliases) {
291
+ return specifier.startsWith(".") || aliases.some((alias) => specifier.startsWith(alias.prefix));
292
+ }
293
+ function resolveSpecifier(specifier, fromFile, aliases) {
294
+ let joined = null;
295
+ if (specifier.startsWith(".")) {
296
+ const slash = fromFile.lastIndexOf("/");
297
+ joined = slash === -1 ? specifier : `${fromFile.slice(0, slash)}/${specifier}`;
298
+ } else {
299
+ const alias = aliases.find((candidate) => specifier.startsWith(candidate.prefix));
300
+ if (alias) {
301
+ joined = alias.target + specifier.slice(alias.prefix.length);
302
+ }
303
+ }
304
+ if (joined === null) {
305
+ return null;
306
+ }
307
+ const segments = [];
308
+ for (const segment of joined.split("/")) {
309
+ if (segment === "..") {
310
+ segments.pop();
311
+ } else if (segment !== "." && segment !== "") {
312
+ segments.push(segment);
313
+ }
314
+ }
315
+ return segments.join("/").replace(SCRIPT_EXTENSION, "");
316
+ }
317
+ function matches(rule, specifier) {
318
+ if (rule.regex) {
319
+ return rule.regex.test(specifier);
320
+ }
321
+ return specifier === rule.module || specifier.startsWith(`${rule.module}/`);
322
+ }
323
+ function judgePackageImport(ref, allow) {
324
+ const rules = allow.filter((rule) => matches(rule, ref.module));
325
+ if (rules.length === 0) {
326
+ return `runtime import of "${ref.module}" is not in settings.imports.allow (allowlist mode allows relative imports, import aliases and listed packages only)`;
327
+ }
328
+ if (rules.some((rule) => rule.names === void 0)) {
329
+ return null;
330
+ }
331
+ const allowedNames = rules.flatMap((rule) => rule.names);
332
+ if (ref.whole) {
333
+ return `only ${allowedNames.join(", ")} may be imported from "${ref.module}", as named imports (settings.imports.allow)`;
334
+ }
335
+ const extra = ref.names.filter((name) => !allowedNames.includes(name));
336
+ return extra.length === 0 ? null : `runtime import of ${extra.join(", ")} from "${ref.module}" is not in settings.imports.allow (allowed: ${allowedNames.join(", ")})`;
337
+ }
338
+ function judgeProjectImport(target, impurePaths, gateExtensions) {
339
+ const candidates = [target, ...gateExtensions.map((extension) => target + extension)];
340
+ for (const rule of impurePaths) {
341
+ if (candidates.some((candidate) => rule.regex.test(candidate))) {
342
+ return rule.reason;
343
+ }
344
+ }
345
+ return null;
346
+ }
347
+ function findImporters(gatePath, others, aliases) {
348
+ const target = gatePath.replace(SCRIPT_EXTENSION, "");
349
+ const asDirectory = target.replace(/\/index$/, "");
350
+ return others.filter(
351
+ (other) => other.path !== gatePath && parseImports(other.source).some((imported) => {
352
+ if (imported.typeOnly) {
353
+ return false;
354
+ }
355
+ const resolved = resolveSpecifier(imported.module, other.path, aliases);
356
+ return resolved === target || resolved === asDirectory;
357
+ })
358
+ ).map((other) => other.path);
359
+ }
283
360
 
284
361
  // src/core/scan.ts
285
362
  var IGNORE_DIRECTIVE = /\b(istanbul|c8|v8)\s+ignore\b/;
@@ -297,7 +374,7 @@ var DISABLED_TESTS = [
297
374
  }
298
375
  ];
299
376
  var MODULE_MOCK = /\b(?:jest|vi)\s*\.\s*(?:mock|doMock|unstable_mockModule|setMock)\s*\(\s*(?:import\s*\(\s*)?(['"`])([^'"`\n]+)\1/g;
300
- var SCRIPT_EXTENSION = /\.[cm]?[jt]sx?$/;
377
+ var SCRIPT_EXTENSION2 = /\.[cm]?[jt]sx?$/;
301
378
  function eachLine(source, visit) {
302
379
  source.split("\n").forEach((line, index) => visit(line, index + 1));
303
380
  }
@@ -334,6 +411,7 @@ function scanGateSource(gatePath, source, settings) {
334
411
  if (imported.typeOnly) {
335
412
  continue;
336
413
  }
414
+ const before = violations.length;
337
415
  if (!settings.allowedImports.some((regex) => regex.test(imported.module))) {
338
416
  for (const rule of settings.impureImports) {
339
417
  if (rule.regex.test(imported.module)) {
@@ -360,34 +438,31 @@ function scanGateSource(gatePath, source, settings) {
360
438
  });
361
439
  }
362
440
  }
363
- }
364
- return violations.sort((a, b) => a.line - b.line);
365
- }
366
- function resolveSpecifier(specifier, fromFile, aliases) {
367
- let joined = null;
368
- if (specifier.startsWith(".")) {
369
- const directory = fromFile.includes("/") ? fromFile.slice(0, fromFile.lastIndexOf("/")) : "";
370
- joined = directory === "" ? specifier : `${directory}/${specifier}`;
371
- } else {
372
- for (const alias of aliases) {
373
- if (specifier.startsWith(alias.prefix)) {
374
- joined = alias.target + specifier.slice(alias.prefix.length);
375
- break;
441
+ if (settings.importMode === "allowlist" && violations.length === before) {
442
+ const reason = isProjectImport(imported.module, settings.importAliases) ? judgeProjectImport(
443
+ resolveSpecifier(imported.module, gatePath, settings.importAliases),
444
+ settings.impurePaths,
445
+ settings.gateExtensions
446
+ ) : judgePackageImport(imported, settings.importAllow);
447
+ if (reason !== null) {
448
+ violations.push({
449
+ file: gatePath,
450
+ line: imported.line,
451
+ message: isProjectImport(imported.module, settings.importAliases) ? `runtime import of "${imported.module}" (${reason})` : reason
452
+ });
376
453
  }
377
454
  }
378
455
  }
379
- if (joined === null) {
380
- return null;
381
- }
382
- const segments = [];
383
- for (const segment of joined.split("/")) {
384
- if (segment === "..") {
385
- segments.pop();
386
- } else if (segment !== "." && segment !== "") {
387
- segments.push(segment);
456
+ if (settings.importMode === "allowlist") {
457
+ for (const line of findUncheckableImports(source)) {
458
+ violations.push({
459
+ file: gatePath,
460
+ line,
461
+ message: "require() / import() with a computed specifier cannot be checked (allowlist mode)"
462
+ });
388
463
  }
389
464
  }
390
- return segments.join("/").replace(SCRIPT_EXTENSION, "");
465
+ return violations.sort((a, b) => a.line - b.line);
391
466
  }
392
467
  function scanSpecSource(gatePath, specPath, source, settings) {
393
468
  const violations = [];
@@ -405,7 +480,7 @@ function scanSpecSource(gatePath, specPath, source, settings) {
405
480
  }
406
481
  }
407
482
  });
408
- const gateModule = gatePath.replace(SCRIPT_EXTENSION, "");
483
+ const gateModule = gatePath.replace(SCRIPT_EXTENSION2, "");
409
484
  const gateDirectory = gateModule.replace(/\/index$/, "");
410
485
  for (const match of source.matchAll(MODULE_MOCK)) {
411
486
  const specifier = match[2];
@@ -466,6 +541,21 @@ function looksLikeSpec(gatePath, settings) {
466
541
  return extension !== null && suffixesFor(extension, settings).some((suffix) => gatePath.endsWith(suffix));
467
542
  }
468
543
 
544
+ // src/core/vitest-coverage.ts
545
+ function vitestCoverageViolation(input) {
546
+ if (input.vitestVersion === null || input.vitestVersion === "") {
547
+ return null;
548
+ }
549
+ const major = Number(input.vitestVersion.split(".")[0]);
550
+ if (!Number.isInteger(major) || major >= 4) {
551
+ return null;
552
+ }
553
+ if (/\bistanbul\b/.test(input.configSource) || /\bexperimentalAstAwareRemapping\s*:\s*true\b/.test(input.configSource)) {
554
+ return null;
555
+ }
556
+ 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`;
557
+ }
558
+
469
559
  // src/fs.ts
470
560
  import fs from "fs";
471
561
  import path from "path";
@@ -559,8 +649,13 @@ var DEFAULT_FORBIDDEN_SOURCE = [
559
649
  {
560
650
  pattern: `^\\s*(['"])use (client|server)\\1`,
561
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"
562
656
  }
563
657
  ];
658
+ var DEFAULT_MAX_TIMEOUTS = 0;
564
659
  var SettingsError = class extends Error {
565
660
  };
566
661
  function isRecord2(value) {
@@ -660,7 +755,9 @@ function resolveSettings(raw) {
660
755
  "gateCommand",
661
756
  "selfcheck",
662
757
  "stryker",
663
- "lcov"
758
+ "lcov",
759
+ "imports",
760
+ "mutation"
664
761
  ],
665
762
  "settings"
666
763
  );
@@ -761,6 +858,42 @@ function resolveSettings(raw) {
761
858
  "settings.selfcheck.failurePattern"
762
859
  );
763
860
  }
861
+ const importsSection = section(settings.imports, ["mode", "allow"], "settings.imports");
862
+ if (importsSection.mode !== void 0 && importsSection.mode !== "blocklist" && importsSection.mode !== "allowlist") {
863
+ throw new SettingsError('settings.imports.mode: must be "blocklist" or "allowlist"');
864
+ }
865
+ const importAllowRaw = importsSection.allow ?? [];
866
+ if (!Array.isArray(importAllowRaw)) {
867
+ throw new SettingsError("settings.imports.allow: must be an array");
868
+ }
869
+ const importAllow = importAllowRaw.map((item, index) => {
870
+ const at = `settings.imports.allow[${index}]`;
871
+ if (typeof item === "string" && item !== "") {
872
+ return { module: item };
873
+ }
874
+ if (isRecord2(item) && typeof item.pattern === "string" && item.pattern !== "") {
875
+ rejectUnknownKeys(item, ["pattern", "flags"], at);
876
+ if (item.flags !== void 0 && typeof item.flags !== "string") {
877
+ throw new SettingsError(`${at}.flags: must be a string`);
878
+ }
879
+ return { regex: compile(item.pattern, item.flags, at) };
880
+ }
881
+ if (isRecord2(item) && typeof item.module === "string" && item.module !== "") {
882
+ rejectUnknownKeys(item, ["module", "names"], at);
883
+ const names = stringArray(item.names, `${at}.names`);
884
+ if (names.length === 0) {
885
+ throw new SettingsError(`${at}.names: must list at least one name`);
886
+ }
887
+ return { module: item.module, names };
888
+ }
889
+ throw new SettingsError(
890
+ `${at}: must be a package name, { pattern, flags? } or { module, names }`
891
+ );
892
+ });
893
+ const mutation = section(settings.mutation, ["maxTimeouts"], "settings.mutation");
894
+ if (mutation.maxTimeouts !== void 0 && !(Number.isInteger(mutation.maxTimeouts) && mutation.maxTimeouts >= 0)) {
895
+ throw new SettingsError("settings.mutation.maxTimeouts: must be an integer >= 0");
896
+ }
764
897
  const stryker = section(settings.stryker, ["configFile", "reportFile"], "settings.stryker");
765
898
  const lcov = section(settings.lcov, ["file", "summaryExclude"], "settings.lcov");
766
899
  return {
@@ -782,6 +915,9 @@ function resolveSettings(raw) {
782
915
  selfcheckFailurePattern,
783
916
  strykerConfigFile: optionalString(stryker.configFile, "settings.stryker.configFile"),
784
917
  strykerReportFile: optionalString(stryker.reportFile, "settings.stryker.reportFile") ?? DEFAULT_REPORT_FILE,
918
+ importMode: importsSection.mode ?? "blocklist",
919
+ importAllow,
920
+ maxTimeouts: mutation.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS,
785
921
  lcovFile: optionalString(lcov.file, "settings.lcov.file"),
786
922
  lcovSummaryExclude: lcov.summaryExclude === void 0 ? [] : stringArray(lcov.summaryExclude, "settings.lcov.summaryExclude").map(
787
923
  (pattern, index) => compile(pattern, void 0, `settings.lcov.summaryExclude[${index}]`)
@@ -955,8 +1091,40 @@ function checkGates(manifest) {
955
1091
  violations.push(...scanStrykerConfig(strykerConfig, readText(dir, strykerConfig)));
956
1092
  }
957
1093
  }
1094
+ violations.push(...checkVitestCoverage(manifest));
958
1095
  return violations;
959
1096
  }
1097
+ var VITEST_GATE_CONFIGS = ["ts", "mts", "cts", "js", "mjs", "cjs"].map(
1098
+ (extension) => `vitest.gates.config.${extension}`
1099
+ );
1100
+ function installedVitestVersion(startDir) {
1101
+ let current = startDir;
1102
+ for (; ; ) {
1103
+ try {
1104
+ const pkg = JSON.parse(
1105
+ fs3.readFileSync(path3.join(current, "node_modules", "vitest", "package.json"), "utf8")
1106
+ );
1107
+ return typeof pkg.version === "string" ? pkg.version : null;
1108
+ } catch {
1109
+ const parent = path3.dirname(current);
1110
+ if (parent === current) {
1111
+ return null;
1112
+ }
1113
+ current = parent;
1114
+ }
1115
+ }
1116
+ }
1117
+ function checkVitestCoverage(manifest) {
1118
+ const config = VITEST_GATE_CONFIGS.find((name) => fs3.existsSync(path3.join(manifest.dir, name)));
1119
+ if (!config) {
1120
+ return [];
1121
+ }
1122
+ const message = vitestCoverageViolation({
1123
+ vitestVersion: installedVitestVersion(manifest.dir),
1124
+ configSource: readText(manifest.dir, config)
1125
+ });
1126
+ return message === null ? [] : [{ file: config, message }];
1127
+ }
960
1128
  function runCheck(dir, io2) {
961
1129
  const manifest = readManifest(dir);
962
1130
  const violations = checkGates(manifest);
@@ -1170,6 +1338,27 @@ function sliceByLocation(source, location) {
1170
1338
  function keyOf(mutator, original, replacement) {
1171
1339
  return JSON.stringify([mutator, normalizeCode(original), normalizeCode(replacement)]);
1172
1340
  }
1341
+ function describeMutant(file, entry) {
1342
+ const { mutant } = entry;
1343
+ return {
1344
+ file,
1345
+ line: mutant.location.start.line,
1346
+ column: mutant.location.start.column,
1347
+ status: mutant.status,
1348
+ mutator: mutant.mutatorName,
1349
+ original: normalizeCode(entry.original),
1350
+ replacement: normalizeCode(entry.replacement),
1351
+ occurrence: entry.occurrence,
1352
+ sameKeyCount: entry.sameKeyCount,
1353
+ allowance: {
1354
+ mutator: mutant.mutatorName,
1355
+ original: normalizeCode(entry.original),
1356
+ replacement: normalizeCode(entry.replacement),
1357
+ ...entry.sameKeyCount > 1 ? { occurrence: entry.occurrence } : {},
1358
+ reason: ""
1359
+ }
1360
+ };
1361
+ }
1173
1362
  function describeAllowance(entry) {
1174
1363
  const occurrence = entry.occurrence === void 0 ? "" : ` / occurrence ${entry.occurrence}`;
1175
1364
  return `${entry.mutator} / ${normalizeCode(entry.original)} \u2192 ${normalizeCode(entry.replacement)}${occurrence}`;
@@ -1177,13 +1366,15 @@ function describeAllowance(entry) {
1177
1366
  function judgeMutationReport(input) {
1178
1367
  const violations = [];
1179
1368
  const survivors = [];
1369
+ const unexpectedTimeouts = [];
1180
1370
  const summary = {
1181
1371
  total: 0,
1182
1372
  detected: 0,
1183
1373
  timeout: 0,
1184
1374
  allowed: 0,
1185
1375
  notEvaluable: 0,
1186
- unallowed: 0
1376
+ unallowed: 0,
1377
+ expectedTimeout: 0
1187
1378
  };
1188
1379
  const files = /* @__PURE__ */ new Map();
1189
1380
  for (const [file, result] of Object.entries(input.report.files ?? {})) {
@@ -1193,7 +1384,9 @@ function judgeMutationReport(input) {
1193
1384
  const gatePath = normalizePath2(gate.path);
1194
1385
  const shapeViolations = validateEquivalentMutants(gate.path, gate.equivalentMutants);
1195
1386
  violations.push(...shapeViolations);
1196
- const allowances = Array.isArray(gate.equivalentMutants) ? gate.equivalentMutants : [];
1387
+ violations.push(
1388
+ ...validateEquivalentMutants(gate.path, gate.expectedTimeouts, "expectedTimeouts")
1389
+ );
1197
1390
  const result = files.get(gatePath);
1198
1391
  if (!result || !Array.isArray(result.mutants) || result.mutants.length === 0) {
1199
1392
  violations.push({
@@ -1257,38 +1450,53 @@ function judgeMutationReport(input) {
1257
1450
  if (!locationsValid) {
1258
1451
  continue;
1259
1452
  }
1260
- const allowed = /* @__PURE__ */ new Set();
1261
- allowances.forEach((allowance, index) => {
1262
- if (!isUsableEquivalentMutant(allowance)) {
1263
- return;
1264
- }
1265
- const at = `${gate.path}: equivalentMutants[${index}] (${describeAllowance(allowance)})`;
1266
- const group = byKey.get(keyOf(allowance.mutator, allowance.original, allowance.replacement)) ?? [];
1267
- if (allowance.occurrence === void 0 && group.length > 1) {
1268
- violations.push({
1269
- file: MANIFEST_FILE,
1270
- message: `${at}: matches ${group.length} mutants in the file. Add "occurrence" (1-based) to pick one`
1271
- });
1272
- return;
1273
- }
1274
- const target = group[(allowance.occurrence ?? 1) - 1];
1275
- if (!target || !SURVIVED.has(target.mutant.status)) {
1276
- const why = target ? `the mutant is now ${target.mutant.status}` : "no such mutant in this run";
1277
- violations.push({
1278
- file: MANIFEST_FILE,
1279
- message: `${at}: stale allowance (${why}). Remove it`
1280
- });
1281
- return;
1282
- }
1283
- if (allowed.has(target)) {
1284
- violations.push({
1285
- file: MANIFEST_FILE,
1286
- message: `${at}: another allowance already covers this mutant`
1287
- });
1288
- return;
1453
+ const claim = (field, entries, noun, accept) => {
1454
+ const claimed = /* @__PURE__ */ new Set();
1455
+ if (!Array.isArray(entries)) {
1456
+ return claimed;
1289
1457
  }
1290
- allowed.add(target);
1291
- });
1458
+ entries.forEach((entry, index) => {
1459
+ if (!isUsableEquivalentMutant(entry)) {
1460
+ return;
1461
+ }
1462
+ const at = `${gate.path}: ${field}[${index}] (${describeAllowance(entry)})`;
1463
+ const group = byKey.get(keyOf(entry.mutator, entry.original, entry.replacement)) ?? [];
1464
+ if (entry.occurrence === void 0 && group.length > 1) {
1465
+ violations.push({
1466
+ file: MANIFEST_FILE,
1467
+ message: `${at}: matches ${group.length} mutants in the file. Add "occurrence" (1-based) to pick one`
1468
+ });
1469
+ return;
1470
+ }
1471
+ const target = group[(entry.occurrence ?? 1) - 1];
1472
+ const refusal = target ? accept(target.mutant.status) : `stale ${noun} (no such mutant in this run). Remove it`;
1473
+ if (!target || refusal !== null) {
1474
+ violations.push({ file: MANIFEST_FILE, message: `${at}: ${refusal}` });
1475
+ return;
1476
+ }
1477
+ if (claimed.has(target)) {
1478
+ violations.push({
1479
+ file: MANIFEST_FILE,
1480
+ message: `${at}: another ${noun} already covers this mutant`
1481
+ });
1482
+ return;
1483
+ }
1484
+ claimed.add(target);
1485
+ });
1486
+ return claimed;
1487
+ };
1488
+ const allowed = claim(
1489
+ "equivalentMutants",
1490
+ gate.equivalentMutants,
1491
+ "allowance",
1492
+ (status) => SURVIVED.has(status) ? null : `stale allowance (the mutant is now ${status}). Remove it`
1493
+ );
1494
+ const expected = claim(
1495
+ "expectedTimeouts",
1496
+ gate.expectedTimeouts,
1497
+ "entry",
1498
+ (status) => SURVIVED.has(status) ? `the mutant ${status === "NoCoverage" ? "has no coverage" : "survived"} instead of timing out. Detect it with a test` : null
1499
+ );
1292
1500
  for (const entry of located) {
1293
1501
  const { mutant } = entry;
1294
1502
  summary.total += 1;
@@ -1296,6 +1504,11 @@ function judgeMutationReport(input) {
1296
1504
  summary.detected += 1;
1297
1505
  if (mutant.status === "Timeout") {
1298
1506
  summary.timeout += 1;
1507
+ if (expected.has(entry)) {
1508
+ summary.expectedTimeout += 1;
1509
+ } else {
1510
+ unexpectedTimeouts.push(describeMutant(gate.path, entry));
1511
+ }
1299
1512
  }
1300
1513
  } else if (NOT_EVALUABLE.has(mutant.status)) {
1301
1514
  summary.notEvaluable += 1;
@@ -1305,24 +1518,7 @@ function judgeMutationReport(input) {
1305
1518
  continue;
1306
1519
  }
1307
1520
  summary.unallowed += 1;
1308
- survivors.push({
1309
- file: gate.path,
1310
- line: mutant.location.start.line,
1311
- column: mutant.location.start.column,
1312
- status: mutant.status,
1313
- mutator: mutant.mutatorName,
1314
- original: normalizeCode(entry.original),
1315
- replacement: normalizeCode(entry.replacement),
1316
- occurrence: entry.occurrence,
1317
- sameKeyCount: entry.sameKeyCount,
1318
- allowance: {
1319
- mutator: mutant.mutatorName,
1320
- original: normalizeCode(entry.original),
1321
- replacement: normalizeCode(entry.replacement),
1322
- ...entry.sameKeyCount > 1 ? { occurrence: entry.occurrence } : {},
1323
- reason: ""
1324
- }
1325
- });
1521
+ survivors.push(describeMutant(gate.path, entry));
1326
1522
  } else {
1327
1523
  violations.push({
1328
1524
  file: gate.path,
@@ -1338,7 +1534,14 @@ function judgeMutationReport(input) {
1338
1534
  message: "no mutants were evaluated (the mutation test ran on nothing)"
1339
1535
  });
1340
1536
  }
1341
- return { violations, survivors, summary };
1537
+ const maxTimeouts = input.maxTimeouts ?? 0;
1538
+ if (unexpectedTimeouts.length > maxTimeouts) {
1539
+ violations.push({
1540
+ file: MANIFEST_FILE,
1541
+ message: `${unexpectedTimeouts.length} mutant(s) timed out outside expectedTimeouts; settings.mutation.maxTimeouts allows ${maxTimeouts}`
1542
+ });
1543
+ }
1544
+ return { violations, survivors, unexpectedTimeouts, summary };
1342
1545
  }
1343
1546
  function formatSurvivor(survivor) {
1344
1547
  const occurrence = survivor.sameKeyCount > 1 ? ` / occurrence ${survivor.occurrence} of ${survivor.sameKeyCount}` : "";
@@ -1372,6 +1575,7 @@ function runMutationResult(dir, reportOverride, io2) {
1372
1575
  const verdict = judgeMutationReport({
1373
1576
  gates: manifest.gates,
1374
1577
  report,
1578
+ maxTimeouts: manifest.settings.maxTimeouts,
1375
1579
  readSource: (gatePath) => {
1376
1580
  try {
1377
1581
  return fs5.readFileSync(path5.join(manifest.dir, gatePath), "utf8");
@@ -1412,6 +1616,25 @@ function runMutationResult(dir, reportOverride, io2) {
1412
1616
  }
1413
1617
  io2.err("");
1414
1618
  }
1619
+ const maxTimeouts = manifest.settings.maxTimeouts;
1620
+ if (verdict.unexpectedTimeouts.length > maxTimeouts) {
1621
+ io2.err(
1622
+ `test-gates mutation: ${verdict.unexpectedTimeouts.length} mutant(s) timed out (settings.mutation.maxTimeouts: ${maxTimeouts})`
1623
+ );
1624
+ for (const mutant of verdict.unexpectedTimeouts) {
1625
+ io2.err(` - ${formatSurvivor(mutant)}`);
1626
+ }
1627
+ io2.err("");
1628
+ io2.err("A timeout counts as detected, but on a loaded machine a mutant that would survive");
1629
+ io2.err("can time out instead. Run again with less load (-- --concurrency 1). Only for a");
1630
+ io2.err("mutant that can never finish (a loop that no longer ends), add it to");
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):");
1633
+ for (const mutant of verdict.unexpectedTimeouts) {
1634
+ io2.err(` ${mutant.file}: ${JSON.stringify(mutant.allowance)}`);
1635
+ }
1636
+ io2.err("");
1637
+ }
1415
1638
  if (verdict.violations.length > 0) {
1416
1639
  printViolations(io2, "mutation", verdict.violations);
1417
1640
  }
@@ -1521,6 +1744,18 @@ function runSelfcheck(dir, mode, commandOverride, io2) {
1521
1744
  } else {
1522
1745
  failed += 1;
1523
1746
  io2.err(` \u2717 ${gate.path}: ${reason}`);
1747
+ if (result.status === 0) {
1748
+ const importers = findImporters(
1749
+ gate.path,
1750
+ manifest.gates.filter((other) => existsExact(manifest.dir, other.path)).map((other) => ({ path: other.path, source: readText(manifest.dir, other.path) })),
1751
+ manifest.settings.importAliases
1752
+ );
1753
+ if (importers.length > 0) {
1754
+ io2.err(
1755
+ ` hint: ${importers.join(", ")} import(s) this gate, so their specs cover it without its own spec. Mock the import in those specs and assert the call (toHaveBeenCalledWith), so that each spec only covers its own gate`
1756
+ );
1757
+ }
1758
+ }
1524
1759
  const tail = output.trimEnd().split("\n").slice(-25).join("\n");
1525
1760
  if (tail !== "") {
1526
1761
  io2.err(tail.replace(/^/gm, " "));