@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/CHANGELOG.md +40 -0
- package/README.md +194 -20
- package/dist/{chunk-LMD3ANJ2.js → chunk-F7WBEFIL.js} +53 -4
- package/dist/cli.js +368 -133
- package/dist/index.cjs +274 -81
- package/dist/index.d.cts +40 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +222 -78
- package/dist/jest.cjs +53 -4
- package/dist/jest.js +1 -1
- package/dist/stryker.cjs +53 -4
- package/dist/stryker.js +1 -1
- package/dist/vitest.cjs +55 -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 +165 -28
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 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
|
-
|
|
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
|
|
1261
|
-
|
|
1262
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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, " "));
|