@t09tanaka/test-gates 0.1.0 → 0.2.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 vitestCoverageWarning(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";
@@ -660,7 +750,9 @@ function resolveSettings(raw) {
660
750
  "gateCommand",
661
751
  "selfcheck",
662
752
  "stryker",
663
- "lcov"
753
+ "lcov",
754
+ "imports",
755
+ "mutation"
664
756
  ],
665
757
  "settings"
666
758
  );
@@ -761,6 +853,42 @@ function resolveSettings(raw) {
761
853
  "settings.selfcheck.failurePattern"
762
854
  );
763
855
  }
856
+ const importsSection = section(settings.imports, ["mode", "allow"], "settings.imports");
857
+ if (importsSection.mode !== void 0 && importsSection.mode !== "blocklist" && importsSection.mode !== "allowlist") {
858
+ throw new SettingsError('settings.imports.mode: must be "blocklist" or "allowlist"');
859
+ }
860
+ const importAllowRaw = importsSection.allow ?? [];
861
+ if (!Array.isArray(importAllowRaw)) {
862
+ throw new SettingsError("settings.imports.allow: must be an array");
863
+ }
864
+ const importAllow = importAllowRaw.map((item, index) => {
865
+ const at = `settings.imports.allow[${index}]`;
866
+ if (typeof item === "string" && item !== "") {
867
+ return { module: item };
868
+ }
869
+ if (isRecord2(item) && typeof item.pattern === "string" && item.pattern !== "") {
870
+ rejectUnknownKeys(item, ["pattern", "flags"], at);
871
+ if (item.flags !== void 0 && typeof item.flags !== "string") {
872
+ throw new SettingsError(`${at}.flags: must be a string`);
873
+ }
874
+ return { regex: compile(item.pattern, item.flags, at) };
875
+ }
876
+ if (isRecord2(item) && typeof item.module === "string" && item.module !== "") {
877
+ rejectUnknownKeys(item, ["module", "names"], at);
878
+ const names = stringArray(item.names, `${at}.names`);
879
+ if (names.length === 0) {
880
+ throw new SettingsError(`${at}.names: must list at least one name`);
881
+ }
882
+ return { module: item.module, names };
883
+ }
884
+ throw new SettingsError(
885
+ `${at}: must be a package name, { pattern, flags? } or { module, names }`
886
+ );
887
+ });
888
+ const mutation = section(settings.mutation, ["maxTimeouts"], "settings.mutation");
889
+ if (mutation.maxTimeouts !== void 0 && !(Number.isInteger(mutation.maxTimeouts) && mutation.maxTimeouts >= 0)) {
890
+ throw new SettingsError("settings.mutation.maxTimeouts: must be an integer >= 0");
891
+ }
764
892
  const stryker = section(settings.stryker, ["configFile", "reportFile"], "settings.stryker");
765
893
  const lcov = section(settings.lcov, ["file", "summaryExclude"], "settings.lcov");
766
894
  return {
@@ -782,6 +910,9 @@ function resolveSettings(raw) {
782
910
  selfcheckFailurePattern,
783
911
  strykerConfigFile: optionalString(stryker.configFile, "settings.stryker.configFile"),
784
912
  strykerReportFile: optionalString(stryker.reportFile, "settings.stryker.reportFile") ?? DEFAULT_REPORT_FILE,
913
+ importMode: importsSection.mode ?? "blocklist",
914
+ importAllow,
915
+ maxTimeouts: mutation.maxTimeouts ?? null,
785
916
  lcovFile: optionalString(lcov.file, "settings.lcov.file"),
786
917
  lcovSummaryExclude: lcov.summaryExclude === void 0 ? [] : stringArray(lcov.summaryExclude, "settings.lcov.summaryExclude").map(
787
918
  (pattern, index) => compile(pattern, void 0, `settings.lcov.summaryExclude[${index}]`)
@@ -957,9 +1088,43 @@ function checkGates(manifest) {
957
1088
  }
958
1089
  return violations;
959
1090
  }
1091
+ var VITEST_GATE_CONFIGS = ["ts", "mts", "cts", "js", "mjs", "cjs"].map(
1092
+ (extension) => `vitest.gates.config.${extension}`
1093
+ );
1094
+ function installedVitestVersion(startDir) {
1095
+ let current = startDir;
1096
+ for (; ; ) {
1097
+ try {
1098
+ const pkg = JSON.parse(
1099
+ fs3.readFileSync(path3.join(current, "node_modules", "vitest", "package.json"), "utf8")
1100
+ );
1101
+ return typeof pkg.version === "string" ? pkg.version : null;
1102
+ } catch {
1103
+ const parent = path3.dirname(current);
1104
+ if (parent === current) {
1105
+ return null;
1106
+ }
1107
+ current = parent;
1108
+ }
1109
+ }
1110
+ }
1111
+ function checkWarnings(manifest) {
1112
+ const config = VITEST_GATE_CONFIGS.find((name) => fs3.existsSync(path3.join(manifest.dir, name)));
1113
+ if (!config) {
1114
+ return [];
1115
+ }
1116
+ const warning = vitestCoverageWarning({
1117
+ vitestVersion: installedVitestVersion(manifest.dir),
1118
+ configSource: readText(manifest.dir, config)
1119
+ });
1120
+ return warning === null ? [] : [`${config}: ${warning}`];
1121
+ }
960
1122
  function runCheck(dir, io2) {
961
1123
  const manifest = readManifest(dir);
962
1124
  const violations = checkGates(manifest);
1125
+ for (const warning of checkWarnings(manifest)) {
1126
+ io2.err(`test-gates check: warning: ${warning}`);
1127
+ }
963
1128
  if (violations.length > 0) {
964
1129
  printViolations(io2, "check", violations);
965
1130
  return 1;
@@ -1170,6 +1335,27 @@ function sliceByLocation(source, location) {
1170
1335
  function keyOf(mutator, original, replacement) {
1171
1336
  return JSON.stringify([mutator, normalizeCode(original), normalizeCode(replacement)]);
1172
1337
  }
1338
+ function describeMutant(file, entry) {
1339
+ const { mutant } = entry;
1340
+ return {
1341
+ file,
1342
+ line: mutant.location.start.line,
1343
+ column: mutant.location.start.column,
1344
+ status: mutant.status,
1345
+ mutator: mutant.mutatorName,
1346
+ original: normalizeCode(entry.original),
1347
+ replacement: normalizeCode(entry.replacement),
1348
+ occurrence: entry.occurrence,
1349
+ sameKeyCount: entry.sameKeyCount,
1350
+ allowance: {
1351
+ mutator: mutant.mutatorName,
1352
+ original: normalizeCode(entry.original),
1353
+ replacement: normalizeCode(entry.replacement),
1354
+ ...entry.sameKeyCount > 1 ? { occurrence: entry.occurrence } : {},
1355
+ reason: ""
1356
+ }
1357
+ };
1358
+ }
1173
1359
  function describeAllowance(entry) {
1174
1360
  const occurrence = entry.occurrence === void 0 ? "" : ` / occurrence ${entry.occurrence}`;
1175
1361
  return `${entry.mutator} / ${normalizeCode(entry.original)} \u2192 ${normalizeCode(entry.replacement)}${occurrence}`;
@@ -1177,13 +1363,15 @@ function describeAllowance(entry) {
1177
1363
  function judgeMutationReport(input) {
1178
1364
  const violations = [];
1179
1365
  const survivors = [];
1366
+ const unexpectedTimeouts = [];
1180
1367
  const summary = {
1181
1368
  total: 0,
1182
1369
  detected: 0,
1183
1370
  timeout: 0,
1184
1371
  allowed: 0,
1185
1372
  notEvaluable: 0,
1186
- unallowed: 0
1373
+ unallowed: 0,
1374
+ expectedTimeout: 0
1187
1375
  };
1188
1376
  const files = /* @__PURE__ */ new Map();
1189
1377
  for (const [file, result] of Object.entries(input.report.files ?? {})) {
@@ -1193,7 +1381,9 @@ function judgeMutationReport(input) {
1193
1381
  const gatePath = normalizePath2(gate.path);
1194
1382
  const shapeViolations = validateEquivalentMutants(gate.path, gate.equivalentMutants);
1195
1383
  violations.push(...shapeViolations);
1196
- const allowances = Array.isArray(gate.equivalentMutants) ? gate.equivalentMutants : [];
1384
+ violations.push(
1385
+ ...validateEquivalentMutants(gate.path, gate.expectedTimeouts, "expectedTimeouts")
1386
+ );
1197
1387
  const result = files.get(gatePath);
1198
1388
  if (!result || !Array.isArray(result.mutants) || result.mutants.length === 0) {
1199
1389
  violations.push({
@@ -1257,38 +1447,53 @@ function judgeMutationReport(input) {
1257
1447
  if (!locationsValid) {
1258
1448
  continue;
1259
1449
  }
1260
- const allowed = /* @__PURE__ */ new Set();
1261
- allowances.forEach((allowance, index) => {
1262
- if (!isUsableEquivalentMutant(allowance)) {
1263
- return;
1450
+ const claim = (field, entries, noun, accept) => {
1451
+ const claimed = /* @__PURE__ */ new Set();
1452
+ if (!Array.isArray(entries)) {
1453
+ return claimed;
1264
1454
  }
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;
1289
- }
1290
- allowed.add(target);
1291
- });
1455
+ entries.forEach((entry, index) => {
1456
+ if (!isUsableEquivalentMutant(entry)) {
1457
+ return;
1458
+ }
1459
+ const at = `${gate.path}: ${field}[${index}] (${describeAllowance(entry)})`;
1460
+ const group = byKey.get(keyOf(entry.mutator, entry.original, entry.replacement)) ?? [];
1461
+ if (entry.occurrence === void 0 && group.length > 1) {
1462
+ violations.push({
1463
+ file: MANIFEST_FILE,
1464
+ message: `${at}: matches ${group.length} mutants in the file. Add "occurrence" (1-based) to pick one`
1465
+ });
1466
+ return;
1467
+ }
1468
+ const target = group[(entry.occurrence ?? 1) - 1];
1469
+ const refusal = target ? accept(target.mutant.status) : `stale ${noun} (no such mutant in this run). Remove it`;
1470
+ if (!target || refusal !== null) {
1471
+ violations.push({ file: MANIFEST_FILE, message: `${at}: ${refusal}` });
1472
+ return;
1473
+ }
1474
+ if (claimed.has(target)) {
1475
+ violations.push({
1476
+ file: MANIFEST_FILE,
1477
+ message: `${at}: another ${noun} already covers this mutant`
1478
+ });
1479
+ return;
1480
+ }
1481
+ claimed.add(target);
1482
+ });
1483
+ return claimed;
1484
+ };
1485
+ const allowed = claim(
1486
+ "equivalentMutants",
1487
+ gate.equivalentMutants,
1488
+ "allowance",
1489
+ (status) => SURVIVED.has(status) ? null : `stale allowance (the mutant is now ${status}). Remove it`
1490
+ );
1491
+ const expected = claim(
1492
+ "expectedTimeouts",
1493
+ gate.expectedTimeouts,
1494
+ "entry",
1495
+ (status) => SURVIVED.has(status) ? `the mutant ${status === "NoCoverage" ? "has no coverage" : "survived"} instead of timing out. Detect it with a test` : null
1496
+ );
1292
1497
  for (const entry of located) {
1293
1498
  const { mutant } = entry;
1294
1499
  summary.total += 1;
@@ -1296,6 +1501,11 @@ function judgeMutationReport(input) {
1296
1501
  summary.detected += 1;
1297
1502
  if (mutant.status === "Timeout") {
1298
1503
  summary.timeout += 1;
1504
+ if (expected.has(entry)) {
1505
+ summary.expectedTimeout += 1;
1506
+ } else {
1507
+ unexpectedTimeouts.push(describeMutant(gate.path, entry));
1508
+ }
1299
1509
  }
1300
1510
  } else if (NOT_EVALUABLE.has(mutant.status)) {
1301
1511
  summary.notEvaluable += 1;
@@ -1305,24 +1515,7 @@ function judgeMutationReport(input) {
1305
1515
  continue;
1306
1516
  }
1307
1517
  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
- });
1518
+ survivors.push(describeMutant(gate.path, entry));
1326
1519
  } else {
1327
1520
  violations.push({
1328
1521
  file: gate.path,
@@ -1338,7 +1531,14 @@ function judgeMutationReport(input) {
1338
1531
  message: "no mutants were evaluated (the mutation test ran on nothing)"
1339
1532
  });
1340
1533
  }
1341
- return { violations, survivors, summary };
1534
+ const maxTimeouts = input.maxTimeouts ?? null;
1535
+ if (maxTimeouts !== null && unexpectedTimeouts.length > maxTimeouts) {
1536
+ violations.push({
1537
+ file: MANIFEST_FILE,
1538
+ message: `${unexpectedTimeouts.length} mutant(s) timed out outside expectedTimeouts; settings.mutation.maxTimeouts allows ${maxTimeouts}`
1539
+ });
1540
+ }
1541
+ return { violations, survivors, unexpectedTimeouts, summary };
1342
1542
  }
1343
1543
  function formatSurvivor(survivor) {
1344
1544
  const occurrence = survivor.sameKeyCount > 1 ? ` / occurrence ${survivor.occurrence} of ${survivor.sameKeyCount}` : "";
@@ -1372,6 +1572,7 @@ function runMutationResult(dir, reportOverride, io2) {
1372
1572
  const verdict = judgeMutationReport({
1373
1573
  gates: manifest.gates,
1374
1574
  report,
1575
+ maxTimeouts: manifest.settings.maxTimeouts,
1375
1576
  readSource: (gatePath) => {
1376
1577
  try {
1377
1578
  return fs5.readFileSync(path5.join(manifest.dir, gatePath), "utf8");
@@ -1412,6 +1613,24 @@ function runMutationResult(dir, reportOverride, io2) {
1412
1613
  }
1413
1614
  io2.err("");
1414
1615
  }
1616
+ const maxTimeouts = manifest.settings.maxTimeouts;
1617
+ if (maxTimeouts !== null && verdict.unexpectedTimeouts.length > maxTimeouts) {
1618
+ io2.err(
1619
+ `test-gates mutation: ${verdict.unexpectedTimeouts.length} mutant(s) timed out (settings.mutation.maxTimeouts: ${maxTimeouts})`
1620
+ );
1621
+ for (const mutant of verdict.unexpectedTimeouts) {
1622
+ io2.err(` - ${formatSurvivor(mutant)}`);
1623
+ }
1624
+ io2.err("");
1625
+ io2.err("A timeout counts as detected, but on a loaded machine a mutant that would survive");
1626
+ io2.err("can time out instead. Run again with less load (-- --concurrency 1). Only for a");
1627
+ io2.err("mutant that can never finish (a loop that no longer ends), add it to");
1628
+ io2.err('"expectedTimeouts" of the gate in test-gates.json and fill in "reason":');
1629
+ for (const mutant of verdict.unexpectedTimeouts) {
1630
+ io2.err(` ${mutant.file}: ${JSON.stringify(mutant.allowance)}`);
1631
+ }
1632
+ io2.err("");
1633
+ }
1415
1634
  if (verdict.violations.length > 0) {
1416
1635
  printViolations(io2, "mutation", verdict.violations);
1417
1636
  }
@@ -1521,6 +1740,18 @@ function runSelfcheck(dir, mode, commandOverride, io2) {
1521
1740
  } else {
1522
1741
  failed += 1;
1523
1742
  io2.err(` \u2717 ${gate.path}: ${reason}`);
1743
+ if (result.status === 0) {
1744
+ const importers = findImporters(
1745
+ gate.path,
1746
+ manifest.gates.filter((other) => existsExact(manifest.dir, other.path)).map((other) => ({ path: other.path, source: readText(manifest.dir, other.path) })),
1747
+ manifest.settings.importAliases
1748
+ );
1749
+ if (importers.length > 0) {
1750
+ io2.err(
1751
+ ` 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`
1752
+ );
1753
+ }
1754
+ }
1524
1755
  const tail = output.trimEnd().split("\n").slice(-25).join("\n");
1525
1756
  if (tail !== "") {
1526
1757
  io2.err(tail.replace(/^/gm, " "));