@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/CHANGELOG.md +26 -0
- package/README.md +191 -19
- package/dist/{chunk-LMD3ANJ2.js → chunk-XEKQKLKY.js} +48 -4
- package/dist/cli.js +364 -133
- package/dist/index.cjs +225 -81
- package/dist/index.d.cts +40 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +178 -78
- package/dist/jest.cjs +48 -4
- package/dist/jest.js +1 -1
- package/dist/stryker.cjs +48 -4
- package/dist/stryker.js +1 -1
- package/dist/vitest.cjs +50 -5
- package/dist/vitest.d.cts +6 -0
- package/dist/vitest.d.ts +6 -0
- package/dist/vitest.js +3 -2
- package/package.json +9 -3
- package/schema/test-gates.schema.json +164 -27
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
|
-
|
|
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}:
|
|
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}:
|
|
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
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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 (
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
1261
|
-
|
|
1262
|
-
if (!
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
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, " "));
|