@t09tanaka/test-gates 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +536 -2
- package/dist/chunk-4HAJIKDZ.js +18 -0
- package/dist/chunk-XEKQKLKY.js +608 -0
- package/dist/cli.js +1838 -0
- package/dist/index.cjs +1377 -0
- package/dist/index.d.cts +377 -0
- package/dist/index.d.ts +377 -0
- package/dist/index.js +759 -0
- package/dist/jest.cjs +635 -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 +634 -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 +639 -0
- package/dist/vitest.d.cts +26 -0
- package/dist/vitest.d.ts +26 -0
- package/dist/vitest.js +43 -0
- package/package.json +138 -4
- package/schema/test-gates.schema.json +409 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_FORBIDDEN_SOURCE,
|
|
3
|
+
DEFAULT_GATE_EXTENSIONS,
|
|
4
|
+
DEFAULT_IMPURE_IMPORTS,
|
|
5
|
+
DEFAULT_IMPURE_NAMED_IMPORTS,
|
|
6
|
+
DEFAULT_IMPURE_PATHS,
|
|
7
|
+
DEFAULT_REPORT_FILE,
|
|
8
|
+
EXCLUDE_ENV,
|
|
9
|
+
MANIFEST_FILE,
|
|
10
|
+
ManifestError,
|
|
11
|
+
existsExact,
|
|
12
|
+
gateExtensionOf,
|
|
13
|
+
gateSpecs,
|
|
14
|
+
isUsableEquivalentMutant,
|
|
15
|
+
judgeSelfcheck,
|
|
16
|
+
loadGates,
|
|
17
|
+
looksLikeSpec,
|
|
18
|
+
parseManifest,
|
|
19
|
+
readManifest,
|
|
20
|
+
readText,
|
|
21
|
+
resolveSettings,
|
|
22
|
+
validateEquivalentMutants
|
|
23
|
+
} from "./chunk-XEKQKLKY.js";
|
|
24
|
+
|
|
25
|
+
// src/commands/check.ts
|
|
26
|
+
import fs from "fs";
|
|
27
|
+
import path from "path";
|
|
28
|
+
|
|
29
|
+
// src/core/imports.ts
|
|
30
|
+
var FROM_STATEMENT = /(?:^|[\n;])[ \t]*(import|export)\s+(type\s+)?([\w$\s,{}*]*?)\s*\bfrom\s*(['"])([^'"\n]+)\4/g;
|
|
31
|
+
var SIDE_EFFECT_IMPORT = /(?:^|[\n;])[ \t]*import\s*(['"])([^'"\n]+)\1/g;
|
|
32
|
+
var CALL_IMPORT = /\b(?:require|import)\s*\(\s*(['"`])([^'"`\n]+)\1\s*\)/g;
|
|
33
|
+
function lineAt(source, index) {
|
|
34
|
+
let line = 1;
|
|
35
|
+
for (let i = 0; i < index; i += 1) {
|
|
36
|
+
if (source[i] === "\n") {
|
|
37
|
+
line += 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return line;
|
|
41
|
+
}
|
|
42
|
+
function parseImports(source) {
|
|
43
|
+
const found = [];
|
|
44
|
+
for (const match of source.matchAll(FROM_STATEMENT)) {
|
|
45
|
+
const clause = match[3];
|
|
46
|
+
const module = match[5];
|
|
47
|
+
const braces = /\{([^}]*)\}/.exec(clause);
|
|
48
|
+
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()) : [];
|
|
49
|
+
const outsideBraces = clause.replace(/\{[^}]*\}/, "").replace(/,/g, "").trim();
|
|
50
|
+
found.push({
|
|
51
|
+
module,
|
|
52
|
+
line: lineAt(source, match.index + match[0].lastIndexOf(module)),
|
|
53
|
+
typeOnly: match[2] !== void 0,
|
|
54
|
+
names,
|
|
55
|
+
whole: outsideBraces !== ""
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
for (const match of source.matchAll(SIDE_EFFECT_IMPORT)) {
|
|
59
|
+
const module = match[2];
|
|
60
|
+
found.push({
|
|
61
|
+
module,
|
|
62
|
+
line: lineAt(source, match.index + match[0].lastIndexOf(module)),
|
|
63
|
+
typeOnly: false,
|
|
64
|
+
names: [],
|
|
65
|
+
whole: false
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
for (const match of source.matchAll(CALL_IMPORT)) {
|
|
69
|
+
const module = match[2];
|
|
70
|
+
found.push({
|
|
71
|
+
module,
|
|
72
|
+
line: lineAt(source, match.index + match[0].lastIndexOf(module)),
|
|
73
|
+
typeOnly: false,
|
|
74
|
+
names: [],
|
|
75
|
+
whole: true
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return found.sort((a, b) => a.line - b.line);
|
|
79
|
+
}
|
|
80
|
+
var COMPUTED_CALL_IMPORT = /(?<![.\w$])(?:require|import)\s*\(\s*(?!['"`\s)])/g;
|
|
81
|
+
function findUncheckableImports(source) {
|
|
82
|
+
return [...source.matchAll(COMPUTED_CALL_IMPORT)].map((match) => lineAt(source, match.index));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/core/import-policy.ts
|
|
86
|
+
var SCRIPT_EXTENSION = /\.[cm]?[jt]sx?$/;
|
|
87
|
+
function isProjectImport(specifier, aliases) {
|
|
88
|
+
return specifier.startsWith(".") || aliases.some((alias) => specifier.startsWith(alias.prefix));
|
|
89
|
+
}
|
|
90
|
+
function resolveSpecifier(specifier, fromFile, aliases) {
|
|
91
|
+
let joined = null;
|
|
92
|
+
if (specifier.startsWith(".")) {
|
|
93
|
+
const slash = fromFile.lastIndexOf("/");
|
|
94
|
+
joined = slash === -1 ? specifier : `${fromFile.slice(0, slash)}/${specifier}`;
|
|
95
|
+
} else {
|
|
96
|
+
const alias = aliases.find((candidate) => specifier.startsWith(candidate.prefix));
|
|
97
|
+
if (alias) {
|
|
98
|
+
joined = alias.target + specifier.slice(alias.prefix.length);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (joined === null) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const segments = [];
|
|
105
|
+
for (const segment of joined.split("/")) {
|
|
106
|
+
if (segment === "..") {
|
|
107
|
+
segments.pop();
|
|
108
|
+
} else if (segment !== "." && segment !== "") {
|
|
109
|
+
segments.push(segment);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return segments.join("/").replace(SCRIPT_EXTENSION, "");
|
|
113
|
+
}
|
|
114
|
+
function matches(rule, specifier) {
|
|
115
|
+
if (rule.regex) {
|
|
116
|
+
return rule.regex.test(specifier);
|
|
117
|
+
}
|
|
118
|
+
return specifier === rule.module || specifier.startsWith(`${rule.module}/`);
|
|
119
|
+
}
|
|
120
|
+
function judgePackageImport(ref, allow) {
|
|
121
|
+
const rules = allow.filter((rule) => matches(rule, ref.module));
|
|
122
|
+
if (rules.length === 0) {
|
|
123
|
+
return `runtime import of "${ref.module}" is not in settings.imports.allow (allowlist mode allows relative imports, import aliases and listed packages only)`;
|
|
124
|
+
}
|
|
125
|
+
if (rules.some((rule) => rule.names === void 0)) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const allowedNames = rules.flatMap((rule) => rule.names);
|
|
129
|
+
if (ref.whole) {
|
|
130
|
+
return `only ${allowedNames.join(", ")} may be imported from "${ref.module}", as named imports (settings.imports.allow)`;
|
|
131
|
+
}
|
|
132
|
+
const extra = ref.names.filter((name) => !allowedNames.includes(name));
|
|
133
|
+
return extra.length === 0 ? null : `runtime import of ${extra.join(", ")} from "${ref.module}" is not in settings.imports.allow (allowed: ${allowedNames.join(", ")})`;
|
|
134
|
+
}
|
|
135
|
+
function judgeProjectImport(target, impurePaths, gateExtensions) {
|
|
136
|
+
const candidates = [target, ...gateExtensions.map((extension) => target + extension)];
|
|
137
|
+
for (const rule of impurePaths) {
|
|
138
|
+
if (candidates.some((candidate) => rule.regex.test(candidate))) {
|
|
139
|
+
return rule.reason;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/core/scan.ts
|
|
146
|
+
var IGNORE_DIRECTIVE = /\b(istanbul|c8|v8)\s+ignore\b/;
|
|
147
|
+
var DECORATOR = /^\s*@[A-Za-z_$][\w$.]*\s*\(/;
|
|
148
|
+
var STRYKER_COMMENT = /Stryker\s+(disable|restore)\b/;
|
|
149
|
+
var DISABLED_TESTS = [
|
|
150
|
+
{ regex: /\.\s*(skip|only|todo)\b/, label: ".skip / .only / .todo" },
|
|
151
|
+
{
|
|
152
|
+
regex: /\b(?:describe|it|test|suite)(?:\s*\.\s*\w+)*\s*\.\s*(?:skipIf|runIf|fails)\b/,
|
|
153
|
+
label: ".skipIf / .runIf / .fails"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
regex: /\b(xit|xtest|xdescribe|fit|fdescribe)\s*[.(]/,
|
|
157
|
+
label: "xit / xtest / xdescribe / fit / fdescribe"
|
|
158
|
+
}
|
|
159
|
+
];
|
|
160
|
+
var MODULE_MOCK = /\b(?:jest|vi)\s*\.\s*(?:mock|doMock|unstable_mockModule|setMock)\s*\(\s*(?:import\s*\(\s*)?(['"`])([^'"`\n]+)\1/g;
|
|
161
|
+
var SCRIPT_EXTENSION2 = /\.[cm]?[jt]sx?$/;
|
|
162
|
+
function eachLine(source, visit) {
|
|
163
|
+
source.split("\n").forEach((line, index) => visit(line, index + 1));
|
|
164
|
+
}
|
|
165
|
+
function escapeRegExp(text) {
|
|
166
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
167
|
+
}
|
|
168
|
+
function scanGateSource(gatePath, source, settings) {
|
|
169
|
+
const violations = [];
|
|
170
|
+
eachLine(source, (line, lineNumber) => {
|
|
171
|
+
if (DECORATOR.test(line)) {
|
|
172
|
+
violations.push({
|
|
173
|
+
file: gatePath,
|
|
174
|
+
line: lineNumber,
|
|
175
|
+
message: "decorator found (not a pure module). Move the file to candidates"
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (IGNORE_DIRECTIVE.test(line)) {
|
|
179
|
+
violations.push({ file: gatePath, line: lineNumber, message: "coverage ignore directive" });
|
|
180
|
+
}
|
|
181
|
+
if (STRYKER_COMMENT.test(line)) {
|
|
182
|
+
violations.push({
|
|
183
|
+
file: gatePath,
|
|
184
|
+
line: lineNumber,
|
|
185
|
+
message: "Stryker comment (disable / restore). Allow an equivalent mutant in test-gates.json (equivalentMutants) instead"
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
for (const rule of settings.forbiddenSource) {
|
|
189
|
+
if (rule.regex.test(line)) {
|
|
190
|
+
violations.push({ file: gatePath, line: lineNumber, message: rule.reason });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
for (const imported of parseImports(source)) {
|
|
195
|
+
if (imported.typeOnly) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const before = violations.length;
|
|
199
|
+
if (!settings.allowedImports.some((regex) => regex.test(imported.module))) {
|
|
200
|
+
for (const rule of settings.impureImports) {
|
|
201
|
+
if (rule.regex.test(imported.module)) {
|
|
202
|
+
violations.push({
|
|
203
|
+
file: gatePath,
|
|
204
|
+
line: imported.line,
|
|
205
|
+
message: `runtime import of "${imported.module}" (${rule.reason})`
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const rule of settings.impureNamedImports) {
|
|
211
|
+
if (imported.module !== rule.module) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const hit = rule.names.filter(
|
|
215
|
+
(name) => imported.names.includes(name) || imported.whole && new RegExp(`\\b${escapeRegExp(name)}\\b`).test(source)
|
|
216
|
+
);
|
|
217
|
+
if (hit.length > 0) {
|
|
218
|
+
violations.push({
|
|
219
|
+
file: gatePath,
|
|
220
|
+
line: imported.line,
|
|
221
|
+
message: `runtime use of ${hit.join(", ")} from "${rule.module}" (${rule.reason})`
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (settings.importMode === "allowlist" && violations.length === before) {
|
|
226
|
+
const reason = isProjectImport(imported.module, settings.importAliases) ? judgeProjectImport(
|
|
227
|
+
resolveSpecifier(imported.module, gatePath, settings.importAliases),
|
|
228
|
+
settings.impurePaths,
|
|
229
|
+
settings.gateExtensions
|
|
230
|
+
) : judgePackageImport(imported, settings.importAllow);
|
|
231
|
+
if (reason !== null) {
|
|
232
|
+
violations.push({
|
|
233
|
+
file: gatePath,
|
|
234
|
+
line: imported.line,
|
|
235
|
+
message: isProjectImport(imported.module, settings.importAliases) ? `runtime import of "${imported.module}" (${reason})` : reason
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (settings.importMode === "allowlist") {
|
|
241
|
+
for (const line of findUncheckableImports(source)) {
|
|
242
|
+
violations.push({
|
|
243
|
+
file: gatePath,
|
|
244
|
+
line,
|
|
245
|
+
message: "require() / import() with a computed specifier cannot be checked (allowlist mode)"
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return violations.sort((a, b) => a.line - b.line);
|
|
250
|
+
}
|
|
251
|
+
function scanSpecSource(gatePath, specPath, source, settings) {
|
|
252
|
+
const violations = [];
|
|
253
|
+
eachLine(source, (line, lineNumber) => {
|
|
254
|
+
if (IGNORE_DIRECTIVE.test(line)) {
|
|
255
|
+
violations.push({ file: specPath, line: lineNumber, message: "coverage ignore directive" });
|
|
256
|
+
}
|
|
257
|
+
for (const { regex, label } of DISABLED_TESTS) {
|
|
258
|
+
if (regex.test(line)) {
|
|
259
|
+
violations.push({
|
|
260
|
+
file: specPath,
|
|
261
|
+
line: lineNumber,
|
|
262
|
+
message: `${label} (a skipped or focused test)`
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
const gateModule = gatePath.replace(SCRIPT_EXTENSION2, "");
|
|
268
|
+
const gateDirectory = gateModule.replace(/\/index$/, "");
|
|
269
|
+
for (const match of source.matchAll(MODULE_MOCK)) {
|
|
270
|
+
const specifier = match[2];
|
|
271
|
+
const resolved = resolveSpecifier(specifier, specPath, settings.importAliases);
|
|
272
|
+
if (resolved === gateModule || resolved === gateDirectory) {
|
|
273
|
+
let line = 1;
|
|
274
|
+
for (let i = 0; i < match.index; i += 1) {
|
|
275
|
+
if (source[i] === "\n") {
|
|
276
|
+
line += 1;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
violations.push({
|
|
280
|
+
file: specPath,
|
|
281
|
+
line,
|
|
282
|
+
message: `mocks the module under test ("${specifier}")`
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return violations.sort((a, b) => a.line - b.line);
|
|
287
|
+
}
|
|
288
|
+
var STRYKER_WEAKENING = /\b(excludedMutations|ignoreStatic|ignorers)\b/;
|
|
289
|
+
function scanStrykerConfig(configPath, source) {
|
|
290
|
+
const violations = [];
|
|
291
|
+
eachLine(source, (line, lineNumber) => {
|
|
292
|
+
const match = STRYKER_WEAKENING.exec(line);
|
|
293
|
+
if (match) {
|
|
294
|
+
violations.push({
|
|
295
|
+
file: configPath,
|
|
296
|
+
line: lineNumber,
|
|
297
|
+
message: `${match[1]} takes mutants out of the evaluation. Allow equivalent mutants one by one in test-gates.json instead`
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
return violations;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// src/commands/check.ts
|
|
305
|
+
var STRYKER_CONFIG_NAMES = [
|
|
306
|
+
"stryker.gates.config.mjs",
|
|
307
|
+
"stryker.gates.config.js",
|
|
308
|
+
"stryker.gates.config.cjs",
|
|
309
|
+
"stryker.gates.config.json"
|
|
310
|
+
];
|
|
311
|
+
function findStrykerConfig(manifest) {
|
|
312
|
+
if (manifest.settings.strykerConfigFile !== null) {
|
|
313
|
+
return manifest.settings.strykerConfigFile;
|
|
314
|
+
}
|
|
315
|
+
return STRYKER_CONFIG_NAMES.find((name) => fs.existsSync(path.join(manifest.dir, name))) ?? null;
|
|
316
|
+
}
|
|
317
|
+
function isPlainRelativePath(file) {
|
|
318
|
+
return !path.isAbsolute(file) && !/^[A-Za-z]:/.test(file) && !file.includes("\\") && !file.split("/").some((segment) => segment === ".." || segment === "." || segment === "");
|
|
319
|
+
}
|
|
320
|
+
function checkGates(manifest) {
|
|
321
|
+
const { dir, settings } = manifest;
|
|
322
|
+
const violations = [...manifest.violations];
|
|
323
|
+
for (const gate of manifest.gates) {
|
|
324
|
+
if (!isPlainRelativePath(gate.path)) {
|
|
325
|
+
violations.push({
|
|
326
|
+
file: MANIFEST_FILE,
|
|
327
|
+
message: `${gate.path}: write the path relative to the subproject, with "/" and without ".."`
|
|
328
|
+
});
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (gateExtensionOf(gate.path, settings.gateExtensions) === null) {
|
|
332
|
+
violations.push({
|
|
333
|
+
file: gate.path,
|
|
334
|
+
message: `a gate must be one of ${settings.gateExtensions.join(" / ")} (settings.gateExtensions)`
|
|
335
|
+
});
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (looksLikeSpec(gate.path, settings)) {
|
|
339
|
+
violations.push({ file: gate.path, message: "a spec cannot be a gate" });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
for (const rule of settings.impurePaths) {
|
|
343
|
+
if (rule.regex.test(gate.path)) {
|
|
344
|
+
violations.push({
|
|
345
|
+
file: gate.path,
|
|
346
|
+
message: `cannot be a gate (${rule.reason}). Move it to candidates`
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (!existsExact(dir, gate.path)) {
|
|
351
|
+
violations.push({
|
|
352
|
+
file: gate.path,
|
|
353
|
+
message: "file not found (the check is case-sensitive)"
|
|
354
|
+
});
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
violations.push(...scanGateSource(gate.path, readText(dir, gate.path), settings));
|
|
358
|
+
if (gate.specCandidates.length === 0) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (gate.existingSpecs.length === 0) {
|
|
362
|
+
violations.push({
|
|
363
|
+
file: gate.path,
|
|
364
|
+
message: `spec not found (looked for ${gate.specCandidates.join(", ")}; the check is case-sensitive)`
|
|
365
|
+
});
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (gate.existingSpecs.length > 1) {
|
|
369
|
+
violations.push({
|
|
370
|
+
file: gate.path,
|
|
371
|
+
message: `more than one spec (${gate.existingSpecs.join(", ")}). Keep one, or narrow settings.spec.suffixes`
|
|
372
|
+
});
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const specPath = gate.existingSpecs[0];
|
|
376
|
+
violations.push(...scanSpecSource(gate.path, specPath, readText(dir, specPath), settings));
|
|
377
|
+
}
|
|
378
|
+
for (const candidate of manifest.candidates) {
|
|
379
|
+
if (!isPlainRelativePath(candidate.path) || !existsExact(dir, candidate.path, "any")) {
|
|
380
|
+
violations.push({
|
|
381
|
+
file: MANIFEST_FILE,
|
|
382
|
+
message: `${candidate.path}: candidate not found. Remove it, or fix the path`
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const strykerConfig = findStrykerConfig(manifest);
|
|
387
|
+
if (strykerConfig !== null) {
|
|
388
|
+
if (!fs.existsSync(path.join(dir, strykerConfig))) {
|
|
389
|
+
violations.push({ file: strykerConfig, message: "Stryker config not found" });
|
|
390
|
+
} else {
|
|
391
|
+
violations.push(...scanStrykerConfig(strykerConfig, readText(dir, strykerConfig)));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return violations;
|
|
395
|
+
}
|
|
396
|
+
var VITEST_GATE_CONFIGS = ["ts", "mts", "cts", "js", "mjs", "cjs"].map(
|
|
397
|
+
(extension) => `vitest.gates.config.${extension}`
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// src/core/mutation-result.ts
|
|
401
|
+
var DETECTED = /* @__PURE__ */ new Set(["Killed", "Timeout"]);
|
|
402
|
+
var SURVIVED = /* @__PURE__ */ new Set(["Survived", "NoCoverage"]);
|
|
403
|
+
var NOT_EVALUABLE = /* @__PURE__ */ new Set(["RuntimeError", "CompileError"]);
|
|
404
|
+
function normalizeCode(text) {
|
|
405
|
+
return text.replace(/\s+/g, " ").trim();
|
|
406
|
+
}
|
|
407
|
+
function normalizePath(file) {
|
|
408
|
+
return file.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
409
|
+
}
|
|
410
|
+
function normalizeNewlines(text) {
|
|
411
|
+
return text.replace(/\r\n/g, "\n");
|
|
412
|
+
}
|
|
413
|
+
function sliceByLocation(source, location) {
|
|
414
|
+
const lines = source.split("\n");
|
|
415
|
+
const { start, end } = location;
|
|
416
|
+
const first = lines[start.line - 1];
|
|
417
|
+
const last = lines[end.line - 1];
|
|
418
|
+
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) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
if (start.line === end.line) {
|
|
422
|
+
return first.slice(start.column - 1, end.column - 1);
|
|
423
|
+
}
|
|
424
|
+
return [
|
|
425
|
+
first.slice(start.column - 1),
|
|
426
|
+
...lines.slice(start.line, end.line - 1),
|
|
427
|
+
last.slice(0, end.column - 1)
|
|
428
|
+
].join("\n");
|
|
429
|
+
}
|
|
430
|
+
function keyOf(mutator, original, replacement) {
|
|
431
|
+
return JSON.stringify([mutator, normalizeCode(original), normalizeCode(replacement)]);
|
|
432
|
+
}
|
|
433
|
+
function describeMutant(file, entry) {
|
|
434
|
+
const { mutant } = entry;
|
|
435
|
+
return {
|
|
436
|
+
file,
|
|
437
|
+
line: mutant.location.start.line,
|
|
438
|
+
column: mutant.location.start.column,
|
|
439
|
+
status: mutant.status,
|
|
440
|
+
mutator: mutant.mutatorName,
|
|
441
|
+
original: normalizeCode(entry.original),
|
|
442
|
+
replacement: normalizeCode(entry.replacement),
|
|
443
|
+
occurrence: entry.occurrence,
|
|
444
|
+
sameKeyCount: entry.sameKeyCount,
|
|
445
|
+
allowance: {
|
|
446
|
+
mutator: mutant.mutatorName,
|
|
447
|
+
original: normalizeCode(entry.original),
|
|
448
|
+
replacement: normalizeCode(entry.replacement),
|
|
449
|
+
...entry.sameKeyCount > 1 ? { occurrence: entry.occurrence } : {},
|
|
450
|
+
reason: ""
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function describeAllowance(entry) {
|
|
455
|
+
const occurrence = entry.occurrence === void 0 ? "" : ` / occurrence ${entry.occurrence}`;
|
|
456
|
+
return `${entry.mutator} / ${normalizeCode(entry.original)} \u2192 ${normalizeCode(entry.replacement)}${occurrence}`;
|
|
457
|
+
}
|
|
458
|
+
function judgeMutationReport(input) {
|
|
459
|
+
const violations = [];
|
|
460
|
+
const survivors = [];
|
|
461
|
+
const unexpectedTimeouts = [];
|
|
462
|
+
const summary = {
|
|
463
|
+
total: 0,
|
|
464
|
+
detected: 0,
|
|
465
|
+
timeout: 0,
|
|
466
|
+
allowed: 0,
|
|
467
|
+
notEvaluable: 0,
|
|
468
|
+
unallowed: 0,
|
|
469
|
+
expectedTimeout: 0
|
|
470
|
+
};
|
|
471
|
+
const files = /* @__PURE__ */ new Map();
|
|
472
|
+
for (const [file, result] of Object.entries(input.report.files ?? {})) {
|
|
473
|
+
files.set(normalizePath(file), result);
|
|
474
|
+
}
|
|
475
|
+
for (const gate of input.gates) {
|
|
476
|
+
const gatePath = normalizePath(gate.path);
|
|
477
|
+
const shapeViolations = validateEquivalentMutants(gate.path, gate.equivalentMutants);
|
|
478
|
+
violations.push(...shapeViolations);
|
|
479
|
+
violations.push(
|
|
480
|
+
...validateEquivalentMutants(gate.path, gate.expectedTimeouts, "expectedTimeouts")
|
|
481
|
+
);
|
|
482
|
+
const result = files.get(gatePath);
|
|
483
|
+
if (!result || !Array.isArray(result.mutants) || result.mutants.length === 0) {
|
|
484
|
+
violations.push({
|
|
485
|
+
file: gate.path,
|
|
486
|
+
message: "no mutants in the report (the gate was not mutation tested)"
|
|
487
|
+
});
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const diskSource = input.readSource(gate.path);
|
|
491
|
+
const source = result.source ?? diskSource;
|
|
492
|
+
if (source === void 0) {
|
|
493
|
+
violations.push({
|
|
494
|
+
file: gate.path,
|
|
495
|
+
message: "source is neither in the report nor readable on disk"
|
|
496
|
+
});
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (result.source !== void 0 && diskSource !== void 0 && normalizeNewlines(result.source) !== normalizeNewlines(diskSource)) {
|
|
500
|
+
violations.push({
|
|
501
|
+
file: gate.path,
|
|
502
|
+
message: "the report was made from a different version of this file. Run Stryker again"
|
|
503
|
+
});
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const located = [];
|
|
507
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
508
|
+
const sorted = [...result.mutants].sort(
|
|
509
|
+
(a, b) => a.location.start.line - b.location.start.line || a.location.start.column - b.location.start.column
|
|
510
|
+
);
|
|
511
|
+
let locationsValid = true;
|
|
512
|
+
for (const mutant of sorted) {
|
|
513
|
+
const original = sliceByLocation(normalizeNewlines(source), mutant.location);
|
|
514
|
+
if (original === null) {
|
|
515
|
+
violations.push({
|
|
516
|
+
file: gate.path,
|
|
517
|
+
line: mutant.location.start.line,
|
|
518
|
+
message: `the report location of a ${mutant.mutatorName} mutant is outside the source`
|
|
519
|
+
});
|
|
520
|
+
locationsValid = false;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const replacement = mutant.replacement ?? "";
|
|
524
|
+
const key = keyOf(mutant.mutatorName, original, replacement);
|
|
525
|
+
const group = byKey.get(key) ?? [];
|
|
526
|
+
const entry = {
|
|
527
|
+
mutant,
|
|
528
|
+
original,
|
|
529
|
+
replacement,
|
|
530
|
+
occurrence: group.length + 1,
|
|
531
|
+
sameKeyCount: 0
|
|
532
|
+
};
|
|
533
|
+
group.push(entry);
|
|
534
|
+
byKey.set(key, group);
|
|
535
|
+
located.push(entry);
|
|
536
|
+
}
|
|
537
|
+
for (const group of byKey.values()) {
|
|
538
|
+
for (const entry of group) {
|
|
539
|
+
entry.sameKeyCount = group.length;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (!locationsValid) {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const claim = (field, entries, noun, accept) => {
|
|
546
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
547
|
+
if (!Array.isArray(entries)) {
|
|
548
|
+
return claimed;
|
|
549
|
+
}
|
|
550
|
+
entries.forEach((entry, index) => {
|
|
551
|
+
if (!isUsableEquivalentMutant(entry)) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const at = `${gate.path}: ${field}[${index}] (${describeAllowance(entry)})`;
|
|
555
|
+
const group = byKey.get(keyOf(entry.mutator, entry.original, entry.replacement)) ?? [];
|
|
556
|
+
if (entry.occurrence === void 0 && group.length > 1) {
|
|
557
|
+
violations.push({
|
|
558
|
+
file: MANIFEST_FILE,
|
|
559
|
+
message: `${at}: matches ${group.length} mutants in the file. Add "occurrence" (1-based) to pick one`
|
|
560
|
+
});
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const target = group[(entry.occurrence ?? 1) - 1];
|
|
564
|
+
const refusal = target ? accept(target.mutant.status) : `stale ${noun} (no such mutant in this run). Remove it`;
|
|
565
|
+
if (!target || refusal !== null) {
|
|
566
|
+
violations.push({ file: MANIFEST_FILE, message: `${at}: ${refusal}` });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (claimed.has(target)) {
|
|
570
|
+
violations.push({
|
|
571
|
+
file: MANIFEST_FILE,
|
|
572
|
+
message: `${at}: another ${noun} already covers this mutant`
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
claimed.add(target);
|
|
577
|
+
});
|
|
578
|
+
return claimed;
|
|
579
|
+
};
|
|
580
|
+
const allowed = claim(
|
|
581
|
+
"equivalentMutants",
|
|
582
|
+
gate.equivalentMutants,
|
|
583
|
+
"allowance",
|
|
584
|
+
(status) => SURVIVED.has(status) ? null : `stale allowance (the mutant is now ${status}). Remove it`
|
|
585
|
+
);
|
|
586
|
+
const expected = claim(
|
|
587
|
+
"expectedTimeouts",
|
|
588
|
+
gate.expectedTimeouts,
|
|
589
|
+
"entry",
|
|
590
|
+
(status) => SURVIVED.has(status) ? `the mutant ${status === "NoCoverage" ? "has no coverage" : "survived"} instead of timing out. Detect it with a test` : null
|
|
591
|
+
);
|
|
592
|
+
for (const entry of located) {
|
|
593
|
+
const { mutant } = entry;
|
|
594
|
+
summary.total += 1;
|
|
595
|
+
if (DETECTED.has(mutant.status)) {
|
|
596
|
+
summary.detected += 1;
|
|
597
|
+
if (mutant.status === "Timeout") {
|
|
598
|
+
summary.timeout += 1;
|
|
599
|
+
if (expected.has(entry)) {
|
|
600
|
+
summary.expectedTimeout += 1;
|
|
601
|
+
} else {
|
|
602
|
+
unexpectedTimeouts.push(describeMutant(gate.path, entry));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
} else if (NOT_EVALUABLE.has(mutant.status)) {
|
|
606
|
+
summary.notEvaluable += 1;
|
|
607
|
+
} else if (SURVIVED.has(mutant.status)) {
|
|
608
|
+
if (allowed.has(entry)) {
|
|
609
|
+
summary.allowed += 1;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
summary.unallowed += 1;
|
|
613
|
+
survivors.push(describeMutant(gate.path, entry));
|
|
614
|
+
} else {
|
|
615
|
+
violations.push({
|
|
616
|
+
file: gate.path,
|
|
617
|
+
line: mutant.location.start.line,
|
|
618
|
+
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})`
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (summary.total === 0) {
|
|
624
|
+
violations.push({
|
|
625
|
+
file: MANIFEST_FILE,
|
|
626
|
+
message: "no mutants were evaluated (the mutation test ran on nothing)"
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
const maxTimeouts = input.maxTimeouts ?? null;
|
|
630
|
+
if (maxTimeouts !== null && unexpectedTimeouts.length > maxTimeouts) {
|
|
631
|
+
violations.push({
|
|
632
|
+
file: MANIFEST_FILE,
|
|
633
|
+
message: `${unexpectedTimeouts.length} mutant(s) timed out outside expectedTimeouts; settings.mutation.maxTimeouts allows ${maxTimeouts}`
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
return { violations, survivors, unexpectedTimeouts, summary };
|
|
637
|
+
}
|
|
638
|
+
function formatSurvivor(survivor) {
|
|
639
|
+
const occurrence = survivor.sameKeyCount > 1 ? ` / occurrence ${survivor.occurrence} of ${survivor.sameKeyCount}` : "";
|
|
640
|
+
return `${survivor.file}:${survivor.line}:${survivor.column} / ${survivor.mutator} / ${survivor.original} \u2192 ${survivor.replacement}${occurrence} (${survivor.status})`;
|
|
641
|
+
}
|
|
642
|
+
function formatSummary(summary) {
|
|
643
|
+
return `mutants ${summary.total} / detected ${summary.detected} (timeout ${summary.timeout}) / allowed equivalent ${summary.allowed} / not evaluable ${summary.notEvaluable} / unallowed survivors ${summary.unallowed}`;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// src/core/lcov.ts
|
|
647
|
+
function normalizePath2(file, rootDir) {
|
|
648
|
+
let normalized = file.replace(/\\/g, "/");
|
|
649
|
+
if (rootDir !== void 0) {
|
|
650
|
+
const root = rootDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
651
|
+
if (normalized.startsWith(`${root}/`)) {
|
|
652
|
+
normalized = normalized.slice(root.length + 1);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return normalized.replace(/^\.\//, "");
|
|
656
|
+
}
|
|
657
|
+
function parseLcov(text, rootDir) {
|
|
658
|
+
const files = /* @__PURE__ */ new Map();
|
|
659
|
+
let record = null;
|
|
660
|
+
const closeRecord = () => {
|
|
661
|
+
if (record && !record.hasDa) {
|
|
662
|
+
record.file.found += record.found;
|
|
663
|
+
record.file.hit += record.hit;
|
|
664
|
+
}
|
|
665
|
+
record = null;
|
|
666
|
+
};
|
|
667
|
+
for (const rawLine of text.split("\n")) {
|
|
668
|
+
const line = rawLine.trim();
|
|
669
|
+
if (line.startsWith("SF:")) {
|
|
670
|
+
closeRecord();
|
|
671
|
+
const path2 = normalizePath2(line.slice(3), rootDir);
|
|
672
|
+
const file = files.get(path2) ?? { lines: /* @__PURE__ */ new Map(), found: 0, hit: 0 };
|
|
673
|
+
files.set(path2, file);
|
|
674
|
+
record = { file, hasDa: false, found: 0, hit: 0 };
|
|
675
|
+
} else if (line === "end_of_record") {
|
|
676
|
+
closeRecord();
|
|
677
|
+
} else if (record) {
|
|
678
|
+
const open = record;
|
|
679
|
+
if (line.startsWith("DA:")) {
|
|
680
|
+
const [lineNumber, hits] = line.slice(3).split(",");
|
|
681
|
+
const number = Number(lineNumber);
|
|
682
|
+
const count = Number(hits);
|
|
683
|
+
if (Number.isInteger(number) && Number.isFinite(count)) {
|
|
684
|
+
open.hasDa = true;
|
|
685
|
+
open.file.lines.set(number, Math.max(open.file.lines.get(number) ?? 0, count));
|
|
686
|
+
}
|
|
687
|
+
} else if (line.startsWith("LF:")) {
|
|
688
|
+
open.found = Number(line.slice(3)) || 0;
|
|
689
|
+
} else if (line.startsWith("LH:")) {
|
|
690
|
+
open.hit = Number(line.slice(3)) || 0;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
closeRecord();
|
|
695
|
+
const result = /* @__PURE__ */ new Map();
|
|
696
|
+
for (const [file, accumulator] of files) {
|
|
697
|
+
const uncovered = [...accumulator.lines.entries()].filter(([, hits]) => hits <= 0).map(([lineNumber]) => lineNumber).sort((a, b) => a - b);
|
|
698
|
+
result.set(file, {
|
|
699
|
+
found: accumulator.lines.size + accumulator.found,
|
|
700
|
+
hit: accumulator.lines.size - uncovered.length + accumulator.hit,
|
|
701
|
+
uncovered
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
return result;
|
|
705
|
+
}
|
|
706
|
+
function judgeLcov(gatePaths, coverage) {
|
|
707
|
+
const violations = [];
|
|
708
|
+
const results = [];
|
|
709
|
+
for (const gatePath of gatePaths) {
|
|
710
|
+
const file = coverage.get(normalizePath2(gatePath));
|
|
711
|
+
if (!file) {
|
|
712
|
+
violations.push({ file: gatePath, message: "no record in the lcov file" });
|
|
713
|
+
results.push({ path: gatePath, found: 0, hit: 0, ok: false });
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (file.found === 0) {
|
|
717
|
+
violations.push({ file: gatePath, message: "no instrumented lines in the lcov record" });
|
|
718
|
+
results.push({ path: gatePath, found: 0, hit: 0, ok: false });
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const ok = file.hit === file.found;
|
|
722
|
+
if (!ok) {
|
|
723
|
+
const shown = file.uncovered.slice(0, 20).join(", ");
|
|
724
|
+
const more = file.uncovered.length > 20 ? ", \u2026" : "";
|
|
725
|
+
violations.push({
|
|
726
|
+
file: gatePath,
|
|
727
|
+
...file.uncovered[0] === void 0 ? {} : { line: file.uncovered[0] },
|
|
728
|
+
message: `${file.hit}/${file.found} lines covered` + (shown === "" ? "" : ` (uncovered: ${shown}${more})`)
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
results.push({ path: gatePath, found: file.found, hit: file.hit, ok });
|
|
732
|
+
}
|
|
733
|
+
return { violations, results };
|
|
734
|
+
}
|
|
735
|
+
export {
|
|
736
|
+
DEFAULT_FORBIDDEN_SOURCE,
|
|
737
|
+
DEFAULT_GATE_EXTENSIONS,
|
|
738
|
+
DEFAULT_IMPURE_IMPORTS,
|
|
739
|
+
DEFAULT_IMPURE_NAMED_IMPORTS,
|
|
740
|
+
DEFAULT_IMPURE_PATHS,
|
|
741
|
+
DEFAULT_REPORT_FILE,
|
|
742
|
+
EXCLUDE_ENV,
|
|
743
|
+
MANIFEST_FILE,
|
|
744
|
+
ManifestError,
|
|
745
|
+
checkGates,
|
|
746
|
+
formatSummary,
|
|
747
|
+
formatSurvivor,
|
|
748
|
+
gateSpecs,
|
|
749
|
+
judgeLcov,
|
|
750
|
+
judgeMutationReport,
|
|
751
|
+
judgeSelfcheck,
|
|
752
|
+
loadGates,
|
|
753
|
+
normalizeCode,
|
|
754
|
+
parseLcov,
|
|
755
|
+
parseManifest,
|
|
756
|
+
readManifest,
|
|
757
|
+
resolveSettings,
|
|
758
|
+
sliceByLocation
|
|
759
|
+
};
|