@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/index.cjs CHANGED
@@ -92,17 +92,17 @@ function allowanceProblems(entry) {
92
92
  }
93
93
  return problems;
94
94
  }
95
- function validateEquivalentMutants(gatePath, value) {
95
+ function validateEquivalentMutants(gatePath, value, field = "equivalentMutants") {
96
96
  if (value === void 0) {
97
97
  return [];
98
98
  }
99
99
  if (!Array.isArray(value)) {
100
- return [{ file: MANIFEST_FILE, message: `${gatePath}: equivalentMutants must be an array` }];
100
+ return [{ file: MANIFEST_FILE, message: `${gatePath}: ${field} must be an array` }];
101
101
  }
102
102
  return value.flatMap(
103
103
  (entry, index) => allowanceProblems(entry).map((problem) => ({
104
104
  file: MANIFEST_FILE,
105
- message: `${gatePath}: equivalentMutants[${index}]${problem}`
105
+ message: `${gatePath}: ${field}[${index}]${problem}`
106
106
  }))
107
107
  );
108
108
  }
@@ -185,6 +185,9 @@ function parseManifest(text) {
185
185
  }
186
186
  for (const gate of gates) {
187
187
  violations.push(...validateEquivalentMutants(gate.path, gate.equivalentMutants));
188
+ violations.push(
189
+ ...validateEquivalentMutants(gate.path, gate.expectedTimeouts, "expectedTimeouts")
190
+ );
188
191
  }
189
192
  return { gates, candidates, settings: raw.settings, violations };
190
193
  }
@@ -233,8 +236,13 @@ var DEFAULT_FORBIDDEN_SOURCE = [
233
236
  {
234
237
  pattern: `^\\s*(['"])use (client|server)\\1`,
235
238
  reason: "'use client' / 'use server' directive (not a pure module)"
239
+ },
240
+ {
241
+ pattern: "\\bprocess\\??\\.env\\b",
242
+ reason: "reads process.env (not a pure module). Take the value as an argument"
236
243
  }
237
244
  ];
245
+ var DEFAULT_MAX_TIMEOUTS = 0;
238
246
  var SettingsError = class extends Error {
239
247
  };
240
248
  function isRecord2(value) {
@@ -334,7 +342,9 @@ function resolveSettings(raw) {
334
342
  "gateCommand",
335
343
  "selfcheck",
336
344
  "stryker",
337
- "lcov"
345
+ "lcov",
346
+ "imports",
347
+ "mutation"
338
348
  ],
339
349
  "settings"
340
350
  );
@@ -435,6 +445,42 @@ function resolveSettings(raw) {
435
445
  "settings.selfcheck.failurePattern"
436
446
  );
437
447
  }
448
+ const importsSection = section(settings.imports, ["mode", "allow"], "settings.imports");
449
+ if (importsSection.mode !== void 0 && importsSection.mode !== "blocklist" && importsSection.mode !== "allowlist") {
450
+ throw new SettingsError('settings.imports.mode: must be "blocklist" or "allowlist"');
451
+ }
452
+ const importAllowRaw = importsSection.allow ?? [];
453
+ if (!Array.isArray(importAllowRaw)) {
454
+ throw new SettingsError("settings.imports.allow: must be an array");
455
+ }
456
+ const importAllow = importAllowRaw.map((item, index) => {
457
+ const at = `settings.imports.allow[${index}]`;
458
+ if (typeof item === "string" && item !== "") {
459
+ return { module: item };
460
+ }
461
+ if (isRecord2(item) && typeof item.pattern === "string" && item.pattern !== "") {
462
+ rejectUnknownKeys(item, ["pattern", "flags"], at);
463
+ if (item.flags !== void 0 && typeof item.flags !== "string") {
464
+ throw new SettingsError(`${at}.flags: must be a string`);
465
+ }
466
+ return { regex: compile(item.pattern, item.flags, at) };
467
+ }
468
+ if (isRecord2(item) && typeof item.module === "string" && item.module !== "") {
469
+ rejectUnknownKeys(item, ["module", "names"], at);
470
+ const names = stringArray(item.names, `${at}.names`);
471
+ if (names.length === 0) {
472
+ throw new SettingsError(`${at}.names: must list at least one name`);
473
+ }
474
+ return { module: item.module, names };
475
+ }
476
+ throw new SettingsError(
477
+ `${at}: must be a package name, { pattern, flags? } or { module, names }`
478
+ );
479
+ });
480
+ const mutation = section(settings.mutation, ["maxTimeouts"], "settings.mutation");
481
+ if (mutation.maxTimeouts !== void 0 && !(Number.isInteger(mutation.maxTimeouts) && mutation.maxTimeouts >= 0)) {
482
+ throw new SettingsError("settings.mutation.maxTimeouts: must be an integer >= 0");
483
+ }
438
484
  const stryker = section(settings.stryker, ["configFile", "reportFile"], "settings.stryker");
439
485
  const lcov = section(settings.lcov, ["file", "summaryExclude"], "settings.lcov");
440
486
  return {
@@ -456,6 +502,9 @@ function resolveSettings(raw) {
456
502
  selfcheckFailurePattern,
457
503
  strykerConfigFile: optionalString(stryker.configFile, "settings.stryker.configFile"),
458
504
  strykerReportFile: optionalString(stryker.reportFile, "settings.stryker.reportFile") ?? DEFAULT_REPORT_FILE,
505
+ importMode: importsSection.mode ?? "blocklist",
506
+ importAllow,
507
+ maxTimeouts: mutation.maxTimeouts ?? DEFAULT_MAX_TIMEOUTS,
459
508
  lcovFile: optionalString(lcov.file, "settings.lcov.file"),
460
509
  lcovSummaryExclude: lcov.summaryExclude === void 0 ? [] : stringArray(lcov.summaryExclude, "settings.lcov.summaryExclude").map(
461
510
  (pattern, index) => compile(pattern, void 0, `settings.lcov.summaryExclude[${index}]`)
@@ -650,6 +699,70 @@ function parseImports(source) {
650
699
  }
651
700
  return found.sort((a, b) => a.line - b.line);
652
701
  }
702
+ var COMPUTED_CALL_IMPORT = /(?<![.\w$])(?:require|import)\s*\(\s*(?!['"`\s)])/g;
703
+ function findUncheckableImports(source) {
704
+ return [...source.matchAll(COMPUTED_CALL_IMPORT)].map((match) => lineAt(source, match.index));
705
+ }
706
+
707
+ // src/core/import-policy.ts
708
+ var SCRIPT_EXTENSION = /\.[cm]?[jt]sx?$/;
709
+ function isProjectImport(specifier, aliases) {
710
+ return specifier.startsWith(".") || aliases.some((alias) => specifier.startsWith(alias.prefix));
711
+ }
712
+ function resolveSpecifier(specifier, fromFile, aliases) {
713
+ let joined = null;
714
+ if (specifier.startsWith(".")) {
715
+ const slash = fromFile.lastIndexOf("/");
716
+ joined = slash === -1 ? specifier : `${fromFile.slice(0, slash)}/${specifier}`;
717
+ } else {
718
+ const alias = aliases.find((candidate) => specifier.startsWith(candidate.prefix));
719
+ if (alias) {
720
+ joined = alias.target + specifier.slice(alias.prefix.length);
721
+ }
722
+ }
723
+ if (joined === null) {
724
+ return null;
725
+ }
726
+ const segments = [];
727
+ for (const segment of joined.split("/")) {
728
+ if (segment === "..") {
729
+ segments.pop();
730
+ } else if (segment !== "." && segment !== "") {
731
+ segments.push(segment);
732
+ }
733
+ }
734
+ return segments.join("/").replace(SCRIPT_EXTENSION, "");
735
+ }
736
+ function matches(rule, specifier) {
737
+ if (rule.regex) {
738
+ return rule.regex.test(specifier);
739
+ }
740
+ return specifier === rule.module || specifier.startsWith(`${rule.module}/`);
741
+ }
742
+ function judgePackageImport(ref, allow) {
743
+ const rules = allow.filter((rule) => matches(rule, ref.module));
744
+ if (rules.length === 0) {
745
+ return `runtime import of "${ref.module}" is not in settings.imports.allow (allowlist mode allows relative imports, import aliases and listed packages only)`;
746
+ }
747
+ if (rules.some((rule) => rule.names === void 0)) {
748
+ return null;
749
+ }
750
+ const allowedNames = rules.flatMap((rule) => rule.names);
751
+ if (ref.whole) {
752
+ return `only ${allowedNames.join(", ")} may be imported from "${ref.module}", as named imports (settings.imports.allow)`;
753
+ }
754
+ const extra = ref.names.filter((name) => !allowedNames.includes(name));
755
+ return extra.length === 0 ? null : `runtime import of ${extra.join(", ")} from "${ref.module}" is not in settings.imports.allow (allowed: ${allowedNames.join(", ")})`;
756
+ }
757
+ function judgeProjectImport(target, impurePaths, gateExtensions) {
758
+ const candidates = [target, ...gateExtensions.map((extension) => target + extension)];
759
+ for (const rule of impurePaths) {
760
+ if (candidates.some((candidate) => rule.regex.test(candidate))) {
761
+ return rule.reason;
762
+ }
763
+ }
764
+ return null;
765
+ }
653
766
 
654
767
  // src/core/scan.ts
655
768
  var IGNORE_DIRECTIVE = /\b(istanbul|c8|v8)\s+ignore\b/;
@@ -667,7 +780,7 @@ var DISABLED_TESTS = [
667
780
  }
668
781
  ];
669
782
  var MODULE_MOCK = /\b(?:jest|vi)\s*\.\s*(?:mock|doMock|unstable_mockModule|setMock)\s*\(\s*(?:import\s*\(\s*)?(['"`])([^'"`\n]+)\1/g;
670
- var SCRIPT_EXTENSION = /\.[cm]?[jt]sx?$/;
783
+ var SCRIPT_EXTENSION2 = /\.[cm]?[jt]sx?$/;
671
784
  function eachLine(source, visit) {
672
785
  source.split("\n").forEach((line, index) => visit(line, index + 1));
673
786
  }
@@ -704,6 +817,7 @@ function scanGateSource(gatePath, source, settings) {
704
817
  if (imported.typeOnly) {
705
818
  continue;
706
819
  }
820
+ const before = violations.length;
707
821
  if (!settings.allowedImports.some((regex) => regex.test(imported.module))) {
708
822
  for (const rule of settings.impureImports) {
709
823
  if (rule.regex.test(imported.module)) {
@@ -730,34 +844,31 @@ function scanGateSource(gatePath, source, settings) {
730
844
  });
731
845
  }
732
846
  }
733
- }
734
- return violations.sort((a, b) => a.line - b.line);
735
- }
736
- function resolveSpecifier(specifier, fromFile, aliases) {
737
- let joined = null;
738
- if (specifier.startsWith(".")) {
739
- const directory = fromFile.includes("/") ? fromFile.slice(0, fromFile.lastIndexOf("/")) : "";
740
- joined = directory === "" ? specifier : `${directory}/${specifier}`;
741
- } else {
742
- for (const alias of aliases) {
743
- if (specifier.startsWith(alias.prefix)) {
744
- joined = alias.target + specifier.slice(alias.prefix.length);
745
- break;
847
+ if (settings.importMode === "allowlist" && violations.length === before) {
848
+ const reason = isProjectImport(imported.module, settings.importAliases) ? judgeProjectImport(
849
+ resolveSpecifier(imported.module, gatePath, settings.importAliases),
850
+ settings.impurePaths,
851
+ settings.gateExtensions
852
+ ) : judgePackageImport(imported, settings.importAllow);
853
+ if (reason !== null) {
854
+ violations.push({
855
+ file: gatePath,
856
+ line: imported.line,
857
+ message: isProjectImport(imported.module, settings.importAliases) ? `runtime import of "${imported.module}" (${reason})` : reason
858
+ });
746
859
  }
747
860
  }
748
861
  }
749
- if (joined === null) {
750
- return null;
751
- }
752
- const segments = [];
753
- for (const segment of joined.split("/")) {
754
- if (segment === "..") {
755
- segments.pop();
756
- } else if (segment !== "." && segment !== "") {
757
- segments.push(segment);
862
+ if (settings.importMode === "allowlist") {
863
+ for (const line of findUncheckableImports(source)) {
864
+ violations.push({
865
+ file: gatePath,
866
+ line,
867
+ message: "require() / import() with a computed specifier cannot be checked (allowlist mode)"
868
+ });
758
869
  }
759
870
  }
760
- return segments.join("/").replace(SCRIPT_EXTENSION, "");
871
+ return violations.sort((a, b) => a.line - b.line);
761
872
  }
762
873
  function scanSpecSource(gatePath, specPath, source, settings) {
763
874
  const violations = [];
@@ -775,7 +886,7 @@ function scanSpecSource(gatePath, specPath, source, settings) {
775
886
  }
776
887
  }
777
888
  });
778
- const gateModule = gatePath.replace(SCRIPT_EXTENSION, "");
889
+ const gateModule = gatePath.replace(SCRIPT_EXTENSION2, "");
779
890
  const gateDirectory = gateModule.replace(/\/index$/, "");
780
891
  for (const match of source.matchAll(MODULE_MOCK)) {
781
892
  const specifier = match[2];
@@ -812,6 +923,21 @@ function scanStrykerConfig(configPath, source) {
812
923
  return violations;
813
924
  }
814
925
 
926
+ // src/core/vitest-coverage.ts
927
+ function vitestCoverageViolation(input) {
928
+ if (input.vitestVersion === null || input.vitestVersion === "") {
929
+ return null;
930
+ }
931
+ const major = Number(input.vitestVersion.split(".")[0]);
932
+ if (!Number.isInteger(major) || major >= 4) {
933
+ return null;
934
+ }
935
+ if (/\bistanbul\b/.test(input.configSource) || /\bexperimentalAstAwareRemapping\s*:\s*true\b/.test(input.configSource)) {
936
+ return null;
937
+ }
938
+ 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`;
939
+ }
940
+
815
941
  // src/commands/check.ts
816
942
  var STRYKER_CONFIG_NAMES = [
817
943
  "stryker.gates.config.mjs",
@@ -902,8 +1028,40 @@ function checkGates(manifest) {
902
1028
  violations.push(...scanStrykerConfig(strykerConfig, readText(dir, strykerConfig)));
903
1029
  }
904
1030
  }
1031
+ violations.push(...checkVitestCoverage(manifest));
905
1032
  return violations;
906
1033
  }
1034
+ var VITEST_GATE_CONFIGS = ["ts", "mts", "cts", "js", "mjs", "cjs"].map(
1035
+ (extension) => `vitest.gates.config.${extension}`
1036
+ );
1037
+ function installedVitestVersion(startDir) {
1038
+ let current = startDir;
1039
+ for (; ; ) {
1040
+ try {
1041
+ const pkg = JSON.parse(
1042
+ import_node_fs3.default.readFileSync(import_node_path3.default.join(current, "node_modules", "vitest", "package.json"), "utf8")
1043
+ );
1044
+ return typeof pkg.version === "string" ? pkg.version : null;
1045
+ } catch {
1046
+ const parent = import_node_path3.default.dirname(current);
1047
+ if (parent === current) {
1048
+ return null;
1049
+ }
1050
+ current = parent;
1051
+ }
1052
+ }
1053
+ }
1054
+ function checkVitestCoverage(manifest) {
1055
+ const config = VITEST_GATE_CONFIGS.find((name) => import_node_fs3.default.existsSync(import_node_path3.default.join(manifest.dir, name)));
1056
+ if (!config) {
1057
+ return [];
1058
+ }
1059
+ const message = vitestCoverageViolation({
1060
+ vitestVersion: installedVitestVersion(manifest.dir),
1061
+ configSource: readText(manifest.dir, config)
1062
+ });
1063
+ return message === null ? [] : [{ file: config, message }];
1064
+ }
907
1065
 
908
1066
  // src/core/mutation-result.ts
909
1067
  var DETECTED = /* @__PURE__ */ new Set(["Killed", "Timeout"]);
@@ -938,6 +1096,27 @@ function sliceByLocation(source, location) {
938
1096
  function keyOf(mutator, original, replacement) {
939
1097
  return JSON.stringify([mutator, normalizeCode(original), normalizeCode(replacement)]);
940
1098
  }
1099
+ function describeMutant(file, entry) {
1100
+ const { mutant } = entry;
1101
+ return {
1102
+ file,
1103
+ line: mutant.location.start.line,
1104
+ column: mutant.location.start.column,
1105
+ status: mutant.status,
1106
+ mutator: mutant.mutatorName,
1107
+ original: normalizeCode(entry.original),
1108
+ replacement: normalizeCode(entry.replacement),
1109
+ occurrence: entry.occurrence,
1110
+ sameKeyCount: entry.sameKeyCount,
1111
+ allowance: {
1112
+ mutator: mutant.mutatorName,
1113
+ original: normalizeCode(entry.original),
1114
+ replacement: normalizeCode(entry.replacement),
1115
+ ...entry.sameKeyCount > 1 ? { occurrence: entry.occurrence } : {},
1116
+ reason: ""
1117
+ }
1118
+ };
1119
+ }
941
1120
  function describeAllowance(entry) {
942
1121
  const occurrence = entry.occurrence === void 0 ? "" : ` / occurrence ${entry.occurrence}`;
943
1122
  return `${entry.mutator} / ${normalizeCode(entry.original)} \u2192 ${normalizeCode(entry.replacement)}${occurrence}`;
@@ -945,13 +1124,15 @@ function describeAllowance(entry) {
945
1124
  function judgeMutationReport(input) {
946
1125
  const violations = [];
947
1126
  const survivors = [];
1127
+ const unexpectedTimeouts = [];
948
1128
  const summary = {
949
1129
  total: 0,
950
1130
  detected: 0,
951
1131
  timeout: 0,
952
1132
  allowed: 0,
953
1133
  notEvaluable: 0,
954
- unallowed: 0
1134
+ unallowed: 0,
1135
+ expectedTimeout: 0
955
1136
  };
956
1137
  const files = /* @__PURE__ */ new Map();
957
1138
  for (const [file, result] of Object.entries(input.report.files ?? {})) {
@@ -961,7 +1142,9 @@ function judgeMutationReport(input) {
961
1142
  const gatePath = normalizePath(gate.path);
962
1143
  const shapeViolations = validateEquivalentMutants(gate.path, gate.equivalentMutants);
963
1144
  violations.push(...shapeViolations);
964
- const allowances = Array.isArray(gate.equivalentMutants) ? gate.equivalentMutants : [];
1145
+ violations.push(
1146
+ ...validateEquivalentMutants(gate.path, gate.expectedTimeouts, "expectedTimeouts")
1147
+ );
965
1148
  const result = files.get(gatePath);
966
1149
  if (!result || !Array.isArray(result.mutants) || result.mutants.length === 0) {
967
1150
  violations.push({
@@ -1025,38 +1208,53 @@ function judgeMutationReport(input) {
1025
1208
  if (!locationsValid) {
1026
1209
  continue;
1027
1210
  }
1028
- const allowed = /* @__PURE__ */ new Set();
1029
- allowances.forEach((allowance, index) => {
1030
- if (!isUsableEquivalentMutant(allowance)) {
1031
- return;
1211
+ const claim = (field, entries, noun, accept) => {
1212
+ const claimed = /* @__PURE__ */ new Set();
1213
+ if (!Array.isArray(entries)) {
1214
+ return claimed;
1032
1215
  }
1033
- const at = `${gate.path}: equivalentMutants[${index}] (${describeAllowance(allowance)})`;
1034
- const group = byKey.get(keyOf(allowance.mutator, allowance.original, allowance.replacement)) ?? [];
1035
- if (allowance.occurrence === void 0 && group.length > 1) {
1036
- violations.push({
1037
- file: MANIFEST_FILE,
1038
- message: `${at}: matches ${group.length} mutants in the file. Add "occurrence" (1-based) to pick one`
1039
- });
1040
- return;
1041
- }
1042
- const target = group[(allowance.occurrence ?? 1) - 1];
1043
- if (!target || !SURVIVED.has(target.mutant.status)) {
1044
- const why = target ? `the mutant is now ${target.mutant.status}` : "no such mutant in this run";
1045
- violations.push({
1046
- file: MANIFEST_FILE,
1047
- message: `${at}: stale allowance (${why}). Remove it`
1048
- });
1049
- return;
1050
- }
1051
- if (allowed.has(target)) {
1052
- violations.push({
1053
- file: MANIFEST_FILE,
1054
- message: `${at}: another allowance already covers this mutant`
1055
- });
1056
- return;
1057
- }
1058
- allowed.add(target);
1059
- });
1216
+ entries.forEach((entry, index) => {
1217
+ if (!isUsableEquivalentMutant(entry)) {
1218
+ return;
1219
+ }
1220
+ const at = `${gate.path}: ${field}[${index}] (${describeAllowance(entry)})`;
1221
+ const group = byKey.get(keyOf(entry.mutator, entry.original, entry.replacement)) ?? [];
1222
+ if (entry.occurrence === void 0 && group.length > 1) {
1223
+ violations.push({
1224
+ file: MANIFEST_FILE,
1225
+ message: `${at}: matches ${group.length} mutants in the file. Add "occurrence" (1-based) to pick one`
1226
+ });
1227
+ return;
1228
+ }
1229
+ const target = group[(entry.occurrence ?? 1) - 1];
1230
+ const refusal = target ? accept(target.mutant.status) : `stale ${noun} (no such mutant in this run). Remove it`;
1231
+ if (!target || refusal !== null) {
1232
+ violations.push({ file: MANIFEST_FILE, message: `${at}: ${refusal}` });
1233
+ return;
1234
+ }
1235
+ if (claimed.has(target)) {
1236
+ violations.push({
1237
+ file: MANIFEST_FILE,
1238
+ message: `${at}: another ${noun} already covers this mutant`
1239
+ });
1240
+ return;
1241
+ }
1242
+ claimed.add(target);
1243
+ });
1244
+ return claimed;
1245
+ };
1246
+ const allowed = claim(
1247
+ "equivalentMutants",
1248
+ gate.equivalentMutants,
1249
+ "allowance",
1250
+ (status) => SURVIVED.has(status) ? null : `stale allowance (the mutant is now ${status}). Remove it`
1251
+ );
1252
+ const expected = claim(
1253
+ "expectedTimeouts",
1254
+ gate.expectedTimeouts,
1255
+ "entry",
1256
+ (status) => SURVIVED.has(status) ? `the mutant ${status === "NoCoverage" ? "has no coverage" : "survived"} instead of timing out. Detect it with a test` : null
1257
+ );
1060
1258
  for (const entry of located) {
1061
1259
  const { mutant } = entry;
1062
1260
  summary.total += 1;
@@ -1064,6 +1262,11 @@ function judgeMutationReport(input) {
1064
1262
  summary.detected += 1;
1065
1263
  if (mutant.status === "Timeout") {
1066
1264
  summary.timeout += 1;
1265
+ if (expected.has(entry)) {
1266
+ summary.expectedTimeout += 1;
1267
+ } else {
1268
+ unexpectedTimeouts.push(describeMutant(gate.path, entry));
1269
+ }
1067
1270
  }
1068
1271
  } else if (NOT_EVALUABLE.has(mutant.status)) {
1069
1272
  summary.notEvaluable += 1;
@@ -1073,24 +1276,7 @@ function judgeMutationReport(input) {
1073
1276
  continue;
1074
1277
  }
1075
1278
  summary.unallowed += 1;
1076
- survivors.push({
1077
- file: gate.path,
1078
- line: mutant.location.start.line,
1079
- column: mutant.location.start.column,
1080
- status: mutant.status,
1081
- mutator: mutant.mutatorName,
1082
- original: normalizeCode(entry.original),
1083
- replacement: normalizeCode(entry.replacement),
1084
- occurrence: entry.occurrence,
1085
- sameKeyCount: entry.sameKeyCount,
1086
- allowance: {
1087
- mutator: mutant.mutatorName,
1088
- original: normalizeCode(entry.original),
1089
- replacement: normalizeCode(entry.replacement),
1090
- ...entry.sameKeyCount > 1 ? { occurrence: entry.occurrence } : {},
1091
- reason: ""
1092
- }
1093
- });
1279
+ survivors.push(describeMutant(gate.path, entry));
1094
1280
  } else {
1095
1281
  violations.push({
1096
1282
  file: gate.path,
@@ -1106,7 +1292,14 @@ function judgeMutationReport(input) {
1106
1292
  message: "no mutants were evaluated (the mutation test ran on nothing)"
1107
1293
  });
1108
1294
  }
1109
- return { violations, survivors, summary };
1295
+ const maxTimeouts = input.maxTimeouts ?? 0;
1296
+ if (unexpectedTimeouts.length > maxTimeouts) {
1297
+ violations.push({
1298
+ file: MANIFEST_FILE,
1299
+ message: `${unexpectedTimeouts.length} mutant(s) timed out outside expectedTimeouts; settings.mutation.maxTimeouts allows ${maxTimeouts}`
1300
+ });
1301
+ }
1302
+ return { violations, survivors, unexpectedTimeouts, summary };
1110
1303
  }
1111
1304
  function formatSurvivor(survivor) {
1112
1305
  const occurrence = survivor.sameKeyCount > 1 ? ` / occurrence ${survivor.occurrence} of ${survivor.sameKeyCount}` : "";
package/dist/index.d.cts CHANGED
@@ -12,6 +12,11 @@ interface Gate {
12
12
  decides: string;
13
13
  impact: string;
14
14
  equivalentMutants?: EquivalentMutant[];
15
+ /**
16
+ * Mutants that always time out (a loop that never ends). Same key as `equivalentMutants`.
17
+ * They are not counted against `settings.mutation.maxTimeouts`.
18
+ */
19
+ expectedTimeouts?: EquivalentMutant[];
15
20
  }
16
21
  interface Candidate {
17
22
  path: string;
@@ -75,6 +80,21 @@ interface Settings {
75
80
  file?: string;
76
81
  summaryExclude?: string[];
77
82
  };
83
+ /** Since 0.2.0. */
84
+ imports?: {
85
+ mode?: 'blocklist' | 'allowlist';
86
+ allow?: (string | {
87
+ pattern: string;
88
+ flags?: string;
89
+ } | {
90
+ module: string;
91
+ names: string[];
92
+ })[];
93
+ };
94
+ /** Since 0.2.0. */
95
+ mutation?: {
96
+ maxTimeouts?: number;
97
+ };
78
98
  }
79
99
  interface Manifest {
80
100
  $schema?: string;
@@ -108,6 +128,14 @@ interface ResolvedSettings {
108
128
  strykerReportFile: string;
109
129
  lcovFile: string | null;
110
130
  lcovSummaryExclude: RegExp[];
131
+ importMode: 'blocklist' | 'allowlist';
132
+ importAllow: {
133
+ module?: string;
134
+ regex?: RegExp;
135
+ names?: string[];
136
+ }[];
137
+ /** Timeouts tolerated outside `expectedTimeouts`. */
138
+ maxTimeouts: number;
111
139
  }
112
140
  /** One finding. Printed as `file:line: message`. */
113
141
  interface Violation {
@@ -244,10 +272,17 @@ interface MutationSummary {
244
272
  notEvaluable: number;
245
273
  /** Survived / NoCoverage mutants with no allowance. */
246
274
  unallowed: number;
275
+ /** The part of `timeout` that `expectedTimeouts` entries cover. */
276
+ expectedTimeout: number;
247
277
  }
248
278
  interface MutationVerdict {
249
279
  violations: Violation[];
250
280
  survivors: UnallowedSurvivor[];
281
+ /**
282
+ * Timed-out mutants that no `expectedTimeouts` entry covers. When there are more of them
283
+ * than `maxTimeouts`, `violations` says so.
284
+ */
285
+ unexpectedTimeouts: UnallowedSurvivor[];
251
286
  summary: MutationSummary;
252
287
  }
253
288
  /** Whitespace is not part of the key, so reformatting a gate does not invalidate an allowance. */
@@ -275,6 +310,11 @@ declare function judgeMutationReport(input: {
275
310
  gates: Gate[];
276
311
  report: MutationReport;
277
312
  readSource: (gatePath: string) => string | undefined;
313
+ /**
314
+ * `settings.mutation.maxTimeouts`: how many timeouts outside `expectedTimeouts` are
315
+ * tolerated. Absent means 0.
316
+ */
317
+ maxTimeouts?: number;
278
318
  }): MutationVerdict;
279
319
  /** `file:line:column / mutator / original → replacement / occurrence n of m`. */
280
320
  declare function formatSurvivor(survivor: UnallowedSurvivor): string;
package/dist/index.d.ts CHANGED
@@ -12,6 +12,11 @@ interface Gate {
12
12
  decides: string;
13
13
  impact: string;
14
14
  equivalentMutants?: EquivalentMutant[];
15
+ /**
16
+ * Mutants that always time out (a loop that never ends). Same key as `equivalentMutants`.
17
+ * They are not counted against `settings.mutation.maxTimeouts`.
18
+ */
19
+ expectedTimeouts?: EquivalentMutant[];
15
20
  }
16
21
  interface Candidate {
17
22
  path: string;
@@ -75,6 +80,21 @@ interface Settings {
75
80
  file?: string;
76
81
  summaryExclude?: string[];
77
82
  };
83
+ /** Since 0.2.0. */
84
+ imports?: {
85
+ mode?: 'blocklist' | 'allowlist';
86
+ allow?: (string | {
87
+ pattern: string;
88
+ flags?: string;
89
+ } | {
90
+ module: string;
91
+ names: string[];
92
+ })[];
93
+ };
94
+ /** Since 0.2.0. */
95
+ mutation?: {
96
+ maxTimeouts?: number;
97
+ };
78
98
  }
79
99
  interface Manifest {
80
100
  $schema?: string;
@@ -108,6 +128,14 @@ interface ResolvedSettings {
108
128
  strykerReportFile: string;
109
129
  lcovFile: string | null;
110
130
  lcovSummaryExclude: RegExp[];
131
+ importMode: 'blocklist' | 'allowlist';
132
+ importAllow: {
133
+ module?: string;
134
+ regex?: RegExp;
135
+ names?: string[];
136
+ }[];
137
+ /** Timeouts tolerated outside `expectedTimeouts`. */
138
+ maxTimeouts: number;
111
139
  }
112
140
  /** One finding. Printed as `file:line: message`. */
113
141
  interface Violation {
@@ -244,10 +272,17 @@ interface MutationSummary {
244
272
  notEvaluable: number;
245
273
  /** Survived / NoCoverage mutants with no allowance. */
246
274
  unallowed: number;
275
+ /** The part of `timeout` that `expectedTimeouts` entries cover. */
276
+ expectedTimeout: number;
247
277
  }
248
278
  interface MutationVerdict {
249
279
  violations: Violation[];
250
280
  survivors: UnallowedSurvivor[];
281
+ /**
282
+ * Timed-out mutants that no `expectedTimeouts` entry covers. When there are more of them
283
+ * than `maxTimeouts`, `violations` says so.
284
+ */
285
+ unexpectedTimeouts: UnallowedSurvivor[];
251
286
  summary: MutationSummary;
252
287
  }
253
288
  /** Whitespace is not part of the key, so reformatting a gate does not invalidate an allowance. */
@@ -275,6 +310,11 @@ declare function judgeMutationReport(input: {
275
310
  gates: Gate[];
276
311
  report: MutationReport;
277
312
  readSource: (gatePath: string) => string | undefined;
313
+ /**
314
+ * `settings.mutation.maxTimeouts`: how many timeouts outside `expectedTimeouts` are
315
+ * tolerated. Absent means 0.
316
+ */
317
+ maxTimeouts?: number;
278
318
  }): MutationVerdict;
279
319
  /** `file:line:column / mutator / original → replacement / occurrence n of m`. */
280
320
  declare function formatSurvivor(survivor: UnallowedSurvivor): string;