@t09tanaka/test-gates 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +364 -2
- package/dist/chunk-4HAJIKDZ.js +18 -0
- package/dist/chunk-LMD3ANJ2.js +564 -0
- package/dist/cli.js +1607 -0
- package/dist/index.cjs +1233 -0
- package/dist/index.d.cts +337 -0
- package/dist/index.d.ts +337 -0
- package/dist/index.js +659 -0
- package/dist/jest.cjs +591 -0
- package/dist/jest.d.cts +20 -0
- package/dist/jest.d.ts +20 -0
- package/dist/jest.js +39 -0
- package/dist/stryker.cjs +590 -0
- package/dist/stryker.d.cts +16 -0
- package/dist/stryker.d.ts +16 -0
- package/dist/stryker.js +44 -0
- package/dist/vitest.cjs +594 -0
- package/dist/vitest.d.cts +20 -0
- package/dist/vitest.d.ts +20 -0
- package/dist/vitest.js +42 -0
- package/package.json +132 -4
- package/schema/test-gates.schema.json +272 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1607 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
|
|
6
|
+
// src/core/args.ts
|
|
7
|
+
var COMMANDS = ["check", "mutation", "mutation-result", "selfcheck", "lcov"];
|
|
8
|
+
var UsageError = class extends Error {
|
|
9
|
+
};
|
|
10
|
+
var VALUE_OPTIONS = ["--dir", "--report", "--file"];
|
|
11
|
+
var OPTIONS_BY_COMMAND = {
|
|
12
|
+
check: ["--dir"],
|
|
13
|
+
mutation: ["--dir"],
|
|
14
|
+
"mutation-result": ["--dir", "--report"],
|
|
15
|
+
selfcheck: ["--dir", "--all", "--first"],
|
|
16
|
+
lcov: ["--dir", "--file"]
|
|
17
|
+
};
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const parsed = {
|
|
20
|
+
command: null,
|
|
21
|
+
help: false,
|
|
22
|
+
version: false,
|
|
23
|
+
dir: ".",
|
|
24
|
+
report: null,
|
|
25
|
+
file: null,
|
|
26
|
+
mode: null,
|
|
27
|
+
rest: []
|
|
28
|
+
};
|
|
29
|
+
let index = 0;
|
|
30
|
+
const first = argv[0];
|
|
31
|
+
if (first !== void 0 && !first.startsWith("-")) {
|
|
32
|
+
if (!COMMANDS.includes(first)) {
|
|
33
|
+
throw new UsageError(`unknown command: ${first}`);
|
|
34
|
+
}
|
|
35
|
+
parsed.command = first;
|
|
36
|
+
index = 1;
|
|
37
|
+
}
|
|
38
|
+
const allowed = parsed.command ? OPTIONS_BY_COMMAND[parsed.command] : void 0;
|
|
39
|
+
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
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (parsed.command === "selfcheck" && parsed.rest.length === 0 && argv.includes("--")) {
|
|
94
|
+
throw new UsageError('"--" must be followed by the gate command');
|
|
95
|
+
}
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/core/manifest.ts
|
|
100
|
+
var MANIFEST_FILE = "test-gates.json";
|
|
101
|
+
var ManifestError = class extends Error {
|
|
102
|
+
};
|
|
103
|
+
function isRecord(value) {
|
|
104
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
105
|
+
}
|
|
106
|
+
function isFilled(value) {
|
|
107
|
+
return typeof value === "string" && value.trim() !== "";
|
|
108
|
+
}
|
|
109
|
+
function allowanceProblems(entry) {
|
|
110
|
+
if (!isRecord(entry)) {
|
|
111
|
+
return [" must be an object"];
|
|
112
|
+
}
|
|
113
|
+
const problems = [];
|
|
114
|
+
if (!isFilled(entry.mutator)) {
|
|
115
|
+
problems.push(': "mutator" is missing');
|
|
116
|
+
}
|
|
117
|
+
if (!isFilled(entry.original)) {
|
|
118
|
+
problems.push(': "original" is missing');
|
|
119
|
+
}
|
|
120
|
+
if (typeof entry.replacement !== "string") {
|
|
121
|
+
problems.push(': "replacement" is missing');
|
|
122
|
+
}
|
|
123
|
+
if (!isFilled(entry.reason)) {
|
|
124
|
+
problems.push(': "reason" is missing (say why the mutant cannot be observed)');
|
|
125
|
+
}
|
|
126
|
+
if (entry.occurrence !== void 0 && !(Number.isInteger(entry.occurrence) && entry.occurrence >= 1)) {
|
|
127
|
+
problems.push(': "occurrence" must be an integer >= 1');
|
|
128
|
+
}
|
|
129
|
+
return problems;
|
|
130
|
+
}
|
|
131
|
+
function validateEquivalentMutants(gatePath, value) {
|
|
132
|
+
if (value === void 0) {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
if (!Array.isArray(value)) {
|
|
136
|
+
return [{ file: MANIFEST_FILE, message: `${gatePath}: equivalentMutants must be an array` }];
|
|
137
|
+
}
|
|
138
|
+
return value.flatMap(
|
|
139
|
+
(entry, index) => allowanceProblems(entry).map((problem) => ({
|
|
140
|
+
file: MANIFEST_FILE,
|
|
141
|
+
message: `${gatePath}: equivalentMutants[${index}]${problem}`
|
|
142
|
+
}))
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
function isUsableEquivalentMutant(entry) {
|
|
146
|
+
return allowanceProblems(entry).length === 0;
|
|
147
|
+
}
|
|
148
|
+
function parseEntries(kind, entries, fields, violations) {
|
|
149
|
+
const usable = [];
|
|
150
|
+
const seen = /* @__PURE__ */ new Set();
|
|
151
|
+
entries.forEach((entry, index) => {
|
|
152
|
+
const at = `${kind}[${index}]`;
|
|
153
|
+
if (!isRecord(entry)) {
|
|
154
|
+
violations.push({ file: MANIFEST_FILE, message: `${at} must be an object` });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!isFilled(entry.path)) {
|
|
158
|
+
violations.push({ file: MANIFEST_FILE, message: `${at}: "path" is missing` });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
for (const field of fields) {
|
|
162
|
+
if (!isFilled(entry[field])) {
|
|
163
|
+
violations.push({
|
|
164
|
+
file: MANIFEST_FILE,
|
|
165
|
+
message: `${entry.path}: "${field}" is missing in ${kind}`
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (seen.has(entry.path)) {
|
|
170
|
+
violations.push({ file: MANIFEST_FILE, message: `${entry.path}: listed twice in ${kind}` });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
seen.add(entry.path);
|
|
174
|
+
usable.push(entry);
|
|
175
|
+
});
|
|
176
|
+
return usable;
|
|
177
|
+
}
|
|
178
|
+
function parseManifest(text) {
|
|
179
|
+
let raw;
|
|
180
|
+
try {
|
|
181
|
+
raw = JSON.parse(text);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
throw new ManifestError(`${MANIFEST_FILE} is not valid JSON: ${error.message}`);
|
|
184
|
+
}
|
|
185
|
+
if (!isRecord(raw)) {
|
|
186
|
+
throw new ManifestError(`${MANIFEST_FILE} must be a JSON object`);
|
|
187
|
+
}
|
|
188
|
+
if (!Array.isArray(raw.gates)) {
|
|
189
|
+
throw new ManifestError(`${MANIFEST_FILE}: "gates" must be an array`);
|
|
190
|
+
}
|
|
191
|
+
if (raw.candidates !== void 0 && !Array.isArray(raw.candidates)) {
|
|
192
|
+
throw new ManifestError(`${MANIFEST_FILE}: "candidates" must be an array`);
|
|
193
|
+
}
|
|
194
|
+
for (const key of Object.keys(raw)) {
|
|
195
|
+
if (!["$schema", "gates", "candidates", "settings"].includes(key)) {
|
|
196
|
+
throw new ManifestError(`${MANIFEST_FILE}: unknown top-level key ${JSON.stringify(key)}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const violations = [];
|
|
200
|
+
if (raw.gates.length === 0) {
|
|
201
|
+
violations.push({
|
|
202
|
+
file: MANIFEST_FILE,
|
|
203
|
+
message: "gates is empty. A project with nothing to gate should not have this file"
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const gates = parseEntries("gates", raw.gates, ["decides", "impact"], violations);
|
|
207
|
+
const candidates = parseEntries(
|
|
208
|
+
"candidates",
|
|
209
|
+
raw.candidates ?? [],
|
|
210
|
+
["decides", "blocker"],
|
|
211
|
+
violations
|
|
212
|
+
);
|
|
213
|
+
const gatePaths = new Set(gates.map((gate) => gate.path));
|
|
214
|
+
for (const candidate of candidates) {
|
|
215
|
+
if (gatePaths.has(candidate.path)) {
|
|
216
|
+
violations.push({
|
|
217
|
+
file: MANIFEST_FILE,
|
|
218
|
+
message: `${candidate.path}: listed in both gates and candidates`
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const gate of gates) {
|
|
223
|
+
violations.push(...validateEquivalentMutants(gate.path, gate.equivalentMutants));
|
|
224
|
+
}
|
|
225
|
+
return { gates, candidates, settings: raw.settings, violations };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/commands/check.ts
|
|
229
|
+
import fs3 from "fs";
|
|
230
|
+
import path3 from "path";
|
|
231
|
+
|
|
232
|
+
// src/core/imports.ts
|
|
233
|
+
var FROM_STATEMENT = /(?:^|[\n;])[ \t]*(import|export)\s+(type\s+)?([\w$\s,{}*]*?)\s*\bfrom\s*(['"])([^'"\n]+)\4/g;
|
|
234
|
+
var SIDE_EFFECT_IMPORT = /(?:^|[\n;])[ \t]*import\s*(['"])([^'"\n]+)\1/g;
|
|
235
|
+
var CALL_IMPORT = /\b(?:require|import)\s*\(\s*(['"`])([^'"`\n]+)\1\s*\)/g;
|
|
236
|
+
function lineAt(source, index) {
|
|
237
|
+
let line = 1;
|
|
238
|
+
for (let i = 0; i < index; i += 1) {
|
|
239
|
+
if (source[i] === "\n") {
|
|
240
|
+
line += 1;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return line;
|
|
244
|
+
}
|
|
245
|
+
function parseImports(source) {
|
|
246
|
+
const found = [];
|
|
247
|
+
for (const match of source.matchAll(FROM_STATEMENT)) {
|
|
248
|
+
const clause = match[3];
|
|
249
|
+
const module = match[5];
|
|
250
|
+
const braces = /\{([^}]*)\}/.exec(clause);
|
|
251
|
+
const names = braces ? braces[1].split(",").map((part) => part.trim()).filter((part) => part !== "" && !/^type\s/.test(part)).map((part) => part.split(/\s+as\s+/)[0].trim()) : [];
|
|
252
|
+
const outsideBraces = clause.replace(/\{[^}]*\}/, "").replace(/,/g, "").trim();
|
|
253
|
+
found.push({
|
|
254
|
+
module,
|
|
255
|
+
line: lineAt(source, match.index + match[0].lastIndexOf(module)),
|
|
256
|
+
typeOnly: match[2] !== void 0,
|
|
257
|
+
names,
|
|
258
|
+
whole: outsideBraces !== ""
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
for (const match of source.matchAll(SIDE_EFFECT_IMPORT)) {
|
|
262
|
+
const module = match[2];
|
|
263
|
+
found.push({
|
|
264
|
+
module,
|
|
265
|
+
line: lineAt(source, match.index + match[0].lastIndexOf(module)),
|
|
266
|
+
typeOnly: false,
|
|
267
|
+
names: [],
|
|
268
|
+
whole: false
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
for (const match of source.matchAll(CALL_IMPORT)) {
|
|
272
|
+
const module = match[2];
|
|
273
|
+
found.push({
|
|
274
|
+
module,
|
|
275
|
+
line: lineAt(source, match.index + match[0].lastIndexOf(module)),
|
|
276
|
+
typeOnly: false,
|
|
277
|
+
names: [],
|
|
278
|
+
whole: true
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return found.sort((a, b) => a.line - b.line);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/core/scan.ts
|
|
285
|
+
var IGNORE_DIRECTIVE = /\b(istanbul|c8|v8)\s+ignore\b/;
|
|
286
|
+
var DECORATOR = /^\s*@[A-Za-z_$][\w$.]*\s*\(/;
|
|
287
|
+
var STRYKER_COMMENT = /Stryker\s+(disable|restore)\b/;
|
|
288
|
+
var DISABLED_TESTS = [
|
|
289
|
+
{ regex: /\.\s*(skip|only|todo)\b/, label: ".skip / .only / .todo" },
|
|
290
|
+
{
|
|
291
|
+
regex: /\b(?:describe|it|test|suite)(?:\s*\.\s*\w+)*\s*\.\s*(?:skipIf|runIf|fails)\b/,
|
|
292
|
+
label: ".skipIf / .runIf / .fails"
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
regex: /\b(xit|xtest|xdescribe|fit|fdescribe)\s*[.(]/,
|
|
296
|
+
label: "xit / xtest / xdescribe / fit / fdescribe"
|
|
297
|
+
}
|
|
298
|
+
];
|
|
299
|
+
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?$/;
|
|
301
|
+
function eachLine(source, visit) {
|
|
302
|
+
source.split("\n").forEach((line, index) => visit(line, index + 1));
|
|
303
|
+
}
|
|
304
|
+
function escapeRegExp(text) {
|
|
305
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
306
|
+
}
|
|
307
|
+
function scanGateSource(gatePath, source, settings) {
|
|
308
|
+
const violations = [];
|
|
309
|
+
eachLine(source, (line, lineNumber) => {
|
|
310
|
+
if (DECORATOR.test(line)) {
|
|
311
|
+
violations.push({
|
|
312
|
+
file: gatePath,
|
|
313
|
+
line: lineNumber,
|
|
314
|
+
message: "decorator found (not a pure module). Move the file to candidates"
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
if (IGNORE_DIRECTIVE.test(line)) {
|
|
318
|
+
violations.push({ file: gatePath, line: lineNumber, message: "coverage ignore directive" });
|
|
319
|
+
}
|
|
320
|
+
if (STRYKER_COMMENT.test(line)) {
|
|
321
|
+
violations.push({
|
|
322
|
+
file: gatePath,
|
|
323
|
+
line: lineNumber,
|
|
324
|
+
message: "Stryker comment (disable / restore). Allow an equivalent mutant in test-gates.json (equivalentMutants) instead"
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
for (const rule of settings.forbiddenSource) {
|
|
328
|
+
if (rule.regex.test(line)) {
|
|
329
|
+
violations.push({ file: gatePath, line: lineNumber, message: rule.reason });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
for (const imported of parseImports(source)) {
|
|
334
|
+
if (imported.typeOnly) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (!settings.allowedImports.some((regex) => regex.test(imported.module))) {
|
|
338
|
+
for (const rule of settings.impureImports) {
|
|
339
|
+
if (rule.regex.test(imported.module)) {
|
|
340
|
+
violations.push({
|
|
341
|
+
file: gatePath,
|
|
342
|
+
line: imported.line,
|
|
343
|
+
message: `runtime import of "${imported.module}" (${rule.reason})`
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for (const rule of settings.impureNamedImports) {
|
|
349
|
+
if (imported.module !== rule.module) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const hit = rule.names.filter(
|
|
353
|
+
(name) => imported.names.includes(name) || imported.whole && new RegExp(`\\b${escapeRegExp(name)}\\b`).test(source)
|
|
354
|
+
);
|
|
355
|
+
if (hit.length > 0) {
|
|
356
|
+
violations.push({
|
|
357
|
+
file: gatePath,
|
|
358
|
+
line: imported.line,
|
|
359
|
+
message: `runtime use of ${hit.join(", ")} from "${rule.module}" (${rule.reason})`
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
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;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
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);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return segments.join("/").replace(SCRIPT_EXTENSION, "");
|
|
391
|
+
}
|
|
392
|
+
function scanSpecSource(gatePath, specPath, source, settings) {
|
|
393
|
+
const violations = [];
|
|
394
|
+
eachLine(source, (line, lineNumber) => {
|
|
395
|
+
if (IGNORE_DIRECTIVE.test(line)) {
|
|
396
|
+
violations.push({ file: specPath, line: lineNumber, message: "coverage ignore directive" });
|
|
397
|
+
}
|
|
398
|
+
for (const { regex, label } of DISABLED_TESTS) {
|
|
399
|
+
if (regex.test(line)) {
|
|
400
|
+
violations.push({
|
|
401
|
+
file: specPath,
|
|
402
|
+
line: lineNumber,
|
|
403
|
+
message: `${label} (a skipped or focused test)`
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
const gateModule = gatePath.replace(SCRIPT_EXTENSION, "");
|
|
409
|
+
const gateDirectory = gateModule.replace(/\/index$/, "");
|
|
410
|
+
for (const match of source.matchAll(MODULE_MOCK)) {
|
|
411
|
+
const specifier = match[2];
|
|
412
|
+
const resolved = resolveSpecifier(specifier, specPath, settings.importAliases);
|
|
413
|
+
if (resolved === gateModule || resolved === gateDirectory) {
|
|
414
|
+
let line = 1;
|
|
415
|
+
for (let i = 0; i < match.index; i += 1) {
|
|
416
|
+
if (source[i] === "\n") {
|
|
417
|
+
line += 1;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
violations.push({
|
|
421
|
+
file: specPath,
|
|
422
|
+
line,
|
|
423
|
+
message: `mocks the module under test ("${specifier}")`
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return violations.sort((a, b) => a.line - b.line);
|
|
428
|
+
}
|
|
429
|
+
var STRYKER_WEAKENING = /\b(excludedMutations|ignoreStatic|ignorers)\b/;
|
|
430
|
+
function scanStrykerConfig(configPath, source) {
|
|
431
|
+
const violations = [];
|
|
432
|
+
eachLine(source, (line, lineNumber) => {
|
|
433
|
+
const match = STRYKER_WEAKENING.exec(line);
|
|
434
|
+
if (match) {
|
|
435
|
+
violations.push({
|
|
436
|
+
file: configPath,
|
|
437
|
+
line: lineNumber,
|
|
438
|
+
message: `${match[1]} takes mutants out of the evaluation. Allow equivalent mutants one by one in test-gates.json instead`
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
return violations;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/core/spec-path.ts
|
|
446
|
+
function gateExtensionOf(gatePath, gateExtensions) {
|
|
447
|
+
const matching = gateExtensions.filter((extension) => gatePath.endsWith(extension)).sort((a, b) => b.length - a.length);
|
|
448
|
+
return matching[0] ?? null;
|
|
449
|
+
}
|
|
450
|
+
function suffixesFor(extension, settings) {
|
|
451
|
+
return settings.specSuffixes ?? [`.spec${extension}`, `.test${extension}`];
|
|
452
|
+
}
|
|
453
|
+
function specCandidatesFor(gatePath, settings) {
|
|
454
|
+
const extension = gateExtensionOf(gatePath, settings.gateExtensions);
|
|
455
|
+
if (extension === null) {
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
let base = gatePath.slice(0, -extension.length);
|
|
459
|
+
for (const { regex, to } of settings.specRewrite) {
|
|
460
|
+
base = base.replace(regex, to);
|
|
461
|
+
}
|
|
462
|
+
return suffixesFor(extension, settings).map((suffix) => base + suffix);
|
|
463
|
+
}
|
|
464
|
+
function looksLikeSpec(gatePath, settings) {
|
|
465
|
+
const extension = gateExtensionOf(gatePath, settings.gateExtensions);
|
|
466
|
+
return extension !== null && suffixesFor(extension, settings).some((suffix) => gatePath.endsWith(suffix));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/fs.ts
|
|
470
|
+
import fs from "fs";
|
|
471
|
+
import path from "path";
|
|
472
|
+
function existsExact(rootDir, relativePath, kind = "file") {
|
|
473
|
+
let current = rootDir;
|
|
474
|
+
for (const segment of relativePath.split("/")) {
|
|
475
|
+
if (segment === "" || segment === ".") {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
let entries;
|
|
479
|
+
try {
|
|
480
|
+
entries = fs.readdirSync(current);
|
|
481
|
+
} catch {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
if (!entries.includes(segment)) {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
current = path.join(current, segment);
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
return kind === "any" || fs.statSync(current).isFile();
|
|
491
|
+
} catch {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function readText(rootDir, relativePath) {
|
|
496
|
+
return fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
|
497
|
+
}
|
|
498
|
+
function findLocalBin(startDir, name) {
|
|
499
|
+
const file = process.platform === "win32" ? `${name}.cmd` : name;
|
|
500
|
+
let current = path.resolve(startDir);
|
|
501
|
+
for (; ; ) {
|
|
502
|
+
const candidate = path.join(current, "node_modules", ".bin", file);
|
|
503
|
+
if (fs.existsSync(candidate)) {
|
|
504
|
+
return candidate;
|
|
505
|
+
}
|
|
506
|
+
const parent = path.dirname(current);
|
|
507
|
+
if (parent === current) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
current = parent;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// src/load.ts
|
|
515
|
+
import fs2 from "fs";
|
|
516
|
+
import path2 from "path";
|
|
517
|
+
|
|
518
|
+
// src/core/settings.ts
|
|
519
|
+
var DEFAULT_REPORT_FILE = "reports/mutation/mutation.json";
|
|
520
|
+
var DEFAULT_GATE_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"];
|
|
521
|
+
var DEFAULT_IMPURE_IMPORTS = [
|
|
522
|
+
{ pattern: "^@nestjs/", reason: "NestJS runtime (DI, decorators)" },
|
|
523
|
+
{ pattern: "^class-(validator|transformer)$", reason: "decorator-based DTO library" },
|
|
524
|
+
{
|
|
525
|
+
pattern: "^(typeorm|sequelize|mongoose|knex|pg|mysql|mysql2|ioredis|redis)(/|$)",
|
|
526
|
+
reason: "database client"
|
|
527
|
+
},
|
|
528
|
+
{ pattern: "^@prisma/adapter-", reason: "database client" },
|
|
529
|
+
{ pattern: "^(react|react-dom)(/|$)", reason: "React runtime" },
|
|
530
|
+
{ pattern: "^next(/|$)", reason: "Next.js runtime" },
|
|
531
|
+
{ pattern: "^(server-only|client-only)$", reason: "Next.js execution environment" },
|
|
532
|
+
{ pattern: "^(vue|vue-router|vue-i18n|pinia)(/|$)", reason: "Vue runtime" },
|
|
533
|
+
{ pattern: "^nuxt(/|$)", reason: "Nuxt runtime" },
|
|
534
|
+
{ pattern: "^#(app|imports)(/|$)", reason: "Nuxt runtime" },
|
|
535
|
+
{ pattern: "\\.vue$", reason: "component" },
|
|
536
|
+
{
|
|
537
|
+
pattern: "^(express|express-jwt|fastify|koa|rxjs|passport)(/|$)",
|
|
538
|
+
reason: "HTTP framework runtime"
|
|
539
|
+
},
|
|
540
|
+
{ pattern: "^passport-", reason: "HTTP framework runtime" },
|
|
541
|
+
{
|
|
542
|
+
pattern: "^(axios|openapi-fetch|stripe|nodemailer|firebase-admin)(/|$)",
|
|
543
|
+
reason: "HTTP client / external service SDK"
|
|
544
|
+
},
|
|
545
|
+
{ pattern: "^@(aws-sdk|sentry|slack|stripe)/", reason: "external service SDK" },
|
|
546
|
+
{
|
|
547
|
+
pattern: "^(node:)?(fs|http|https|http2|net|tls|dgram|dns|child_process|worker_threads)(/|$)",
|
|
548
|
+
reason: "file system / network / process access"
|
|
549
|
+
}
|
|
550
|
+
];
|
|
551
|
+
var DEFAULT_IMPURE_NAMED_IMPORTS = [
|
|
552
|
+
{ module: "@prisma/client", names: ["PrismaClient"], reason: "connects to the database" }
|
|
553
|
+
];
|
|
554
|
+
var DEFAULT_IMPURE_PATHS = [
|
|
555
|
+
{ pattern: "\\.(vue|jsx|tsx)$", reason: "component file" },
|
|
556
|
+
{ pattern: "\\.d\\.[cm]?ts$", reason: "type declaration file" }
|
|
557
|
+
];
|
|
558
|
+
var DEFAULT_FORBIDDEN_SOURCE = [
|
|
559
|
+
{
|
|
560
|
+
pattern: `^\\s*(['"])use (client|server)\\1`,
|
|
561
|
+
reason: "'use client' / 'use server' directive (not a pure module)"
|
|
562
|
+
}
|
|
563
|
+
];
|
|
564
|
+
var SettingsError = class extends Error {
|
|
565
|
+
};
|
|
566
|
+
function isRecord2(value) {
|
|
567
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
568
|
+
}
|
|
569
|
+
function compile(pattern, flags, where) {
|
|
570
|
+
try {
|
|
571
|
+
return new RegExp(pattern, flags);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
throw new SettingsError(
|
|
574
|
+
`${where}: invalid regular expression ${JSON.stringify(pattern)} (${error.message})`
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
function stringArray(value, where) {
|
|
579
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item === "")) {
|
|
580
|
+
throw new SettingsError(`${where}: must be an array of non-empty strings`);
|
|
581
|
+
}
|
|
582
|
+
return value;
|
|
583
|
+
}
|
|
584
|
+
function optionalString(value, where) {
|
|
585
|
+
if (value === void 0) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
if (typeof value !== "string" || value === "") {
|
|
589
|
+
throw new SettingsError(`${where}: must be a non-empty string`);
|
|
590
|
+
}
|
|
591
|
+
return value;
|
|
592
|
+
}
|
|
593
|
+
function rejectUnknownKeys(value, known, where) {
|
|
594
|
+
for (const key of Object.keys(value)) {
|
|
595
|
+
if (!known.includes(key)) {
|
|
596
|
+
throw new SettingsError(`${where}: unknown key ${JSON.stringify(key)}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function section(value, known, where) {
|
|
601
|
+
if (value === void 0) {
|
|
602
|
+
return {};
|
|
603
|
+
}
|
|
604
|
+
if (!isRecord2(value)) {
|
|
605
|
+
throw new SettingsError(`${where}: must be an object`);
|
|
606
|
+
}
|
|
607
|
+
rejectUnknownKeys(value, known, where);
|
|
608
|
+
return value;
|
|
609
|
+
}
|
|
610
|
+
function compileRules(defaults, raw, where, extraKeys = []) {
|
|
611
|
+
const config = section(raw, ["defaults", "add", ...extraKeys], where);
|
|
612
|
+
if (config.defaults !== void 0 && typeof config.defaults !== "boolean") {
|
|
613
|
+
throw new SettingsError(`${where}.defaults: must be a boolean`);
|
|
614
|
+
}
|
|
615
|
+
const added = config.add ?? [];
|
|
616
|
+
if (!Array.isArray(added)) {
|
|
617
|
+
throw new SettingsError(`${where}.add: must be an array`);
|
|
618
|
+
}
|
|
619
|
+
const all = [];
|
|
620
|
+
if (config.defaults !== false) {
|
|
621
|
+
defaults.forEach((rule) => all.push({ rule, where: `${where} (default)` }));
|
|
622
|
+
}
|
|
623
|
+
added.forEach((item, index) => {
|
|
624
|
+
const at = `${where}.add[${index}]`;
|
|
625
|
+
if (typeof item === "string" && item !== "") {
|
|
626
|
+
all.push({ rule: { pattern: item }, where: at });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (!isRecord2(item) || typeof item.pattern !== "string" || item.pattern === "") {
|
|
630
|
+
throw new SettingsError(`${at}: must be a pattern string or { pattern, flags?, reason? }`);
|
|
631
|
+
}
|
|
632
|
+
rejectUnknownKeys(item, ["pattern", "flags", "reason"], at);
|
|
633
|
+
if (item.flags !== void 0 && typeof item.flags !== "string") {
|
|
634
|
+
throw new SettingsError(`${at}.flags: must be a string`);
|
|
635
|
+
}
|
|
636
|
+
if (item.reason !== void 0 && typeof item.reason !== "string") {
|
|
637
|
+
throw new SettingsError(`${at}.reason: must be a string`);
|
|
638
|
+
}
|
|
639
|
+
all.push({ rule: item, where: at });
|
|
640
|
+
});
|
|
641
|
+
return {
|
|
642
|
+
rules: all.map(({ rule, where: at }) => ({
|
|
643
|
+
regex: compile(rule.pattern, rule.flags, at),
|
|
644
|
+
reason: rule.reason ?? `matches ${rule.pattern}`
|
|
645
|
+
})),
|
|
646
|
+
section: config
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function resolveSettings(raw) {
|
|
650
|
+
const settings = section(
|
|
651
|
+
raw,
|
|
652
|
+
[
|
|
653
|
+
"gateExtensions",
|
|
654
|
+
"spec",
|
|
655
|
+
"impureImports",
|
|
656
|
+
"impureNamedImports",
|
|
657
|
+
"impurePaths",
|
|
658
|
+
"forbiddenSource",
|
|
659
|
+
"importAliases",
|
|
660
|
+
"gateCommand",
|
|
661
|
+
"selfcheck",
|
|
662
|
+
"stryker",
|
|
663
|
+
"lcov"
|
|
664
|
+
],
|
|
665
|
+
"settings"
|
|
666
|
+
);
|
|
667
|
+
const gateExtensions = settings.gateExtensions === void 0 ? DEFAULT_GATE_EXTENSIONS : stringArray(settings.gateExtensions, "settings.gateExtensions");
|
|
668
|
+
if (gateExtensions.length === 0 || gateExtensions.some((ext) => !ext.startsWith("."))) {
|
|
669
|
+
throw new SettingsError(
|
|
670
|
+
'settings.gateExtensions: must list at least one extension starting with "."'
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
const spec = section(settings.spec, ["suffixes", "rewrite"], "settings.spec");
|
|
674
|
+
const specSuffixes = spec.suffixes === void 0 ? null : stringArray(spec.suffixes, "settings.spec.suffixes");
|
|
675
|
+
const rewriteRaw = spec.rewrite ?? [];
|
|
676
|
+
if (!Array.isArray(rewriteRaw)) {
|
|
677
|
+
throw new SettingsError("settings.spec.rewrite: must be an array");
|
|
678
|
+
}
|
|
679
|
+
const specRewrite = rewriteRaw.map((item, index) => {
|
|
680
|
+
const at = `settings.spec.rewrite[${index}]`;
|
|
681
|
+
if (!isRecord2(item) || typeof item.from !== "string" || typeof item.to !== "string") {
|
|
682
|
+
throw new SettingsError(`${at}: must be { from, to }`);
|
|
683
|
+
}
|
|
684
|
+
rejectUnknownKeys(item, ["from", "to"], at);
|
|
685
|
+
return { regex: compile(item.from, void 0, at), to: item.to };
|
|
686
|
+
});
|
|
687
|
+
const imports = compileRules(
|
|
688
|
+
DEFAULT_IMPURE_IMPORTS,
|
|
689
|
+
settings.impureImports,
|
|
690
|
+
"settings.impureImports",
|
|
691
|
+
["allow"]
|
|
692
|
+
);
|
|
693
|
+
const allowedImports = imports.section.allow === void 0 ? [] : stringArray(imports.section.allow, "settings.impureImports.allow").map(
|
|
694
|
+
(pattern, index) => compile(pattern, void 0, `settings.impureImports.allow[${index}]`)
|
|
695
|
+
);
|
|
696
|
+
const named = section(
|
|
697
|
+
settings.impureNamedImports,
|
|
698
|
+
["defaults", "add"],
|
|
699
|
+
"settings.impureNamedImports"
|
|
700
|
+
);
|
|
701
|
+
if (named.defaults !== void 0 && typeof named.defaults !== "boolean") {
|
|
702
|
+
throw new SettingsError("settings.impureNamedImports.defaults: must be a boolean");
|
|
703
|
+
}
|
|
704
|
+
const namedAdd = named.add ?? [];
|
|
705
|
+
if (!Array.isArray(namedAdd)) {
|
|
706
|
+
throw new SettingsError("settings.impureNamedImports.add: must be an array");
|
|
707
|
+
}
|
|
708
|
+
const impureNamedImports = [
|
|
709
|
+
...named.defaults === false ? [] : DEFAULT_IMPURE_NAMED_IMPORTS,
|
|
710
|
+
...namedAdd.map((item, index) => {
|
|
711
|
+
const at = `settings.impureNamedImports.add[${index}]`;
|
|
712
|
+
if (!isRecord2(item) || typeof item.module !== "string" || item.module === "") {
|
|
713
|
+
throw new SettingsError(`${at}: must be { module, names, reason? }`);
|
|
714
|
+
}
|
|
715
|
+
rejectUnknownKeys(item, ["module", "names", "reason"], at);
|
|
716
|
+
const names = stringArray(item.names, `${at}.names`);
|
|
717
|
+
if (names.length === 0) {
|
|
718
|
+
throw new SettingsError(`${at}.names: must list at least one name`);
|
|
719
|
+
}
|
|
720
|
+
if (item.reason !== void 0 && typeof item.reason !== "string") {
|
|
721
|
+
throw new SettingsError(`${at}.reason: must be a string`);
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
module: item.module,
|
|
725
|
+
names,
|
|
726
|
+
reason: item.reason ?? "not allowed in a pure module"
|
|
727
|
+
};
|
|
728
|
+
})
|
|
729
|
+
];
|
|
730
|
+
const aliasesRaw = settings.importAliases ?? [];
|
|
731
|
+
if (!Array.isArray(aliasesRaw)) {
|
|
732
|
+
throw new SettingsError("settings.importAliases: must be an array");
|
|
733
|
+
}
|
|
734
|
+
const importAliases = aliasesRaw.map((item, index) => {
|
|
735
|
+
const at = `settings.importAliases[${index}]`;
|
|
736
|
+
if (!isRecord2(item) || typeof item.prefix !== "string" || item.prefix === "" || typeof item.target !== "string") {
|
|
737
|
+
throw new SettingsError(`${at}: must be { prefix, target }`);
|
|
738
|
+
}
|
|
739
|
+
rejectUnknownKeys(item, ["prefix", "target"], at);
|
|
740
|
+
return { prefix: item.prefix, target: item.target };
|
|
741
|
+
});
|
|
742
|
+
let gateCommand = null;
|
|
743
|
+
if (settings.gateCommand !== void 0) {
|
|
744
|
+
gateCommand = stringArray(settings.gateCommand, "settings.gateCommand");
|
|
745
|
+
if (gateCommand.length === 0) {
|
|
746
|
+
throw new SettingsError("settings.gateCommand: must not be empty");
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const selfcheck = section(settings.selfcheck, ["mode", "failurePattern"], "settings.selfcheck");
|
|
750
|
+
if (selfcheck.mode !== void 0 && selfcheck.mode !== "all" && selfcheck.mode !== "first") {
|
|
751
|
+
throw new SettingsError('settings.selfcheck.mode: must be "all" or "first"');
|
|
752
|
+
}
|
|
753
|
+
const selfcheckFailurePattern = optionalString(
|
|
754
|
+
selfcheck.failurePattern,
|
|
755
|
+
"settings.selfcheck.failurePattern"
|
|
756
|
+
);
|
|
757
|
+
if (selfcheckFailurePattern !== null) {
|
|
758
|
+
compile(
|
|
759
|
+
selfcheckFailurePattern.split("{gate}").join("x"),
|
|
760
|
+
void 0,
|
|
761
|
+
"settings.selfcheck.failurePattern"
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
const stryker = section(settings.stryker, ["configFile", "reportFile"], "settings.stryker");
|
|
765
|
+
const lcov = section(settings.lcov, ["file", "summaryExclude"], "settings.lcov");
|
|
766
|
+
return {
|
|
767
|
+
gateExtensions,
|
|
768
|
+
specSuffixes,
|
|
769
|
+
specRewrite,
|
|
770
|
+
impureImports: imports.rules,
|
|
771
|
+
allowedImports,
|
|
772
|
+
impureNamedImports,
|
|
773
|
+
impurePaths: compileRules(DEFAULT_IMPURE_PATHS, settings.impurePaths, "settings.impurePaths").rules,
|
|
774
|
+
forbiddenSource: compileRules(
|
|
775
|
+
DEFAULT_FORBIDDEN_SOURCE,
|
|
776
|
+
settings.forbiddenSource,
|
|
777
|
+
"settings.forbiddenSource"
|
|
778
|
+
).rules,
|
|
779
|
+
importAliases,
|
|
780
|
+
gateCommand,
|
|
781
|
+
selfcheckMode: selfcheck.mode ?? "all",
|
|
782
|
+
selfcheckFailurePattern,
|
|
783
|
+
strykerConfigFile: optionalString(stryker.configFile, "settings.stryker.configFile"),
|
|
784
|
+
strykerReportFile: optionalString(stryker.reportFile, "settings.stryker.reportFile") ?? DEFAULT_REPORT_FILE,
|
|
785
|
+
lcovFile: optionalString(lcov.file, "settings.lcov.file"),
|
|
786
|
+
lcovSummaryExclude: lcov.summaryExclude === void 0 ? [] : stringArray(lcov.summaryExclude, "settings.lcov.summaryExclude").map(
|
|
787
|
+
(pattern, index) => compile(pattern, void 0, `settings.lcov.summaryExclude[${index}]`)
|
|
788
|
+
)
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// src/core/selfcheck.ts
|
|
793
|
+
var EXCLUDE_ENV = "TEST_GATES_EXCLUDE_SPEC";
|
|
794
|
+
var THRESHOLD_MESSAGE = /threshold|coverage data for/i;
|
|
795
|
+
function escapeRegExp2(text) {
|
|
796
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
797
|
+
}
|
|
798
|
+
function judgeSelfcheck(input) {
|
|
799
|
+
const { status, output, gatePath } = input;
|
|
800
|
+
if (status === null) {
|
|
801
|
+
return "the gate command did not run to completion";
|
|
802
|
+
}
|
|
803
|
+
if (status === 0) {
|
|
804
|
+
return `the gate passed although the spec of ${gatePath} was left out. The gate does not measure this file (does the gate config honor ${EXCLUDE_ENV}?)`;
|
|
805
|
+
}
|
|
806
|
+
if (input.failurePattern) {
|
|
807
|
+
const pattern = input.failurePattern.split("{gate}").join(escapeRegExp2(gatePath));
|
|
808
|
+
return new RegExp(pattern).test(output) ? null : `the gate failed (exit ${status}) but the output does not match settings.selfcheck.failurePattern`;
|
|
809
|
+
}
|
|
810
|
+
const reported = output.split("\n").some((line) => line.includes(gatePath) && THRESHOLD_MESSAGE.test(line));
|
|
811
|
+
return reported ? null : `the gate failed (exit ${status}) but not on the coverage threshold of ${gatePath}. It may be failing for another reason`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// src/load.ts
|
|
815
|
+
function readManifest(dir) {
|
|
816
|
+
const absoluteDir = path2.resolve(dir);
|
|
817
|
+
const manifestPath = path2.join(absoluteDir, MANIFEST_FILE);
|
|
818
|
+
let text;
|
|
819
|
+
try {
|
|
820
|
+
text = fs2.readFileSync(manifestPath, "utf8");
|
|
821
|
+
} catch (error) {
|
|
822
|
+
if (error.code === "ENOENT") {
|
|
823
|
+
throw new ManifestError(`${MANIFEST_FILE} not found in ${absoluteDir}`);
|
|
824
|
+
}
|
|
825
|
+
throw new ManifestError(`cannot read ${manifestPath}: ${error.message}`);
|
|
826
|
+
}
|
|
827
|
+
const parsed = parseManifest(text);
|
|
828
|
+
let settings;
|
|
829
|
+
try {
|
|
830
|
+
settings = resolveSettings(parsed.settings);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
if (error instanceof SettingsError) {
|
|
833
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${error.message}`);
|
|
834
|
+
}
|
|
835
|
+
throw error;
|
|
836
|
+
}
|
|
837
|
+
const gates = parsed.gates.map((gate) => {
|
|
838
|
+
const specCandidates = specCandidatesFor(gate.path, settings);
|
|
839
|
+
const existingSpecs = specCandidates.filter((candidate) => existsExact(absoluteDir, candidate));
|
|
840
|
+
return {
|
|
841
|
+
...gate,
|
|
842
|
+
specCandidates,
|
|
843
|
+
existingSpecs,
|
|
844
|
+
spec: existingSpecs[0] ?? specCandidates[0] ?? null
|
|
845
|
+
};
|
|
846
|
+
});
|
|
847
|
+
return {
|
|
848
|
+
dir: absoluteDir,
|
|
849
|
+
gates,
|
|
850
|
+
candidates: parsed.candidates,
|
|
851
|
+
settings,
|
|
852
|
+
violations: parsed.violations
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// src/commands/output.ts
|
|
857
|
+
function formatViolation(violation) {
|
|
858
|
+
const where = violation.line === void 0 ? violation.file : `${violation.file}:${violation.line}`;
|
|
859
|
+
return `${where}: ${violation.message}`;
|
|
860
|
+
}
|
|
861
|
+
function printViolations(io2, title, violations) {
|
|
862
|
+
io2.err(`test-gates ${title}: ${violations.length} violation(s)`);
|
|
863
|
+
for (const violation of violations) {
|
|
864
|
+
io2.err(` - ${formatViolation(violation)}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/commands/check.ts
|
|
869
|
+
var STRYKER_CONFIG_NAMES = [
|
|
870
|
+
"stryker.gates.config.mjs",
|
|
871
|
+
"stryker.gates.config.js",
|
|
872
|
+
"stryker.gates.config.cjs",
|
|
873
|
+
"stryker.gates.config.json"
|
|
874
|
+
];
|
|
875
|
+
function findStrykerConfig(manifest) {
|
|
876
|
+
if (manifest.settings.strykerConfigFile !== null) {
|
|
877
|
+
return manifest.settings.strykerConfigFile;
|
|
878
|
+
}
|
|
879
|
+
return STRYKER_CONFIG_NAMES.find((name) => fs3.existsSync(path3.join(manifest.dir, name))) ?? null;
|
|
880
|
+
}
|
|
881
|
+
function isPlainRelativePath(file) {
|
|
882
|
+
return !path3.isAbsolute(file) && !/^[A-Za-z]:/.test(file) && !file.includes("\\") && !file.split("/").some((segment) => segment === ".." || segment === "." || segment === "");
|
|
883
|
+
}
|
|
884
|
+
function checkGates(manifest) {
|
|
885
|
+
const { dir, settings } = manifest;
|
|
886
|
+
const violations = [...manifest.violations];
|
|
887
|
+
for (const gate of manifest.gates) {
|
|
888
|
+
if (!isPlainRelativePath(gate.path)) {
|
|
889
|
+
violations.push({
|
|
890
|
+
file: MANIFEST_FILE,
|
|
891
|
+
message: `${gate.path}: write the path relative to the subproject, with "/" and without ".."`
|
|
892
|
+
});
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (gateExtensionOf(gate.path, settings.gateExtensions) === null) {
|
|
896
|
+
violations.push({
|
|
897
|
+
file: gate.path,
|
|
898
|
+
message: `a gate must be one of ${settings.gateExtensions.join(" / ")} (settings.gateExtensions)`
|
|
899
|
+
});
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (looksLikeSpec(gate.path, settings)) {
|
|
903
|
+
violations.push({ file: gate.path, message: "a spec cannot be a gate" });
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
for (const rule of settings.impurePaths) {
|
|
907
|
+
if (rule.regex.test(gate.path)) {
|
|
908
|
+
violations.push({
|
|
909
|
+
file: gate.path,
|
|
910
|
+
message: `cannot be a gate (${rule.reason}). Move it to candidates`
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
if (!existsExact(dir, gate.path)) {
|
|
915
|
+
violations.push({
|
|
916
|
+
file: gate.path,
|
|
917
|
+
message: "file not found (the check is case-sensitive)"
|
|
918
|
+
});
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
violations.push(...scanGateSource(gate.path, readText(dir, gate.path), settings));
|
|
922
|
+
if (gate.specCandidates.length === 0) {
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (gate.existingSpecs.length === 0) {
|
|
926
|
+
violations.push({
|
|
927
|
+
file: gate.path,
|
|
928
|
+
message: `spec not found (looked for ${gate.specCandidates.join(", ")}; the check is case-sensitive)`
|
|
929
|
+
});
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
if (gate.existingSpecs.length > 1) {
|
|
933
|
+
violations.push({
|
|
934
|
+
file: gate.path,
|
|
935
|
+
message: `more than one spec (${gate.existingSpecs.join(", ")}). Keep one, or narrow settings.spec.suffixes`
|
|
936
|
+
});
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
const specPath = gate.existingSpecs[0];
|
|
940
|
+
violations.push(...scanSpecSource(gate.path, specPath, readText(dir, specPath), settings));
|
|
941
|
+
}
|
|
942
|
+
for (const candidate of manifest.candidates) {
|
|
943
|
+
if (!isPlainRelativePath(candidate.path) || !existsExact(dir, candidate.path, "any")) {
|
|
944
|
+
violations.push({
|
|
945
|
+
file: MANIFEST_FILE,
|
|
946
|
+
message: `${candidate.path}: candidate not found. Remove it, or fix the path`
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const strykerConfig = findStrykerConfig(manifest);
|
|
951
|
+
if (strykerConfig !== null) {
|
|
952
|
+
if (!fs3.existsSync(path3.join(dir, strykerConfig))) {
|
|
953
|
+
violations.push({ file: strykerConfig, message: "Stryker config not found" });
|
|
954
|
+
} else {
|
|
955
|
+
violations.push(...scanStrykerConfig(strykerConfig, readText(dir, strykerConfig)));
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return violations;
|
|
959
|
+
}
|
|
960
|
+
function runCheck(dir, io2) {
|
|
961
|
+
const manifest = readManifest(dir);
|
|
962
|
+
const violations = checkGates(manifest);
|
|
963
|
+
if (violations.length > 0) {
|
|
964
|
+
printViolations(io2, "check", violations);
|
|
965
|
+
return 1;
|
|
966
|
+
}
|
|
967
|
+
io2.out(
|
|
968
|
+
`test-gates check: OK (${manifest.gates.length} gate(s), ${manifest.candidates.length} candidate(s))`
|
|
969
|
+
);
|
|
970
|
+
return 0;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// src/commands/lcov.ts
|
|
974
|
+
import fs4 from "fs";
|
|
975
|
+
import path4 from "path";
|
|
976
|
+
|
|
977
|
+
// src/core/lcov.ts
|
|
978
|
+
function normalizePath(file, rootDir) {
|
|
979
|
+
let normalized = file.replace(/\\/g, "/");
|
|
980
|
+
if (rootDir !== void 0) {
|
|
981
|
+
const root = rootDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
982
|
+
if (normalized.startsWith(`${root}/`)) {
|
|
983
|
+
normalized = normalized.slice(root.length + 1);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return normalized.replace(/^\.\//, "");
|
|
987
|
+
}
|
|
988
|
+
function parseLcov(text, rootDir) {
|
|
989
|
+
const files = /* @__PURE__ */ new Map();
|
|
990
|
+
let record = null;
|
|
991
|
+
const closeRecord = () => {
|
|
992
|
+
if (record && !record.hasDa) {
|
|
993
|
+
record.file.found += record.found;
|
|
994
|
+
record.file.hit += record.hit;
|
|
995
|
+
}
|
|
996
|
+
record = null;
|
|
997
|
+
};
|
|
998
|
+
for (const rawLine of text.split("\n")) {
|
|
999
|
+
const line = rawLine.trim();
|
|
1000
|
+
if (line.startsWith("SF:")) {
|
|
1001
|
+
closeRecord();
|
|
1002
|
+
const path7 = normalizePath(line.slice(3), rootDir);
|
|
1003
|
+
const file = files.get(path7) ?? { lines: /* @__PURE__ */ new Map(), found: 0, hit: 0 };
|
|
1004
|
+
files.set(path7, file);
|
|
1005
|
+
record = { file, hasDa: false, found: 0, hit: 0 };
|
|
1006
|
+
} else if (line === "end_of_record") {
|
|
1007
|
+
closeRecord();
|
|
1008
|
+
} else if (record) {
|
|
1009
|
+
const open = record;
|
|
1010
|
+
if (line.startsWith("DA:")) {
|
|
1011
|
+
const [lineNumber, hits] = line.slice(3).split(",");
|
|
1012
|
+
const number = Number(lineNumber);
|
|
1013
|
+
const count = Number(hits);
|
|
1014
|
+
if (Number.isInteger(number) && Number.isFinite(count)) {
|
|
1015
|
+
open.hasDa = true;
|
|
1016
|
+
open.file.lines.set(number, Math.max(open.file.lines.get(number) ?? 0, count));
|
|
1017
|
+
}
|
|
1018
|
+
} else if (line.startsWith("LF:")) {
|
|
1019
|
+
open.found = Number(line.slice(3)) || 0;
|
|
1020
|
+
} else if (line.startsWith("LH:")) {
|
|
1021
|
+
open.hit = Number(line.slice(3)) || 0;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
closeRecord();
|
|
1026
|
+
const result = /* @__PURE__ */ new Map();
|
|
1027
|
+
for (const [file, accumulator] of files) {
|
|
1028
|
+
const uncovered = [...accumulator.lines.entries()].filter(([, hits]) => hits <= 0).map(([lineNumber]) => lineNumber).sort((a, b) => a - b);
|
|
1029
|
+
result.set(file, {
|
|
1030
|
+
found: accumulator.lines.size + accumulator.found,
|
|
1031
|
+
hit: accumulator.lines.size - uncovered.length + accumulator.hit,
|
|
1032
|
+
uncovered
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
return result;
|
|
1036
|
+
}
|
|
1037
|
+
function judgeLcov(gatePaths, coverage) {
|
|
1038
|
+
const violations = [];
|
|
1039
|
+
const results = [];
|
|
1040
|
+
for (const gatePath of gatePaths) {
|
|
1041
|
+
const file = coverage.get(normalizePath(gatePath));
|
|
1042
|
+
if (!file) {
|
|
1043
|
+
violations.push({ file: gatePath, message: "no record in the lcov file" });
|
|
1044
|
+
results.push({ path: gatePath, found: 0, hit: 0, ok: false });
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
if (file.found === 0) {
|
|
1048
|
+
violations.push({ file: gatePath, message: "no instrumented lines in the lcov record" });
|
|
1049
|
+
results.push({ path: gatePath, found: 0, hit: 0, ok: false });
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
const ok = file.hit === file.found;
|
|
1053
|
+
if (!ok) {
|
|
1054
|
+
const shown = file.uncovered.slice(0, 20).join(", ");
|
|
1055
|
+
const more = file.uncovered.length > 20 ? ", \u2026" : "";
|
|
1056
|
+
violations.push({
|
|
1057
|
+
file: gatePath,
|
|
1058
|
+
...file.uncovered[0] === void 0 ? {} : { line: file.uncovered[0] },
|
|
1059
|
+
message: `${file.hit}/${file.found} lines covered` + (shown === "" ? "" : ` (uncovered: ${shown}${more})`)
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
results.push({ path: gatePath, found: file.found, hit: file.hit, ok });
|
|
1063
|
+
}
|
|
1064
|
+
return { violations, results };
|
|
1065
|
+
}
|
|
1066
|
+
function summarizeLcov(coverage, exclude) {
|
|
1067
|
+
let found = 0;
|
|
1068
|
+
let hit = 0;
|
|
1069
|
+
for (const [file, lines] of coverage) {
|
|
1070
|
+
if (exclude.some((regex) => regex.test(file))) {
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
found += lines.found;
|
|
1074
|
+
hit += lines.hit;
|
|
1075
|
+
}
|
|
1076
|
+
return { found, hit };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// src/commands/lcov.ts
|
|
1080
|
+
function runLcov(dir, fileOption, io2) {
|
|
1081
|
+
const manifest = readManifest(dir);
|
|
1082
|
+
const file = fileOption ?? manifest.settings.lcovFile;
|
|
1083
|
+
if (file === null) {
|
|
1084
|
+
throw new ManifestError(
|
|
1085
|
+
"no lcov file given. Pass --file <lcov.info> or set settings.lcov.file"
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
const lcovPath = path4.resolve(manifest.dir, file);
|
|
1089
|
+
let text;
|
|
1090
|
+
try {
|
|
1091
|
+
text = fs4.readFileSync(lcovPath, "utf8");
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
io2.err(
|
|
1094
|
+
`test-gates lcov: cannot read ${file} (${error.message}). Run the tests with coverage first`
|
|
1095
|
+
);
|
|
1096
|
+
return 1;
|
|
1097
|
+
}
|
|
1098
|
+
const violations = [...manifest.violations];
|
|
1099
|
+
const existing = manifest.gates.filter((gate) => {
|
|
1100
|
+
const exists = existsExact(manifest.dir, gate.path);
|
|
1101
|
+
if (!exists) {
|
|
1102
|
+
violations.push({
|
|
1103
|
+
file: gate.path,
|
|
1104
|
+
message: "file not found (the check is case-sensitive). Update test-gates.json if it moved"
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
return exists;
|
|
1108
|
+
});
|
|
1109
|
+
const coverage = parseLcov(text, manifest.dir);
|
|
1110
|
+
const total = summarizeLcov(coverage, manifest.settings.lcovSummaryExclude);
|
|
1111
|
+
io2.out(
|
|
1112
|
+
total.found === 0 ? "reference: no instrumented lines in the lcov file" : `reference: overall line coverage ${(total.hit / total.found * 100).toFixed(2)}% (${total.hit}/${total.found}, not gated)`
|
|
1113
|
+
);
|
|
1114
|
+
const verdict = judgeLcov(
|
|
1115
|
+
existing.map((gate) => gate.path),
|
|
1116
|
+
coverage
|
|
1117
|
+
);
|
|
1118
|
+
for (const result of verdict.results) {
|
|
1119
|
+
if (result.ok) {
|
|
1120
|
+
io2.out(` \u2713 ${result.path}: ${result.hit}/${result.found} lines`);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
violations.push(...verdict.violations);
|
|
1124
|
+
if (violations.length > 0) {
|
|
1125
|
+
printViolations(io2, "lcov", violations);
|
|
1126
|
+
return 1;
|
|
1127
|
+
}
|
|
1128
|
+
io2.out(`test-gates lcov: OK (${verdict.results.length} gate(s) at 100% line coverage)`);
|
|
1129
|
+
return 0;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/commands/mutation.ts
|
|
1133
|
+
import { spawnSync } from "child_process";
|
|
1134
|
+
import fs6 from "fs";
|
|
1135
|
+
|
|
1136
|
+
// src/commands/mutation-result.ts
|
|
1137
|
+
import fs5 from "fs";
|
|
1138
|
+
import path5 from "path";
|
|
1139
|
+
|
|
1140
|
+
// src/core/mutation-result.ts
|
|
1141
|
+
var DETECTED = /* @__PURE__ */ new Set(["Killed", "Timeout"]);
|
|
1142
|
+
var SURVIVED = /* @__PURE__ */ new Set(["Survived", "NoCoverage"]);
|
|
1143
|
+
var NOT_EVALUABLE = /* @__PURE__ */ new Set(["RuntimeError", "CompileError"]);
|
|
1144
|
+
function normalizeCode(text) {
|
|
1145
|
+
return text.replace(/\s+/g, " ").trim();
|
|
1146
|
+
}
|
|
1147
|
+
function normalizePath2(file) {
|
|
1148
|
+
return file.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1149
|
+
}
|
|
1150
|
+
function normalizeNewlines(text) {
|
|
1151
|
+
return text.replace(/\r\n/g, "\n");
|
|
1152
|
+
}
|
|
1153
|
+
function sliceByLocation(source, location) {
|
|
1154
|
+
const lines = source.split("\n");
|
|
1155
|
+
const { start, end } = location;
|
|
1156
|
+
const first = lines[start.line - 1];
|
|
1157
|
+
const last = lines[end.line - 1];
|
|
1158
|
+
if (first === void 0 || last === void 0 || start.column < 1 || end.column < 1 || end.line < start.line || end.line === start.line && end.column < start.column) {
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
if (start.line === end.line) {
|
|
1162
|
+
return first.slice(start.column - 1, end.column - 1);
|
|
1163
|
+
}
|
|
1164
|
+
return [
|
|
1165
|
+
first.slice(start.column - 1),
|
|
1166
|
+
...lines.slice(start.line, end.line - 1),
|
|
1167
|
+
last.slice(0, end.column - 1)
|
|
1168
|
+
].join("\n");
|
|
1169
|
+
}
|
|
1170
|
+
function keyOf(mutator, original, replacement) {
|
|
1171
|
+
return JSON.stringify([mutator, normalizeCode(original), normalizeCode(replacement)]);
|
|
1172
|
+
}
|
|
1173
|
+
function describeAllowance(entry) {
|
|
1174
|
+
const occurrence = entry.occurrence === void 0 ? "" : ` / occurrence ${entry.occurrence}`;
|
|
1175
|
+
return `${entry.mutator} / ${normalizeCode(entry.original)} \u2192 ${normalizeCode(entry.replacement)}${occurrence}`;
|
|
1176
|
+
}
|
|
1177
|
+
function judgeMutationReport(input) {
|
|
1178
|
+
const violations = [];
|
|
1179
|
+
const survivors = [];
|
|
1180
|
+
const summary = {
|
|
1181
|
+
total: 0,
|
|
1182
|
+
detected: 0,
|
|
1183
|
+
timeout: 0,
|
|
1184
|
+
allowed: 0,
|
|
1185
|
+
notEvaluable: 0,
|
|
1186
|
+
unallowed: 0
|
|
1187
|
+
};
|
|
1188
|
+
const files = /* @__PURE__ */ new Map();
|
|
1189
|
+
for (const [file, result] of Object.entries(input.report.files ?? {})) {
|
|
1190
|
+
files.set(normalizePath2(file), result);
|
|
1191
|
+
}
|
|
1192
|
+
for (const gate of input.gates) {
|
|
1193
|
+
const gatePath = normalizePath2(gate.path);
|
|
1194
|
+
const shapeViolations = validateEquivalentMutants(gate.path, gate.equivalentMutants);
|
|
1195
|
+
violations.push(...shapeViolations);
|
|
1196
|
+
const allowances = Array.isArray(gate.equivalentMutants) ? gate.equivalentMutants : [];
|
|
1197
|
+
const result = files.get(gatePath);
|
|
1198
|
+
if (!result || !Array.isArray(result.mutants) || result.mutants.length === 0) {
|
|
1199
|
+
violations.push({
|
|
1200
|
+
file: gate.path,
|
|
1201
|
+
message: "no mutants in the report (the gate was not mutation tested)"
|
|
1202
|
+
});
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
const diskSource = input.readSource(gate.path);
|
|
1206
|
+
const source = result.source ?? diskSource;
|
|
1207
|
+
if (source === void 0) {
|
|
1208
|
+
violations.push({
|
|
1209
|
+
file: gate.path,
|
|
1210
|
+
message: "source is neither in the report nor readable on disk"
|
|
1211
|
+
});
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
if (result.source !== void 0 && diskSource !== void 0 && normalizeNewlines(result.source) !== normalizeNewlines(diskSource)) {
|
|
1215
|
+
violations.push({
|
|
1216
|
+
file: gate.path,
|
|
1217
|
+
message: "the report was made from a different version of this file. Run Stryker again"
|
|
1218
|
+
});
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
const located = [];
|
|
1222
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1223
|
+
const sorted = [...result.mutants].sort(
|
|
1224
|
+
(a, b) => a.location.start.line - b.location.start.line || a.location.start.column - b.location.start.column
|
|
1225
|
+
);
|
|
1226
|
+
let locationsValid = true;
|
|
1227
|
+
for (const mutant of sorted) {
|
|
1228
|
+
const original = sliceByLocation(normalizeNewlines(source), mutant.location);
|
|
1229
|
+
if (original === null) {
|
|
1230
|
+
violations.push({
|
|
1231
|
+
file: gate.path,
|
|
1232
|
+
line: mutant.location.start.line,
|
|
1233
|
+
message: `the report location of a ${mutant.mutatorName} mutant is outside the source`
|
|
1234
|
+
});
|
|
1235
|
+
locationsValid = false;
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
const replacement = mutant.replacement ?? "";
|
|
1239
|
+
const key = keyOf(mutant.mutatorName, original, replacement);
|
|
1240
|
+
const group = byKey.get(key) ?? [];
|
|
1241
|
+
const entry = {
|
|
1242
|
+
mutant,
|
|
1243
|
+
original,
|
|
1244
|
+
replacement,
|
|
1245
|
+
occurrence: group.length + 1,
|
|
1246
|
+
sameKeyCount: 0
|
|
1247
|
+
};
|
|
1248
|
+
group.push(entry);
|
|
1249
|
+
byKey.set(key, group);
|
|
1250
|
+
located.push(entry);
|
|
1251
|
+
}
|
|
1252
|
+
for (const group of byKey.values()) {
|
|
1253
|
+
for (const entry of group) {
|
|
1254
|
+
entry.sameKeyCount = group.length;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (!locationsValid) {
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
1261
|
+
allowances.forEach((allowance, index) => {
|
|
1262
|
+
if (!isUsableEquivalentMutant(allowance)) {
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const at = `${gate.path}: equivalentMutants[${index}] (${describeAllowance(allowance)})`;
|
|
1266
|
+
const group = byKey.get(keyOf(allowance.mutator, allowance.original, allowance.replacement)) ?? [];
|
|
1267
|
+
if (allowance.occurrence === void 0 && group.length > 1) {
|
|
1268
|
+
violations.push({
|
|
1269
|
+
file: MANIFEST_FILE,
|
|
1270
|
+
message: `${at}: matches ${group.length} mutants in the file. Add "occurrence" (1-based) to pick one`
|
|
1271
|
+
});
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const target = group[(allowance.occurrence ?? 1) - 1];
|
|
1275
|
+
if (!target || !SURVIVED.has(target.mutant.status)) {
|
|
1276
|
+
const why = target ? `the mutant is now ${target.mutant.status}` : "no such mutant in this run";
|
|
1277
|
+
violations.push({
|
|
1278
|
+
file: MANIFEST_FILE,
|
|
1279
|
+
message: `${at}: stale allowance (${why}). Remove it`
|
|
1280
|
+
});
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
if (allowed.has(target)) {
|
|
1284
|
+
violations.push({
|
|
1285
|
+
file: MANIFEST_FILE,
|
|
1286
|
+
message: `${at}: another allowance already covers this mutant`
|
|
1287
|
+
});
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
allowed.add(target);
|
|
1291
|
+
});
|
|
1292
|
+
for (const entry of located) {
|
|
1293
|
+
const { mutant } = entry;
|
|
1294
|
+
summary.total += 1;
|
|
1295
|
+
if (DETECTED.has(mutant.status)) {
|
|
1296
|
+
summary.detected += 1;
|
|
1297
|
+
if (mutant.status === "Timeout") {
|
|
1298
|
+
summary.timeout += 1;
|
|
1299
|
+
}
|
|
1300
|
+
} else if (NOT_EVALUABLE.has(mutant.status)) {
|
|
1301
|
+
summary.notEvaluable += 1;
|
|
1302
|
+
} else if (SURVIVED.has(mutant.status)) {
|
|
1303
|
+
if (allowed.has(entry)) {
|
|
1304
|
+
summary.allowed += 1;
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
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
|
+
});
|
|
1326
|
+
} else {
|
|
1327
|
+
violations.push({
|
|
1328
|
+
file: gate.path,
|
|
1329
|
+
line: mutant.location.start.line,
|
|
1330
|
+
message: mutant.status === "Ignored" ? `a ${mutant.mutatorName} mutant was ignored. Taking mutants out with Stryker comments or mutator exclusions is not allowed` : `a ${mutant.mutatorName} mutant was not evaluated (status: ${mutant.status})`
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (summary.total === 0) {
|
|
1336
|
+
violations.push({
|
|
1337
|
+
file: MANIFEST_FILE,
|
|
1338
|
+
message: "no mutants were evaluated (the mutation test ran on nothing)"
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
return { violations, survivors, summary };
|
|
1342
|
+
}
|
|
1343
|
+
function formatSurvivor(survivor) {
|
|
1344
|
+
const occurrence = survivor.sameKeyCount > 1 ? ` / occurrence ${survivor.occurrence} of ${survivor.sameKeyCount}` : "";
|
|
1345
|
+
return `${survivor.file}:${survivor.line}:${survivor.column} / ${survivor.mutator} / ${survivor.original} \u2192 ${survivor.replacement}${occurrence} (${survivor.status})`;
|
|
1346
|
+
}
|
|
1347
|
+
function formatSummary(summary) {
|
|
1348
|
+
return `mutants ${summary.total} / detected ${summary.detected} (timeout ${summary.timeout}) / allowed equivalent ${summary.allowed} / not evaluable ${summary.notEvaluable} / unallowed survivors ${summary.unallowed}`;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// src/commands/mutation-result.ts
|
|
1352
|
+
function reportPathOf(manifest, override) {
|
|
1353
|
+
return path5.resolve(manifest.dir, override ?? manifest.settings.strykerReportFile);
|
|
1354
|
+
}
|
|
1355
|
+
function runMutationResult(dir, reportOverride, io2) {
|
|
1356
|
+
const manifest = readManifest(dir);
|
|
1357
|
+
const reportPath = reportPathOf(manifest, reportOverride);
|
|
1358
|
+
const shown = path5.relative(manifest.dir, reportPath) || reportPath;
|
|
1359
|
+
let report;
|
|
1360
|
+
try {
|
|
1361
|
+
report = JSON.parse(fs5.readFileSync(reportPath, "utf8"));
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
io2.err(
|
|
1364
|
+
`test-gates mutation: cannot read the Stryker JSON report ${shown} (${error.message}). Did Stryker finish, with the "json" reporter writing to this path?`
|
|
1365
|
+
);
|
|
1366
|
+
return 1;
|
|
1367
|
+
}
|
|
1368
|
+
if (typeof report !== "object" || report === null) {
|
|
1369
|
+
io2.err(`test-gates mutation: ${shown} is not a Stryker JSON report`);
|
|
1370
|
+
return 1;
|
|
1371
|
+
}
|
|
1372
|
+
const verdict = judgeMutationReport({
|
|
1373
|
+
gates: manifest.gates,
|
|
1374
|
+
report,
|
|
1375
|
+
readSource: (gatePath) => {
|
|
1376
|
+
try {
|
|
1377
|
+
return fs5.readFileSync(path5.join(manifest.dir, gatePath), "utf8");
|
|
1378
|
+
} catch {
|
|
1379
|
+
return void 0;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
const summary = formatSummary(verdict.summary);
|
|
1384
|
+
if (verdict.violations.length === 0 && verdict.survivors.length === 0) {
|
|
1385
|
+
io2.out(`test-gates mutation: OK (${summary})`);
|
|
1386
|
+
return 0;
|
|
1387
|
+
}
|
|
1388
|
+
if (verdict.survivors.length > 0) {
|
|
1389
|
+
io2.err(
|
|
1390
|
+
`test-gates mutation: ${verdict.survivors.length} surviving mutant(s) not in the allow list`
|
|
1391
|
+
);
|
|
1392
|
+
for (const survivor of verdict.survivors) {
|
|
1393
|
+
io2.err(` - ${formatSurvivor(survivor)}`);
|
|
1394
|
+
}
|
|
1395
|
+
io2.err("");
|
|
1396
|
+
io2.err(
|
|
1397
|
+
"Fix the test so that it detects the mutant. Only when the mutant cannot be observed from"
|
|
1398
|
+
);
|
|
1399
|
+
io2.err("outside (same return value, exception and side effects for every input), add it to");
|
|
1400
|
+
io2.err('"equivalentMutants" of the gate in test-gates.json and fill in "reason":');
|
|
1401
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1402
|
+
for (const survivor of verdict.survivors) {
|
|
1403
|
+
byFile.set(survivor.file, [...byFile.get(survivor.file) ?? [], survivor]);
|
|
1404
|
+
}
|
|
1405
|
+
for (const [file, survivors] of byFile) {
|
|
1406
|
+
io2.err("");
|
|
1407
|
+
io2.err(` ${file}`);
|
|
1408
|
+
survivors.forEach((survivor, index) => {
|
|
1409
|
+
const comma = index === survivors.length - 1 ? "" : ",";
|
|
1410
|
+
io2.err(` ${JSON.stringify(survivor.allowance)}${comma}`);
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
io2.err("");
|
|
1414
|
+
}
|
|
1415
|
+
if (verdict.violations.length > 0) {
|
|
1416
|
+
printViolations(io2, "mutation", verdict.violations);
|
|
1417
|
+
}
|
|
1418
|
+
io2.err(`test-gates mutation: FAILED (${summary})`);
|
|
1419
|
+
return 1;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// src/commands/mutation.ts
|
|
1423
|
+
function runMutation(dir, strykerArgs, io2) {
|
|
1424
|
+
const manifest = readManifest(dir);
|
|
1425
|
+
const config = findStrykerConfig(manifest);
|
|
1426
|
+
if (config === null) {
|
|
1427
|
+
throw new ManifestError(
|
|
1428
|
+
"no Stryker config found (stryker.gates.config.mjs). Create one, or set settings.stryker.configFile"
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
const stryker = findLocalBin(manifest.dir, "stryker");
|
|
1432
|
+
if (stryker === null) {
|
|
1433
|
+
throw new ManifestError(
|
|
1434
|
+
"stryker is not installed in this project (node_modules/.bin/stryker). Add @stryker-mutator/core as a devDependency"
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
fs6.rmSync(reportPathOf(manifest, null), { force: true });
|
|
1438
|
+
const result = spawnSync(stryker, ["run", config, ...strykerArgs], {
|
|
1439
|
+
cwd: manifest.dir,
|
|
1440
|
+
stdio: "inherit",
|
|
1441
|
+
shell: process.platform === "win32"
|
|
1442
|
+
});
|
|
1443
|
+
if (result.error) {
|
|
1444
|
+
io2.err(`test-gates mutation: cannot start Stryker (${result.error.message})`);
|
|
1445
|
+
return 1;
|
|
1446
|
+
}
|
|
1447
|
+
if (result.status !== 0) {
|
|
1448
|
+
io2.err(
|
|
1449
|
+
`test-gates mutation: Stryker failed (${result.signal ? `signal ${result.signal}` : `exit ${result.status}`}). The result was not judged`
|
|
1450
|
+
);
|
|
1451
|
+
return 1;
|
|
1452
|
+
}
|
|
1453
|
+
return runMutationResult(dir, null, io2);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// src/commands/selfcheck.ts
|
|
1457
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1458
|
+
import fs7 from "fs";
|
|
1459
|
+
import path6 from "path";
|
|
1460
|
+
var JEST_CONFIGS = ["js", "cjs", "mjs", "ts", "cts", "mts"].map(
|
|
1461
|
+
(extension) => `jest.gates.config.${extension}`
|
|
1462
|
+
);
|
|
1463
|
+
var VITEST_CONFIGS = ["ts", "mts", "cts", "js", "mjs", "cjs"].map(
|
|
1464
|
+
(extension) => `vitest.gates.config.${extension}`
|
|
1465
|
+
);
|
|
1466
|
+
function resolveGateCommand(manifest) {
|
|
1467
|
+
if (manifest.settings.gateCommand !== null) {
|
|
1468
|
+
return manifest.settings.gateCommand;
|
|
1469
|
+
}
|
|
1470
|
+
const exists = (name) => fs7.existsSync(path6.join(manifest.dir, name));
|
|
1471
|
+
const jest = JEST_CONFIGS.find(exists);
|
|
1472
|
+
const vitest = VITEST_CONFIGS.find(exists);
|
|
1473
|
+
if (jest && vitest) {
|
|
1474
|
+
throw new ManifestError(
|
|
1475
|
+
`both ${jest} and ${vitest} exist. Set settings.gateCommand to say which one runs the gate`
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
if (jest) {
|
|
1479
|
+
return ["jest", "--config", jest];
|
|
1480
|
+
}
|
|
1481
|
+
if (vitest) {
|
|
1482
|
+
return ["vitest", "run", "--config", vitest];
|
|
1483
|
+
}
|
|
1484
|
+
throw new ManifestError(
|
|
1485
|
+
"no gate config found (jest.gates.config.* / vitest.gates.config.*). Set settings.gateCommand"
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
function runSelfcheck(dir, mode, commandOverride, io2) {
|
|
1489
|
+
const manifest = readManifest(dir);
|
|
1490
|
+
if (manifest.violations.length > 0 || manifest.gates.length === 0) {
|
|
1491
|
+
io2.err('test-gates selfcheck: test-gates.json has problems. Run "test-gates check" first');
|
|
1492
|
+
return 1;
|
|
1493
|
+
}
|
|
1494
|
+
const command = commandOverride.length > 0 ? commandOverride : resolveGateCommand(manifest);
|
|
1495
|
+
const [program, ...args] = command;
|
|
1496
|
+
const executable = findLocalBin(manifest.dir, program) ?? program;
|
|
1497
|
+
const targets = (mode ?? manifest.settings.selfcheckMode) === "first" ? manifest.gates.slice(0, 1) : manifest.gates;
|
|
1498
|
+
let failed = 0;
|
|
1499
|
+
for (const gate of targets) {
|
|
1500
|
+
if (gate.spec === null || gate.existingSpecs.length !== 1) {
|
|
1501
|
+
io2.err(` \u2717 ${gate.path}: no single spec to leave out. Run "test-gates check"`);
|
|
1502
|
+
failed += 1;
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
const result = spawnSync2(executable, args, {
|
|
1506
|
+
cwd: manifest.dir,
|
|
1507
|
+
env: { ...process.env, [EXCLUDE_ENV]: gate.spec },
|
|
1508
|
+
encoding: "utf8",
|
|
1509
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
1510
|
+
shell: process.platform === "win32"
|
|
1511
|
+
});
|
|
1512
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
1513
|
+
const reason = result.error ? `cannot start "${command.join(" ")}" (${result.error.message})` : judgeSelfcheck({
|
|
1514
|
+
status: result.status,
|
|
1515
|
+
output,
|
|
1516
|
+
gatePath: gate.path,
|
|
1517
|
+
failurePattern: manifest.settings.selfcheckFailurePattern
|
|
1518
|
+
});
|
|
1519
|
+
if (reason === null) {
|
|
1520
|
+
io2.out(` \u2713 without ${gate.spec} the gate fails (exit ${result.status})`);
|
|
1521
|
+
} else {
|
|
1522
|
+
failed += 1;
|
|
1523
|
+
io2.err(` \u2717 ${gate.path}: ${reason}`);
|
|
1524
|
+
const tail = output.trimEnd().split("\n").slice(-25).join("\n");
|
|
1525
|
+
if (tail !== "") {
|
|
1526
|
+
io2.err(tail.replace(/^/gm, " "));
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
if (failed > 0) {
|
|
1531
|
+
io2.err(`test-gates selfcheck: FAILED (${failed} of ${targets.length} negative control(s))`);
|
|
1532
|
+
return 1;
|
|
1533
|
+
}
|
|
1534
|
+
io2.out(`test-gates selfcheck: OK (${targets.length} negative control(s) failed as they should)`);
|
|
1535
|
+
return 0;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// src/cli.ts
|
|
1539
|
+
var HELP = `test-gates \u2014 test gates for the code that must not be wrong
|
|
1540
|
+
|
|
1541
|
+
Usage: test-gates <command> [options]
|
|
1542
|
+
|
|
1543
|
+
Commands:
|
|
1544
|
+
check Static checks of test-gates.json, the gates and their specs
|
|
1545
|
+
mutation [-- <args>] Run the project's Stryker, then judge the result.
|
|
1546
|
+
Unknown arguments and everything after "--" go to Stryker
|
|
1547
|
+
mutation-result Judge an existing Stryker JSON report
|
|
1548
|
+
selfcheck [-- <command>] Negative control: leave one spec out, the gate must fail
|
|
1549
|
+
lcov --file <lcov.info> Per-file 100% line coverage from an lcov tracefile
|
|
1550
|
+
|
|
1551
|
+
Options:
|
|
1552
|
+
--dir <subproject> Directory that holds test-gates.json (default: .)
|
|
1553
|
+
--report <path> mutation-result: report to judge (default: settings.stryker.reportFile)
|
|
1554
|
+
--all | --first selfcheck: every gate in turn (default) or only the first
|
|
1555
|
+
--file <path> lcov: tracefile (default: settings.lcov.file)
|
|
1556
|
+
-h, --help Show this help
|
|
1557
|
+
-v, --version Show the version
|
|
1558
|
+
|
|
1559
|
+
Exit codes: 0 passed, 1 violations found, 2 wrong usage or unusable test-gates.json
|
|
1560
|
+
`;
|
|
1561
|
+
var io = {
|
|
1562
|
+
out: (line) => process.stdout.write(`${line}
|
|
1563
|
+
`),
|
|
1564
|
+
err: (line) => process.stderr.write(`${line}
|
|
1565
|
+
`)
|
|
1566
|
+
};
|
|
1567
|
+
function main(argv) {
|
|
1568
|
+
const args = parseArgs(argv);
|
|
1569
|
+
if (args.version) {
|
|
1570
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
1571
|
+
io.out(version);
|
|
1572
|
+
return 0;
|
|
1573
|
+
}
|
|
1574
|
+
if (args.help) {
|
|
1575
|
+
io.out(HELP);
|
|
1576
|
+
return 0;
|
|
1577
|
+
}
|
|
1578
|
+
switch (args.command) {
|
|
1579
|
+
case "check":
|
|
1580
|
+
return runCheck(args.dir, io);
|
|
1581
|
+
case "mutation":
|
|
1582
|
+
return runMutation(args.dir, args.rest, io);
|
|
1583
|
+
case "mutation-result":
|
|
1584
|
+
return runMutationResult(args.dir, args.report, io);
|
|
1585
|
+
case "selfcheck":
|
|
1586
|
+
return runSelfcheck(args.dir, args.mode, args.rest, io);
|
|
1587
|
+
case "lcov":
|
|
1588
|
+
return runLcov(args.dir, args.file, io);
|
|
1589
|
+
case null:
|
|
1590
|
+
throw new UsageError("no command given");
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
try {
|
|
1594
|
+
process.exitCode = main(process.argv.slice(2));
|
|
1595
|
+
} catch (error) {
|
|
1596
|
+
if (error instanceof UsageError) {
|
|
1597
|
+
io.err(`test-gates: ${error.message}
|
|
1598
|
+
`);
|
|
1599
|
+
io.err(HELP);
|
|
1600
|
+
process.exitCode = 2;
|
|
1601
|
+
} else if (error instanceof ManifestError) {
|
|
1602
|
+
io.err(`test-gates: ${error.message}`);
|
|
1603
|
+
process.exitCode = 2;
|
|
1604
|
+
} else {
|
|
1605
|
+
throw error;
|
|
1606
|
+
}
|
|
1607
|
+
}
|