@t09tanaka/test-gates 0.3.3 → 0.4.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 +44 -0
- package/README.md +290 -17
- package/dist/{chunk-F7WBEFIL.js → chunk-AK2NTFQO.js} +39 -10
- package/dist/chunk-TOKHAUFX.js +163 -0
- package/dist/cli.js +3129 -501
- package/dist/index.cjs +37 -12
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +4 -3
- package/dist/jest-touch-reporter.cjs +129 -0
- package/dist/jest-touch-reporter.d.cts +19 -0
- package/dist/jest-touch-reporter.d.ts +19 -0
- package/dist/jest-touch-reporter.js +48 -0
- package/dist/jest.cjs +70 -12
- package/dist/jest.js +19 -2
- package/dist/stryker.cjs +29 -10
- package/dist/stryker.d.cts +15 -1
- package/dist/stryker.d.ts +15 -1
- package/dist/stryker.js +10 -3
- package/dist/vitest.cjs +151 -12
- package/dist/vitest.js +22 -4
- package/package.json +7 -2
- /package/dist/{chunk-4HAJIKDZ.js → chunk-C4R3RDHF.js} +0 -0
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { createRequire } from "module";
|
|
4
|
+
import { createRequire as createRequire2 } from "module";
|
|
5
5
|
|
|
6
6
|
// src/core/args.ts
|
|
7
|
-
var COMMANDS = [
|
|
7
|
+
var COMMANDS = [
|
|
8
|
+
"check",
|
|
9
|
+
"mutation",
|
|
10
|
+
"mutation-result",
|
|
11
|
+
"selfcheck",
|
|
12
|
+
"lcov",
|
|
13
|
+
"doctor"
|
|
14
|
+
];
|
|
8
15
|
var UsageError = class extends Error {
|
|
9
16
|
};
|
|
10
|
-
var VALUE_OPTIONS = ["--dir", "--report", "--file"];
|
|
17
|
+
var VALUE_OPTIONS = ["--dir", "--report", "--file", "--gate", "--shard", "--jobs"];
|
|
18
|
+
function parseShard(value) {
|
|
19
|
+
const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(value);
|
|
20
|
+
const index = Number(match?.[1]);
|
|
21
|
+
const total = Number(match?.[2]);
|
|
22
|
+
if (!Number.isSafeInteger(total) || index > total) {
|
|
23
|
+
throw new UsageError(
|
|
24
|
+
`--shard needs <index>/<total> with 1 <= index <= total (for example 2/4), got ${value}`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return { index, total };
|
|
28
|
+
}
|
|
11
29
|
var OPTIONS_BY_COMMAND = {
|
|
12
30
|
check: ["--dir"],
|
|
13
|
-
mutation: ["--dir"],
|
|
31
|
+
mutation: ["--dir", "--gate", "--affected"],
|
|
14
32
|
"mutation-result": ["--dir", "--report"],
|
|
15
|
-
selfcheck: ["--dir", "--all", "--first"],
|
|
16
|
-
lcov: ["--dir", "--file"]
|
|
33
|
+
selfcheck: ["--dir", "--all", "--first", "--gate", "--shard", "--quick", "--jobs"],
|
|
34
|
+
lcov: ["--dir", "--file"],
|
|
35
|
+
doctor: ["--dir"]
|
|
17
36
|
};
|
|
18
37
|
function consume(parsed, argv, index, allowed) {
|
|
19
38
|
const arg = argv[index];
|
|
@@ -41,13 +60,35 @@ function consume(parsed, argv, index, allowed) {
|
|
|
41
60
|
if (value === void 0 || value === "") {
|
|
42
61
|
throw new UsageError(`${name} needs a value`);
|
|
43
62
|
}
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
if (name === "--gate") {
|
|
64
|
+
parsed.gates.push(value);
|
|
65
|
+
} else if (name === "--jobs") {
|
|
66
|
+
if (!/^[1-9]\d{0,2}$/.test(value)) {
|
|
67
|
+
throw new UsageError(`--jobs needs a number from 1 to 999, got ${value}`);
|
|
68
|
+
}
|
|
69
|
+
parsed.jobs = Number(value);
|
|
70
|
+
} else if (name === "--shard") {
|
|
71
|
+
if (parsed.shard !== null) {
|
|
72
|
+
throw new UsageError("--shard can be given only once");
|
|
73
|
+
}
|
|
74
|
+
parsed.shard = parseShard(value);
|
|
75
|
+
} else {
|
|
76
|
+
const key = name.slice(2);
|
|
77
|
+
parsed[key] = value;
|
|
78
|
+
}
|
|
46
79
|
return equals !== -1 ? next : next + 1;
|
|
47
80
|
}
|
|
48
81
|
if (equals !== -1) {
|
|
49
82
|
throw new UsageError(`${name} does not take a value`);
|
|
50
83
|
}
|
|
84
|
+
if (name === "--quick") {
|
|
85
|
+
parsed.quick = true;
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
88
|
+
if (name === "--affected") {
|
|
89
|
+
parsed.affected = true;
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
51
92
|
const mode = name === "--all" ? "all" : "first";
|
|
52
93
|
if (parsed.mode !== null && parsed.mode !== mode) {
|
|
53
94
|
throw new UsageError("--all and --first cannot be combined");
|
|
@@ -72,6 +113,11 @@ function parseArgs(argv) {
|
|
|
72
113
|
report: null,
|
|
73
114
|
file: null,
|
|
74
115
|
mode: null,
|
|
116
|
+
gates: [],
|
|
117
|
+
shard: null,
|
|
118
|
+
quick: false,
|
|
119
|
+
affected: false,
|
|
120
|
+
jobs: null,
|
|
75
121
|
rest: []
|
|
76
122
|
};
|
|
77
123
|
let index = 0;
|
|
@@ -87,6 +133,31 @@ function parseArgs(argv) {
|
|
|
87
133
|
while (index < argv.length) {
|
|
88
134
|
index = consume(parsed, argv, index, allowed);
|
|
89
135
|
}
|
|
136
|
+
const selections = [
|
|
137
|
+
parsed.mode === null ? null : `--${parsed.mode}`,
|
|
138
|
+
parsed.gates.length > 0 ? "--gate" : null,
|
|
139
|
+
parsed.shard === null ? null : "--shard"
|
|
140
|
+
].filter((option) => option !== null);
|
|
141
|
+
if (selections.length > 1) {
|
|
142
|
+
throw new UsageError(`${selections.join(" and ")} cannot be combined`);
|
|
143
|
+
}
|
|
144
|
+
if (parsed.affected && parsed.gates.length > 0) {
|
|
145
|
+
throw new UsageError(
|
|
146
|
+
"--affected and --gate cannot be combined: --gate runs the gates it names, --affected decides which gates run"
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const misplaced = parsed.rest.find((arg) => /^--(gate|shard|quick|jobs)(=|$)/.test(arg));
|
|
150
|
+
if (parsed.command === "selfcheck" && misplaced !== void 0) {
|
|
151
|
+
throw new UsageError(
|
|
152
|
+
`${misplaced.split("=")[0]} after "--" would go to the gate command. Write it before "--"`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const forStryker = parsed.rest.find((arg) => /^--(gate|affected)(=|$)/.test(arg));
|
|
156
|
+
if (forStryker !== void 0) {
|
|
157
|
+
throw new UsageError(
|
|
158
|
+
`${forStryker.split("=")[0]} after "--" would go to Stryker. Write it before "--"`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
90
161
|
if (parsed.command === "selfcheck" && parsed.rest.length === 0 && argv.includes("--")) {
|
|
91
162
|
throw new UsageError('"--" must be followed by the gate command');
|
|
92
163
|
}
|
|
@@ -559,19 +630,22 @@ function vitestCoverageViolation(input) {
|
|
|
559
630
|
// src/fs.ts
|
|
560
631
|
import fs from "fs";
|
|
561
632
|
import path from "path";
|
|
562
|
-
function existsExact(rootDir, relativePath, kind = "file") {
|
|
633
|
+
function existsExact(rootDir, relativePath, kind = "file", listing = /* @__PURE__ */ new Map()) {
|
|
563
634
|
let current = rootDir;
|
|
564
635
|
for (const segment of relativePath.split("/")) {
|
|
565
636
|
if (segment === "" || segment === ".") {
|
|
566
637
|
continue;
|
|
567
638
|
}
|
|
568
|
-
let entries;
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
639
|
+
let entries = listing.get(current);
|
|
640
|
+
if (entries === void 0) {
|
|
641
|
+
try {
|
|
642
|
+
entries = fs.readdirSync(current);
|
|
643
|
+
} catch {
|
|
644
|
+
entries = null;
|
|
645
|
+
}
|
|
646
|
+
listing.set(current, entries);
|
|
573
647
|
}
|
|
574
|
-
if (!entries.includes(segment)) {
|
|
648
|
+
if (entries === null || !entries.includes(segment)) {
|
|
575
649
|
return false;
|
|
576
650
|
}
|
|
577
651
|
current = path.join(current, segment);
|
|
@@ -927,6 +1001,7 @@ function resolveSettings(raw) {
|
|
|
927
1001
|
|
|
928
1002
|
// src/core/selfcheck.ts
|
|
929
1003
|
var EXCLUDE_ENV = "TEST_GATES_EXCLUDE_SPEC";
|
|
1004
|
+
var EXCLUDE_ALL_ENV = "TEST_GATES_EXCLUDE_SPECS";
|
|
930
1005
|
var THRESHOLD_MESSAGE = /threshold|coverage data for/i;
|
|
931
1006
|
function escapeRegExp2(text) {
|
|
932
1007
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -946,6 +1021,197 @@ function judgeSelfcheck(input) {
|
|
|
946
1021
|
const reported = output.split("\n").some((line) => line.includes(gatePath) && THRESHOLD_MESSAGE.test(line));
|
|
947
1022
|
return reported ? null : `the gate failed (exit ${status}) but not on the coverage threshold of ${gatePath}. It may be failing for another reason`;
|
|
948
1023
|
}
|
|
1024
|
+
function selectControls(input) {
|
|
1025
|
+
const { gates, mode, only, shard } = input;
|
|
1026
|
+
if (only.length > 0) {
|
|
1027
|
+
const unknown = only.filter((path13) => !gates.includes(path13));
|
|
1028
|
+
if (unknown.length > 0) {
|
|
1029
|
+
return {
|
|
1030
|
+
error: `--gate ${unknown.join(", ")}: not a gate of test-gates.json (write the path as it is written there)`
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
return { selected: gates.filter((path13) => only.includes(path13)) };
|
|
1034
|
+
}
|
|
1035
|
+
if (shard !== null) {
|
|
1036
|
+
if (shard.total > gates.length) {
|
|
1037
|
+
return {
|
|
1038
|
+
error: `--shard ${shard.index}/${shard.total}: there are only ${gates.length} gate(s). A part without a gate would pass without checking anything`
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
return {
|
|
1042
|
+
selected: gates.filter((_path, position) => position % shard.total === shard.index - 1)
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
return { selected: mode === "first" ? gates.slice(0, 1) : gates };
|
|
1046
|
+
}
|
|
1047
|
+
var VERIFIED_RUNS = [
|
|
1048
|
+
{ runner: "jest", versions: ["30.5"], provider: "babel", pools: ["default"] },
|
|
1049
|
+
{ runner: "vitest", versions: ["3.2"], provider: "istanbul", pools: ["forks", "threads"] }
|
|
1050
|
+
];
|
|
1051
|
+
function minorOf(version) {
|
|
1052
|
+
const match = /^(\d+\.\d+)\./.exec(version);
|
|
1053
|
+
return match === null ? version : match[1];
|
|
1054
|
+
}
|
|
1055
|
+
function unverifiedVersion(runner, version, table = VERIFIED_RUNS) {
|
|
1056
|
+
const known = table.find((entry) => entry.runner === runner);
|
|
1057
|
+
if (known === void 0) {
|
|
1058
|
+
return `the shared run is not verified with ${runner}`;
|
|
1059
|
+
}
|
|
1060
|
+
return known.versions.includes(minorOf(version)) ? null : `the shared run is verified with ${runner} ${known.versions.join(", ")}, not with ${version}`;
|
|
1061
|
+
}
|
|
1062
|
+
var SURROUNDINGS = /\bprocess\s*\.\s*env\b|\bimport\.meta\.env\b|['"`](?:node:)?(?:fs|fs\/promises|child_process|worker_threads|net|http|https)['"`]/;
|
|
1063
|
+
function inCi(env) {
|
|
1064
|
+
const ci = (env.CI ?? "").trim().toLowerCase();
|
|
1065
|
+
return ci !== "" && ci !== "false" && ci !== "0";
|
|
1066
|
+
}
|
|
1067
|
+
function quickRefused(env) {
|
|
1068
|
+
return inCi(env) ? "--quick is refused in CI (the variable CI is set): what it reports as holding is not the final check. Run selfcheck without it, with --shard to split it over jobs" : null;
|
|
1069
|
+
}
|
|
1070
|
+
function gateRunRefused(env) {
|
|
1071
|
+
return inCi(env) ? "--gate is refused in CI (the variable CI is set): a mutation run of a part of the gates is not the final check. Run mutation without it" : null;
|
|
1072
|
+
}
|
|
1073
|
+
function affectedRefused(env) {
|
|
1074
|
+
return inCi(env) ? "CI is set, and there every mutant is run (the final check)" : null;
|
|
1075
|
+
}
|
|
1076
|
+
function gateRunCaveat(judged, all) {
|
|
1077
|
+
return `. Only ${judged} of ${all} gate(s) were run and judged (--gate), and a change in one gate can make a mutant of another survive: run without --gate for the final check`;
|
|
1078
|
+
}
|
|
1079
|
+
function quickCaveat(shared) {
|
|
1080
|
+
return shared === 0 ? "" : `. Of the controls that held, ${shared} came from one shared run (--quick), which a spec that depends on other specs can make differ from a run per gate: run without --quick for the final check`;
|
|
1081
|
+
}
|
|
1082
|
+
function sharedRunPrecheck(input) {
|
|
1083
|
+
const reaching = input.specs.find((spec) => SURROUNDINGS.test(spec.source));
|
|
1084
|
+
if (reaching !== void 0) {
|
|
1085
|
+
return `${reaching.path} reads the environment or files, so what it executes may depend on which other specs ran`;
|
|
1086
|
+
}
|
|
1087
|
+
const runner = ["jest", "vitest"].find((name) => name === input.program);
|
|
1088
|
+
if (runner === void 0) {
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
if (input.version !== null) {
|
|
1092
|
+
const unverified = unverifiedVersion(runner, input.version);
|
|
1093
|
+
if (unverified !== null) {
|
|
1094
|
+
return unverified;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (input.configSource === null) {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
const helper = runner === "jest" ? "createJestGatesConfig" : "createVitestGatesConfig";
|
|
1101
|
+
if (!input.configSource.includes(helper)) {
|
|
1102
|
+
return `the gate config does not use ${helper}`;
|
|
1103
|
+
}
|
|
1104
|
+
if (runner === "vitest" && !input.configSource.includes("istanbul")) {
|
|
1105
|
+
return "Vitest measures with the v8 provider; the shared run needs coverageProvider: 'istanbul'";
|
|
1106
|
+
}
|
|
1107
|
+
return null;
|
|
1108
|
+
}
|
|
1109
|
+
function unverifiedRun(run) {
|
|
1110
|
+
if (run.problem !== null) {
|
|
1111
|
+
return run.problem;
|
|
1112
|
+
}
|
|
1113
|
+
const version = unverifiedVersion(run.runner, run.version);
|
|
1114
|
+
if (version !== null) {
|
|
1115
|
+
return version;
|
|
1116
|
+
}
|
|
1117
|
+
const known = VERIFIED_RUNS.find(
|
|
1118
|
+
(entry) => entry.runner === run.runner
|
|
1119
|
+
);
|
|
1120
|
+
if (known.provider !== run.provider) {
|
|
1121
|
+
return `the shared run is not verified with the coverage provider ${run.provider}`;
|
|
1122
|
+
}
|
|
1123
|
+
if (!known.pools.includes(run.pool)) {
|
|
1124
|
+
return `the shared run is not verified with the pool ${run.pool}`;
|
|
1125
|
+
}
|
|
1126
|
+
if (!run.isolate) {
|
|
1127
|
+
return "the shared run is not verified with spec files that share their modules (isolate: false)";
|
|
1128
|
+
}
|
|
1129
|
+
if (run.projects !== 1) {
|
|
1130
|
+
return `the shared run is not verified with ${run.projects} projects in one run`;
|
|
1131
|
+
}
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
function sorted(counts) {
|
|
1135
|
+
return [...counts].sort((left, right) => left - right);
|
|
1136
|
+
}
|
|
1137
|
+
function same(left, right) {
|
|
1138
|
+
return left.every((count, index) => count === right[index]);
|
|
1139
|
+
}
|
|
1140
|
+
function contained(part, whole) {
|
|
1141
|
+
const left = sorted(whole);
|
|
1142
|
+
return sorted(part).every((count) => {
|
|
1143
|
+
const position = left.indexOf(count);
|
|
1144
|
+
if (position === -1) {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
left.splice(position, 1);
|
|
1148
|
+
return true;
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
function accountedFor(total, parts) {
|
|
1152
|
+
const first = parts[0];
|
|
1153
|
+
if (total === void 0 || first === void 0) {
|
|
1154
|
+
return "unknown";
|
|
1155
|
+
}
|
|
1156
|
+
const lined = parts.every(
|
|
1157
|
+
(part) => part.s.length === first.s.length && part.f.length === first.f.length && part.b.length === first.b.length
|
|
1158
|
+
);
|
|
1159
|
+
if (!lined || total.f.length !== first.f.length || total.b.length !== first.b.length) {
|
|
1160
|
+
return "unknown";
|
|
1161
|
+
}
|
|
1162
|
+
const sum = (key) => first[key].map(
|
|
1163
|
+
(_count, index) => parts.reduce((added, part) => added + part[key][index], 0)
|
|
1164
|
+
);
|
|
1165
|
+
return same(sorted(total.f), sorted(sum("f"))) && same(sorted(total.b), sorted(sum("b"))) && contained(total.s, sum("s")) ? "yes" : "no";
|
|
1166
|
+
}
|
|
1167
|
+
function planControls(input) {
|
|
1168
|
+
const { all, targets, run, notes, totals } = input;
|
|
1169
|
+
const everyoneAlone = (reason) => ({ together: [], alone: targets, reason });
|
|
1170
|
+
if (input.failurePattern && !input.failurePattern.includes("{gate}")) {
|
|
1171
|
+
return everyoneAlone("settings.selfcheck.failurePattern does not contain {gate}");
|
|
1172
|
+
}
|
|
1173
|
+
if (run === null || notes === null || totals === null) {
|
|
1174
|
+
return everyoneAlone(
|
|
1175
|
+
"the gate run left no notes (the gate config does not use the helpers of this package, or Vitest measures with the v8 provider)"
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
const unverified = unverifiedRun(run);
|
|
1179
|
+
if (unverified !== null) {
|
|
1180
|
+
return everyoneAlone(unverified);
|
|
1181
|
+
}
|
|
1182
|
+
const silent = all.find((gate) => !notes.some((note) => note.spec === gate.specFile));
|
|
1183
|
+
if (silent !== void 0) {
|
|
1184
|
+
return everyoneAlone(`the gate run left no note for the spec of ${silent.path}`);
|
|
1185
|
+
}
|
|
1186
|
+
const partsOf = (gate) => notes.flatMap((note) => gate.file in note.files ? [note.files[gate.file]] : []);
|
|
1187
|
+
const short = all.find((gate) => accountedFor(totals[gate.file], partsOf(gate)) === "no");
|
|
1188
|
+
if (short !== void 0) {
|
|
1189
|
+
return everyoneAlone(
|
|
1190
|
+
`the notes of the specs do not add up to what the runner counted for ${short.path}`
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
const together = targets.filter((target) => {
|
|
1194
|
+
const gate = all.find((candidate) => candidate.path === target);
|
|
1195
|
+
if (gate === void 0) {
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
if (all.some((other) => other.path !== gate.path && other.path.includes(gate.path))) {
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1201
|
+
if (accountedFor(totals[gate.file], partsOf(gate)) !== "yes") {
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1204
|
+
const touching = notes.filter((note) => {
|
|
1205
|
+
const counters = note.files[gate.file];
|
|
1206
|
+
return counters !== void 0 && [...counters.s, ...counters.f, ...counters.b].some((count) => count !== 0);
|
|
1207
|
+
}).map((note) => note.spec);
|
|
1208
|
+
return touching.length === 1 && touching.includes(gate.specFile);
|
|
1209
|
+
});
|
|
1210
|
+
if (together.length < 2) {
|
|
1211
|
+
return everyoneAlone("fewer than two gates are touched by their own spec only");
|
|
1212
|
+
}
|
|
1213
|
+
return { together, alone: targets.filter((target) => !together.includes(target)), reason: null };
|
|
1214
|
+
}
|
|
949
1215
|
|
|
950
1216
|
// src/load.ts
|
|
951
1217
|
function readManifest(dir) {
|
|
@@ -970,9 +1236,12 @@ function readManifest(dir) {
|
|
|
970
1236
|
}
|
|
971
1237
|
throw error;
|
|
972
1238
|
}
|
|
1239
|
+
const listing = /* @__PURE__ */ new Map();
|
|
973
1240
|
const gates = parsed.gates.map((gate) => {
|
|
974
1241
|
const specCandidates = specCandidatesFor(gate.path, settings);
|
|
975
|
-
const existingSpecs = specCandidates.filter(
|
|
1242
|
+
const existingSpecs = specCandidates.filter(
|
|
1243
|
+
(candidate) => existsExact(absoluteDir, candidate, "file", listing)
|
|
1244
|
+
);
|
|
976
1245
|
return {
|
|
977
1246
|
...gate,
|
|
978
1247
|
specCandidates,
|
|
@@ -1020,6 +1289,7 @@ function isPlainRelativePath(file) {
|
|
|
1020
1289
|
function checkGates(manifest) {
|
|
1021
1290
|
const { dir, settings } = manifest;
|
|
1022
1291
|
const violations = [...manifest.violations];
|
|
1292
|
+
const listing = /* @__PURE__ */ new Map();
|
|
1023
1293
|
for (const gate of manifest.gates) {
|
|
1024
1294
|
if (!isPlainRelativePath(gate.path)) {
|
|
1025
1295
|
violations.push({
|
|
@@ -1047,7 +1317,7 @@ function checkGates(manifest) {
|
|
|
1047
1317
|
});
|
|
1048
1318
|
}
|
|
1049
1319
|
}
|
|
1050
|
-
if (!existsExact(dir, gate.path)) {
|
|
1320
|
+
if (!existsExact(dir, gate.path, "file", listing)) {
|
|
1051
1321
|
violations.push({
|
|
1052
1322
|
file: gate.path,
|
|
1053
1323
|
message: "file not found (the check is case-sensitive)"
|
|
@@ -1076,7 +1346,7 @@ function checkGates(manifest) {
|
|
|
1076
1346
|
violations.push(...scanSpecSource(gate.path, specPath, readText(dir, specPath), settings));
|
|
1077
1347
|
}
|
|
1078
1348
|
for (const candidate of manifest.candidates) {
|
|
1079
|
-
if (!isPlainRelativePath(candidate.path) || !existsExact(dir, candidate.path, "any")) {
|
|
1349
|
+
if (!isPlainRelativePath(candidate.path) || !existsExact(dir, candidate.path, "any", listing)) {
|
|
1080
1350
|
violations.push({
|
|
1081
1351
|
file: MANIFEST_FILE,
|
|
1082
1352
|
message: `${candidate.path}: candidate not found. Remove it, or fix the path`
|
|
@@ -1138,548 +1408,2621 @@ function runCheck(dir, io2) {
|
|
|
1138
1408
|
return 0;
|
|
1139
1409
|
}
|
|
1140
1410
|
|
|
1141
|
-
// src/commands/
|
|
1142
|
-
import
|
|
1143
|
-
import
|
|
1411
|
+
// src/commands/doctor.ts
|
|
1412
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
1413
|
+
import fs9 from "fs";
|
|
1414
|
+
import path9 from "path";
|
|
1144
1415
|
|
|
1145
|
-
// src/core/
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1416
|
+
// src/core/reuse-guard.ts
|
|
1417
|
+
var RECORD_FORMAT = 2;
|
|
1418
|
+
function isStringRecord(value) {
|
|
1419
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
1420
|
+
}
|
|
1421
|
+
var isStringList = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
1422
|
+
function parseAffected(value) {
|
|
1423
|
+
const { tests, gates } = value ?? {};
|
|
1424
|
+
if (!isStringList(tests) || typeof gates !== "object" || gates === null || Array.isArray(gates)) {
|
|
1425
|
+
return null;
|
|
1426
|
+
}
|
|
1427
|
+
const parsed = /* @__PURE__ */ Object.create(null);
|
|
1428
|
+
for (const [gate, entry] of Object.entries(gates)) {
|
|
1429
|
+
const { specs, files } = entry ?? {};
|
|
1430
|
+
if (!isStringList(specs) || !isStringList(files)) {
|
|
1431
|
+
return null;
|
|
1152
1432
|
}
|
|
1433
|
+
parsed[gate] = { specs, files };
|
|
1153
1434
|
}
|
|
1154
|
-
return
|
|
1435
|
+
return { tests, gates: parsed };
|
|
1155
1436
|
}
|
|
1156
|
-
function
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1437
|
+
function parseRecord(value) {
|
|
1438
|
+
const { format, incremental, snapshot, affected } = value ?? {};
|
|
1439
|
+
if (format !== RECORD_FORMAT || typeof incremental !== "string") {
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
if (typeof snapshot !== "object" || snapshot === null) {
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
const { mode, environment, args, files } = snapshot;
|
|
1446
|
+
if (mode !== "git" && mode !== "walk") {
|
|
1447
|
+
return null;
|
|
1448
|
+
}
|
|
1449
|
+
if (!isStringList(args) || !isStringRecord(environment) || !isStringRecord(files)) {
|
|
1450
|
+
return null;
|
|
1451
|
+
}
|
|
1452
|
+
const record = { format, incremental, snapshot: { mode, environment, args, files } };
|
|
1453
|
+
if (affected !== void 0) {
|
|
1454
|
+
const parsed = parseAffected(affected);
|
|
1455
|
+
if (parsed === null) {
|
|
1456
|
+
return null;
|
|
1163
1457
|
}
|
|
1164
|
-
record =
|
|
1458
|
+
record.affected = parsed;
|
|
1459
|
+
}
|
|
1460
|
+
return record;
|
|
1461
|
+
}
|
|
1462
|
+
function listed(names) {
|
|
1463
|
+
const shown = names.slice(0, 3).join(", ");
|
|
1464
|
+
return names.length > 3 ? `${shown} and ${names.length - 3} more` : shown;
|
|
1465
|
+
}
|
|
1466
|
+
function differingKeys(before, after) {
|
|
1467
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])].filter((key) => before[key] !== after[key]).sort();
|
|
1468
|
+
}
|
|
1469
|
+
function readRecord(input) {
|
|
1470
|
+
if (input.record === void 0) {
|
|
1471
|
+
return { refused: "there is no record of the run that wrote the incremental file" };
|
|
1472
|
+
}
|
|
1473
|
+
const record = parseRecord(input.record);
|
|
1474
|
+
if (record === null) {
|
|
1475
|
+
return { refused: "the record of the last run is not one this version can read" };
|
|
1476
|
+
}
|
|
1477
|
+
if (record.incremental !== input.incremental) {
|
|
1478
|
+
return { refused: "the incremental file is not the one the last recorded run wrote" };
|
|
1479
|
+
}
|
|
1480
|
+
const before = record.snapshot;
|
|
1481
|
+
const now = input.snapshot;
|
|
1482
|
+
if (before.mode !== now.mode) {
|
|
1483
|
+
return {
|
|
1484
|
+
refused: `the files are listed in another way than in the last run (${before.mode}, now ${now.mode})`
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
const environment = differingKeys(before.environment, now.environment);
|
|
1488
|
+
if (environment.length > 0) {
|
|
1489
|
+
const changes = environment.map(
|
|
1490
|
+
(key) => `${key} ${before.environment[key] ?? "none"} \u2192 ${now.environment[key] ?? "none"}`
|
|
1491
|
+
);
|
|
1492
|
+
return { refused: `the tools changed since the last run: ${listed(changes)}` };
|
|
1493
|
+
}
|
|
1494
|
+
if (JSON.stringify(before.args) !== JSON.stringify(now.args)) {
|
|
1495
|
+
return { refused: "the arguments for Stryker are not those of the last run" };
|
|
1496
|
+
}
|
|
1497
|
+
return { record };
|
|
1498
|
+
}
|
|
1499
|
+
function refuseReuse(input) {
|
|
1500
|
+
const read2 = readRecord(input);
|
|
1501
|
+
if ("refused" in read2) {
|
|
1502
|
+
return read2.refused;
|
|
1503
|
+
}
|
|
1504
|
+
const files = differingKeys(read2.record.snapshot.files, input.snapshot.files);
|
|
1505
|
+
if (files.length > 0) {
|
|
1506
|
+
return `changed since the last run: ${listed(files)}`;
|
|
1507
|
+
}
|
|
1508
|
+
return null;
|
|
1509
|
+
}
|
|
1510
|
+
function incrementalOptions(config, args) {
|
|
1511
|
+
const options = typeof config === "object" && config !== null ? config : {};
|
|
1512
|
+
const valueOf = (name) => {
|
|
1513
|
+
let value;
|
|
1514
|
+
args.forEach((arg, position) => {
|
|
1515
|
+
if (arg === name) {
|
|
1516
|
+
value = args[position + 1];
|
|
1517
|
+
} else if (arg.startsWith(`${name}=`)) {
|
|
1518
|
+
value = arg.slice(name.length + 1);
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
return value;
|
|
1165
1522
|
};
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1523
|
+
const fromConfig = (name, fallback) => typeof options[name] === "string" ? options[name] : fallback;
|
|
1524
|
+
return {
|
|
1525
|
+
incremental: options.incremental === true || args.includes("--incremental"),
|
|
1526
|
+
force: options.force === true || args.includes("--force"),
|
|
1527
|
+
incrementalFile: valueOf("--incrementalFile") ?? fromConfig("incrementalFile", "reports/stryker-incremental.json"),
|
|
1528
|
+
tempDirName: valueOf("--tempDirName") ?? fromConfig("tempDirName", ".stryker-tmp")
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
function comparableArgs(args) {
|
|
1532
|
+
return args.filter((arg) => arg !== "--force");
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// src/core/affected.ts
|
|
1536
|
+
var CONFIG_NAMES = [
|
|
1537
|
+
/^package\.json$/,
|
|
1538
|
+
/^(?:package-lock|npm-shrinkwrap)\.json$/,
|
|
1539
|
+
/^test-gates\.json$/,
|
|
1540
|
+
/^[jt]sconfig(?:\..*)?\.json$/,
|
|
1541
|
+
/\.config\.[^.]+$/,
|
|
1542
|
+
/\.config\.[^.]+\.[^.]+$/,
|
|
1543
|
+
/^(?:jest|vitest|vite|stryker|babel)[.-]/,
|
|
1544
|
+
/^\./,
|
|
1545
|
+
// Declarations need no import to take effect.
|
|
1546
|
+
/\.d\.[cm]?ts$/
|
|
1547
|
+
];
|
|
1548
|
+
var CONFIG_DIRECTORIES = ["__mocks__", "node_modules"];
|
|
1549
|
+
var DOCUMENT = /\.(?:md|markdown|txt|rst|adoc)$/i;
|
|
1550
|
+
var PASSIVE = /\.(?:[cm]?[jt]sx?|json|snap|css|scss|sass|less|svg|png|jpe?g|gif|webp|avif|ico)$/i;
|
|
1551
|
+
function kindOf(key, subproject) {
|
|
1552
|
+
const inside = subproject === "" || key.startsWith(`${subproject}/`);
|
|
1553
|
+
const local = inside && subproject !== "" ? key.slice(subproject.length + 1) : key;
|
|
1554
|
+
const segments = local.split("/");
|
|
1555
|
+
const name = segments[segments.length - 1];
|
|
1556
|
+
if (segments.some((segment) => CONFIG_DIRECTORIES.includes(segment)) || CONFIG_NAMES.some((pattern) => pattern.test(name))) {
|
|
1557
|
+
return "config";
|
|
1558
|
+
}
|
|
1559
|
+
if (DOCUMENT.test(name)) {
|
|
1560
|
+
return "document";
|
|
1561
|
+
}
|
|
1562
|
+
if (!inside || segments.length === 1 || !PASSIVE.test(name)) {
|
|
1563
|
+
return "other";
|
|
1564
|
+
}
|
|
1565
|
+
return "passive";
|
|
1566
|
+
}
|
|
1567
|
+
function gateDependencies(input) {
|
|
1568
|
+
const closureOf = (key) => Object.hasOwn(input.closures, key) ? input.closures[key] : [key];
|
|
1569
|
+
const result = /* @__PURE__ */ Object.create(null);
|
|
1570
|
+
for (const gate of input.gates) {
|
|
1571
|
+
const covering = Object.hasOwn(input.covering, gate.key) ? input.covering[gate.key] : [];
|
|
1572
|
+
const specs = /* @__PURE__ */ new Set([...gate.specs, ...covering]);
|
|
1573
|
+
for (const test of input.tests) {
|
|
1574
|
+
if (closureOf(test).includes(gate.key)) {
|
|
1575
|
+
specs.add(test);
|
|
1190
1576
|
}
|
|
1191
1577
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
result.set(file, {
|
|
1198
|
-
found: accumulator.lines.size + accumulator.found,
|
|
1199
|
-
hit: accumulator.lines.size - uncovered.length + accumulator.hit,
|
|
1200
|
-
uncovered
|
|
1201
|
-
});
|
|
1578
|
+
const files = new Set(closureOf(gate.key));
|
|
1579
|
+
for (const spec of specs) {
|
|
1580
|
+
closureOf(spec).forEach((file) => files.add(file));
|
|
1581
|
+
}
|
|
1582
|
+
result[gate.key] = { specs: [...specs].sort(), files: [...files].sort() };
|
|
1202
1583
|
}
|
|
1203
1584
|
return result;
|
|
1204
1585
|
}
|
|
1205
|
-
function
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1586
|
+
function planAffected(input) {
|
|
1587
|
+
const recorded = input.before.affected;
|
|
1588
|
+
if (recorded === void 0) {
|
|
1589
|
+
return {
|
|
1590
|
+
full: "the last run did not write down what each gate depends on (it ran without --affected)"
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
const changed = differingKeys(input.before.files, input.now);
|
|
1594
|
+
const reached = new Set(input.gates.flatMap((gate) => gate.files));
|
|
1595
|
+
Object.values(recorded.gates).forEach((gate) => gate.files.forEach((file) => reached.add(file)));
|
|
1596
|
+
const config = new Set(input.config);
|
|
1597
|
+
const everywhere = changed.filter((key) => {
|
|
1598
|
+
const kind = kindOf(key, input.subproject);
|
|
1599
|
+
return config.has(key) || kind === "config" || kind === "other" && !reached.has(key);
|
|
1600
|
+
});
|
|
1601
|
+
if (everywhere.length > 0) {
|
|
1602
|
+
return { full: `changed since the last run, and may affect every gate: ${listed(everywhere)}` };
|
|
1603
|
+
}
|
|
1604
|
+
const rerun = [];
|
|
1605
|
+
const reused = [];
|
|
1606
|
+
for (const gate of input.gates) {
|
|
1607
|
+
const last = Object.hasOwn(recorded.gates, gate.path) ? recorded.gates[gate.path] : void 0;
|
|
1608
|
+
if (last === void 0) {
|
|
1609
|
+
rerun.push({ gate: gate.path, changed: [] });
|
|
1218
1610
|
continue;
|
|
1219
1611
|
}
|
|
1220
|
-
const
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
...file.uncovered[0] === void 0 ? {} : { line: file.uncovered[0] },
|
|
1227
|
-
message: `${file.hit}/${file.found} lines covered` + (shown === "" ? "" : ` (uncovered: ${shown}${more})`)
|
|
1228
|
-
});
|
|
1612
|
+
const files = [.../* @__PURE__ */ new Set([...last.files, ...gate.files])];
|
|
1613
|
+
const differing = files.filter((file) => input.before.files[file] !== input.now[file]).sort();
|
|
1614
|
+
if (differing.length > 0) {
|
|
1615
|
+
rerun.push({ gate: gate.path, changed: differing });
|
|
1616
|
+
} else {
|
|
1617
|
+
reused.push(gate.path);
|
|
1229
1618
|
}
|
|
1230
|
-
results.push({ path: gatePath, found: file.found, hit: file.hit, ok });
|
|
1231
1619
|
}
|
|
1232
|
-
return {
|
|
1620
|
+
return { rerun, reused };
|
|
1233
1621
|
}
|
|
1234
|
-
function
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1622
|
+
function withoutDots(file) {
|
|
1623
|
+
return file.split("/").filter((part) => part !== ".").join("/");
|
|
1624
|
+
}
|
|
1625
|
+
function timeoutRanges(last, gates) {
|
|
1626
|
+
const ranges = [];
|
|
1627
|
+
const files = last.files ?? {};
|
|
1628
|
+
for (const named of gates) {
|
|
1629
|
+
const gate = withoutDots(named);
|
|
1630
|
+
const mutants = Object.hasOwn(files, gate) ? files[gate]?.mutants : void 0;
|
|
1631
|
+
if (mutants === void 0) {
|
|
1239
1632
|
continue;
|
|
1240
1633
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1634
|
+
for (const mutant of mutants) {
|
|
1635
|
+
if (mutant.status === "Timeout") {
|
|
1636
|
+
const { start, end } = mutant.location;
|
|
1637
|
+
ranges.push(`${gate}:${start.line}:${start.column - 1}-${end.line}:${end.column}`);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1243
1640
|
}
|
|
1244
|
-
return
|
|
1641
|
+
return [...new Set(ranges)];
|
|
1245
1642
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
if (file === null) {
|
|
1252
|
-
throw new ManifestError(
|
|
1253
|
-
"no lcov file given. Pass --file <lcov.info> or set settings.lcov.file"
|
|
1254
|
-
);
|
|
1643
|
+
function describePlan(plan, shown = 5, timeouts = 0) {
|
|
1644
|
+
if ("full" in plan) {
|
|
1645
|
+
return [
|
|
1646
|
+
`test-gates mutation --affected: running every mutant again (--force), the results of the last run are not reused: ${plan.full}`
|
|
1647
|
+
];
|
|
1255
1648
|
}
|
|
1256
|
-
const
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1649
|
+
const total = plan.rerun.length + plan.reused.length;
|
|
1650
|
+
const lines = [
|
|
1651
|
+
`test-gates mutation --affected: ${plan.reused.length} of ${total} gate(s) reused, ${plan.rerun.length} run again`
|
|
1652
|
+
];
|
|
1653
|
+
for (const { gate, changed } of plan.rerun.slice(0, shown)) {
|
|
1654
|
+
lines.push(
|
|
1655
|
+
changed.length === 0 ? ` ${gate}: the last run left no result of it that can be reused` : ` ${gate}: changed ${listed(changed)}`
|
|
1263
1656
|
);
|
|
1264
|
-
return 1;
|
|
1265
1657
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
const exists = existsExact(manifest.dir, gate.path);
|
|
1269
|
-
if (!exists) {
|
|
1270
|
-
violations.push({
|
|
1271
|
-
file: gate.path,
|
|
1272
|
-
message: "file not found (the check is case-sensitive). Update test-gates.json if it moved"
|
|
1273
|
-
});
|
|
1274
|
-
}
|
|
1275
|
-
return exists;
|
|
1276
|
-
});
|
|
1277
|
-
const coverage = parseLcov(text, manifest.dir);
|
|
1278
|
-
const total = summarizeLcov(coverage, manifest.settings.lcovSummaryExclude);
|
|
1279
|
-
io2.out(
|
|
1280
|
-
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)`
|
|
1281
|
-
);
|
|
1282
|
-
const verdict = judgeLcov(
|
|
1283
|
-
existing.map((gate) => gate.path),
|
|
1284
|
-
coverage
|
|
1285
|
-
);
|
|
1286
|
-
for (const result of verdict.results) {
|
|
1287
|
-
if (result.ok) {
|
|
1288
|
-
io2.out(` \u2713 ${result.path}: ${result.hit}/${result.found} lines`);
|
|
1289
|
-
}
|
|
1658
|
+
if (plan.rerun.length > shown) {
|
|
1659
|
+
lines.push(` and ${plan.rerun.length - shown} more`);
|
|
1290
1660
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
printViolations(io2, "lcov", violations);
|
|
1294
|
-
return 1;
|
|
1661
|
+
if (timeouts > 0) {
|
|
1662
|
+
lines.push(` and ${timeouts} mutant(s) of reused gates that timed out in the last run`);
|
|
1295
1663
|
}
|
|
1296
|
-
|
|
1297
|
-
return 0;
|
|
1664
|
+
return lines;
|
|
1298
1665
|
}
|
|
1299
1666
|
|
|
1300
|
-
// src/
|
|
1301
|
-
|
|
1302
|
-
|
|
1667
|
+
// src/core/doctor.ts
|
|
1668
|
+
var NOT_THE_HELPER = "the runner config is not made with";
|
|
1669
|
+
function diagnose(facts) {
|
|
1670
|
+
const findings = [];
|
|
1671
|
+
if (!facts.incremental) {
|
|
1672
|
+
findings.push({
|
|
1673
|
+
level: "problem",
|
|
1674
|
+
text: "Stryker does not run in incremental mode: every run is one of every mutant",
|
|
1675
|
+
fix: "make the Stryker config with createStrykerGatesConfig, or set incremental: true"
|
|
1676
|
+
});
|
|
1677
|
+
return findings;
|
|
1678
|
+
}
|
|
1679
|
+
if (facts.forced) {
|
|
1680
|
+
findings.push({
|
|
1681
|
+
level: "problem",
|
|
1682
|
+
text: "the Stryker config sets force: every run is one of every mutant",
|
|
1683
|
+
fix: "remove force from the Stryker config; pass -- --force where a full run is meant (CI)"
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
if (facts.failure !== null) {
|
|
1687
|
+
findings.push({
|
|
1688
|
+
level: "problem",
|
|
1689
|
+
text: `the results of a run are never reused: ${facts.failure}`,
|
|
1690
|
+
fix: facts.failure.includes("points outside") ? "remove the path, or let it lead into the repository. A path that leads outside of it to nothing is compared as missing" : null
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
if (facts.widened.length > 0) {
|
|
1694
|
+
findings.push({
|
|
1695
|
+
level: "problem",
|
|
1696
|
+
text: `every file of the repository is compared, so a change anywhere in it makes the next run a full one. Because of: ${facts.widened.join("; ")}`,
|
|
1697
|
+
fix: "write such a path from the directory of its file (path.resolve(__dirname, \u2026)) so that it can be followed, or keep it out of the files the run loads"
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
if (facts.scope !== null && "every" in facts.scope) {
|
|
1701
|
+
findings.push({
|
|
1702
|
+
level: "note",
|
|
1703
|
+
text: `the paths in every file of the subproject are followed, also in files the run does not load: ${facts.scope.every}`,
|
|
1704
|
+
fix: facts.scope.every.startsWith(NOT_THE_HELPER) ? "make the runner config with createJestGatesConfig or createVitestGatesConfig" : null
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
if (facts.hooks.length === 0) {
|
|
1708
|
+
findings.push({
|
|
1709
|
+
level: "note",
|
|
1710
|
+
text: "no git hook runs test-gates mutation for this subproject",
|
|
1711
|
+
fix: null
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
for (const hook of facts.hooks.filter((one) => !one.affected)) {
|
|
1715
|
+
findings.push({
|
|
1716
|
+
level: "problem",
|
|
1717
|
+
text: `${hook.file} runs test-gates mutation without --affected: a change of any file of the subproject runs every mutant`,
|
|
1718
|
+
fix: "pass --affected in the hook (npm run test:gates:mutation -- --affected); keep the run in CI without it"
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
return findings;
|
|
1722
|
+
}
|
|
1723
|
+
function formatFindings(findings) {
|
|
1724
|
+
const lines = [];
|
|
1725
|
+
for (const finding of findings) {
|
|
1726
|
+
lines.push(`${finding.level === "problem" ? "problem" : "note"}: ${finding.text}`);
|
|
1727
|
+
if (finding.fix !== null) {
|
|
1728
|
+
lines.push(` fix: ${finding.fix}`);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
const problems = findings.filter((finding) => finding.level === "problem").length;
|
|
1732
|
+
lines.push(
|
|
1733
|
+
problems === 0 ? "test-gates doctor: OK" : `test-gates doctor: ${problems} problem(s) that make runs full ones`
|
|
1734
|
+
);
|
|
1735
|
+
return { lines, status: problems === 0 ? 0 : 1 };
|
|
1736
|
+
}
|
|
1737
|
+
var RUNS_MUTATION = /(?<![\w-])test-gates(?:\.js)?\s+mutation(?![\w-])|(?<![\w:-])test:gates:mutation(?![\w:-])/;
|
|
1738
|
+
var TARGET = /(?:--dir|--prefix|--workspace|-w)(?:=|\s+)(['"]?)([^\s'";&|)]+)\1/;
|
|
1739
|
+
var CD = /\bcd\s+(['"]?)([^\s'";&|)]+)\1/;
|
|
1740
|
+
function hookUses(input) {
|
|
1741
|
+
const lines = input.text.split("\n").map((line) => line.replace(/(?:^|\s)#.*/, ""));
|
|
1742
|
+
const uses = [];
|
|
1743
|
+
lines.forEach((line, index) => {
|
|
1744
|
+
if (!RUNS_MUTATION.test(line)) {
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
const named = TARGET.exec(line) ?? CD.exec(line);
|
|
1748
|
+
const target = named?.[2] ?? lines.slice(0, index).reverse().map((before) => CD.exec(before)?.[2]).find((found) => found !== void 0);
|
|
1749
|
+
const normalized = target?.split("/").filter((part) => part !== "." && part !== "").join("/");
|
|
1750
|
+
const ours = normalized === void 0 || normalized.includes("$") || normalized.includes("..") || normalized === input.subproject;
|
|
1751
|
+
if (ours) {
|
|
1752
|
+
uses.push({
|
|
1753
|
+
file: input.file,
|
|
1754
|
+
affected: /--affected\b/.test(line) || /test:gates:mutation\b/.test(line) && input.script.includes("--affected")
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
return uses;
|
|
1759
|
+
}
|
|
1303
1760
|
|
|
1304
|
-
// src/
|
|
1761
|
+
// src/snapshot.ts
|
|
1762
|
+
import { spawnSync } from "child_process";
|
|
1763
|
+
import { createHash } from "crypto";
|
|
1305
1764
|
import fs5 from "fs";
|
|
1765
|
+
import { createRequire } from "module";
|
|
1766
|
+
import os from "os";
|
|
1306
1767
|
import path5 from "path";
|
|
1307
1768
|
|
|
1308
|
-
// src/
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1769
|
+
// src/commands/loaded-files.ts
|
|
1770
|
+
import fs4 from "fs";
|
|
1771
|
+
import { builtinModules } from "module";
|
|
1772
|
+
import path4 from "path";
|
|
1773
|
+
var SCRIPT = /\.[cm]?[jt]sx?$/;
|
|
1774
|
+
var COMPONENT = /\.(?:vue|svelte|astro|mdx)$/;
|
|
1775
|
+
var EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
1776
|
+
var QUOTED = /(['"`])([^'"`\n]+)\1/g;
|
|
1777
|
+
var RUNNER_LOAD = /\b(?:requireActual|requireMock|importActual|importMock|mock|doMock|unstable_mockModule)\(\s*(['"`])([^'"`\n]+)\1/g;
|
|
1778
|
+
function isFile(file) {
|
|
1779
|
+
try {
|
|
1780
|
+
return fs4.statSync(file).isFile();
|
|
1781
|
+
} catch {
|
|
1782
|
+
return false;
|
|
1783
|
+
}
|
|
1314
1784
|
}
|
|
1315
|
-
function
|
|
1316
|
-
|
|
1785
|
+
function resolveFile(base, directory = true) {
|
|
1786
|
+
const candidates = [
|
|
1787
|
+
base,
|
|
1788
|
+
...EXTENSIONS.map((extension) => base + extension),
|
|
1789
|
+
// `./fee.js` written in a TypeScript file is `./fee.ts`
|
|
1790
|
+
...EXTENSIONS.map((extension) => base.replace(/\.[cm]?jsx?$/, extension)),
|
|
1791
|
+
...directory ? EXTENSIONS.map((extension) => path4.join(base, `index${extension}`)) : []
|
|
1792
|
+
];
|
|
1793
|
+
return candidates.find(isFile) ?? null;
|
|
1317
1794
|
}
|
|
1318
|
-
function
|
|
1319
|
-
|
|
1795
|
+
function installed(specifier, from) {
|
|
1796
|
+
const name = specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
|
|
1797
|
+
for (let current = path4.dirname(from); ; current = path4.dirname(current)) {
|
|
1798
|
+
if (fs4.existsSync(path4.join(current, "node_modules", name))) {
|
|
1799
|
+
return true;
|
|
1800
|
+
}
|
|
1801
|
+
if (current === path4.dirname(current)) {
|
|
1802
|
+
return false;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1320
1805
|
}
|
|
1321
|
-
function
|
|
1322
|
-
const
|
|
1323
|
-
const
|
|
1324
|
-
const
|
|
1325
|
-
|
|
1326
|
-
|
|
1806
|
+
function loadedFiles(manifest, configFile) {
|
|
1807
|
+
const aliases = manifest.settings.importAliases;
|
|
1808
|
+
const relative = (file) => path4.relative(manifest.dir, file).split(path4.sep).join("/");
|
|
1809
|
+
const target = (specifier, from) => {
|
|
1810
|
+
if (specifier.startsWith(".")) {
|
|
1811
|
+
return resolveFile(path4.resolve(path4.dirname(from), specifier));
|
|
1812
|
+
}
|
|
1813
|
+
const alias = aliases.find((candidate) => specifier.startsWith(candidate.prefix));
|
|
1814
|
+
return alias === void 0 ? null : resolveFile(
|
|
1815
|
+
path4.resolve(manifest.dir, alias.target + specifier.slice(alias.prefix.length))
|
|
1816
|
+
);
|
|
1817
|
+
};
|
|
1818
|
+
const read2 = (file) => {
|
|
1819
|
+
const source = fs4.readFileSync(file, "utf8");
|
|
1820
|
+
const imports = parseImports(source).filter((ref) => !ref.typeOnly);
|
|
1821
|
+
const specifiers = [
|
|
1822
|
+
.../* @__PURE__ */ new Set([
|
|
1823
|
+
...imports.map((ref) => ref.module),
|
|
1824
|
+
...[...source.matchAll(RUNNER_LOAD)].map((match) => match[2])
|
|
1825
|
+
])
|
|
1826
|
+
];
|
|
1827
|
+
const strings = [...source.matchAll(QUOTED)].map((match) => match[2]).filter((text) => !specifiers.includes(text));
|
|
1828
|
+
return { source, specifiers, strings };
|
|
1829
|
+
};
|
|
1830
|
+
const named = /* @__PURE__ */ new Set();
|
|
1831
|
+
const configs = configFile === null ? [] : [path4.resolve(manifest.dir, configFile)];
|
|
1832
|
+
const seenConfigs = new Set(configs);
|
|
1833
|
+
const entry = configs[0];
|
|
1834
|
+
for (let file = configs.shift(); file !== void 0; file = configs.shift()) {
|
|
1835
|
+
const { specifiers, strings } = read2(file);
|
|
1836
|
+
const paths = strings.filter((text) => file === entry || /^(?:\.|<rootDir>\/)/.test(text));
|
|
1837
|
+
for (const text of paths) {
|
|
1838
|
+
const stripped = text.replace(/^<rootDir>\//, "");
|
|
1839
|
+
const found = resolveFile(path4.resolve(path4.dirname(file), stripped), false) ?? resolveFile(path4.resolve(manifest.dir, stripped), false);
|
|
1840
|
+
if (found !== null && SCRIPT.test(found)) {
|
|
1841
|
+
named.add(found);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
for (const specifier of specifiers.filter((one) => isProjectImport(one, aliases))) {
|
|
1845
|
+
const found = target(specifier, file);
|
|
1846
|
+
if (found !== null && SCRIPT.test(found) && !seenConfigs.has(found)) {
|
|
1847
|
+
seenConfigs.add(found);
|
|
1848
|
+
configs.push(found);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
const known = /* @__PURE__ */ new Map();
|
|
1853
|
+
const visit = (file) => {
|
|
1854
|
+
const done = known.get(file);
|
|
1855
|
+
if (done !== void 0) {
|
|
1856
|
+
return done;
|
|
1857
|
+
}
|
|
1858
|
+
const { source, specifiers, strings } = read2(file);
|
|
1859
|
+
const imports = [];
|
|
1860
|
+
const next = [];
|
|
1861
|
+
let lost = COMPONENT.test(file) ? `${relative(file)} is a component, which can use others that it does not import` : null;
|
|
1862
|
+
for (const specifier of specifiers) {
|
|
1863
|
+
if (isProjectImport(specifier, aliases)) {
|
|
1864
|
+
const found = target(specifier, file);
|
|
1865
|
+
if (found === null) {
|
|
1866
|
+
lost ??= `${specifier} (imported by ${relative(file)})`;
|
|
1867
|
+
} else {
|
|
1868
|
+
next.push(found);
|
|
1869
|
+
}
|
|
1870
|
+
} else if (specifier.startsWith("node:") || builtinModules.includes(specifier)) {
|
|
1871
|
+
imports.push({ specifier, builtin: true });
|
|
1872
|
+
} else if (installed(specifier, file)) {
|
|
1873
|
+
imports.push({ specifier, builtin: false });
|
|
1874
|
+
} else {
|
|
1875
|
+
lost ??= `${specifier} (imported by ${relative(file)})`;
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
for (const text of strings.filter((one) => one.startsWith("."))) {
|
|
1879
|
+
const found = resolveFile(path4.resolve(path4.dirname(file), text));
|
|
1880
|
+
if (found !== null) {
|
|
1881
|
+
next.push(found);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
const visited = {
|
|
1885
|
+
file: { path: relative(file), source, imports },
|
|
1886
|
+
// A component imports like a script (see COMPONENT); data imports nothing.
|
|
1887
|
+
next: next.filter((found) => SCRIPT.test(found) || COMPONENT.test(found)),
|
|
1888
|
+
lost
|
|
1889
|
+
};
|
|
1890
|
+
known.set(file, visited);
|
|
1891
|
+
return visited;
|
|
1892
|
+
};
|
|
1893
|
+
const closure = (spec, starts) => {
|
|
1894
|
+
const queue = [...starts];
|
|
1895
|
+
const seen = new Set(queue);
|
|
1896
|
+
const files = [];
|
|
1897
|
+
let lost = null;
|
|
1898
|
+
for (let file = queue.shift(); file !== void 0; file = queue.shift()) {
|
|
1899
|
+
const visited = visit(file);
|
|
1900
|
+
files.push(visited.file.path);
|
|
1901
|
+
lost ??= visited.lost;
|
|
1902
|
+
for (const found of visited.next.filter((one) => !seen.has(one))) {
|
|
1903
|
+
seen.add(found);
|
|
1904
|
+
queue.push(found);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
return { spec, files, lost };
|
|
1908
|
+
};
|
|
1909
|
+
const specs = [...new Set(manifest.gates.flatMap((gate) => gate.existingSpecs))];
|
|
1910
|
+
const roots = [
|
|
1911
|
+
closure(null, [...named]),
|
|
1912
|
+
...specs.map((spec) => closure(spec, [path4.join(manifest.dir, spec)]))
|
|
1913
|
+
];
|
|
1914
|
+
return { files: [...known.values()].map((visited) => visited.file), roots };
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// src/snapshot.ts
|
|
1918
|
+
var SnapshotError = class extends Error {
|
|
1919
|
+
};
|
|
1920
|
+
var TOOLS = [
|
|
1921
|
+
"@stryker-mutator/core",
|
|
1922
|
+
"@stryker-mutator/jest-runner",
|
|
1923
|
+
"@stryker-mutator/vitest-runner",
|
|
1924
|
+
"@stryker-mutator/typescript-checker",
|
|
1925
|
+
"jest",
|
|
1926
|
+
"babel-jest",
|
|
1927
|
+
"ts-jest",
|
|
1928
|
+
"@swc/jest",
|
|
1929
|
+
"vitest",
|
|
1930
|
+
"vite",
|
|
1931
|
+
"@vitest/coverage-istanbul",
|
|
1932
|
+
"@vitest/coverage-v8",
|
|
1933
|
+
"typescript"
|
|
1934
|
+
];
|
|
1935
|
+
var INSTALL_MARKERS = [
|
|
1936
|
+
".package-lock.json",
|
|
1937
|
+
".yarn-integrity",
|
|
1938
|
+
".yarn-state.yml",
|
|
1939
|
+
".modules.yaml"
|
|
1940
|
+
];
|
|
1941
|
+
var LOCK_FILES = [
|
|
1942
|
+
"package-lock.json",
|
|
1943
|
+
"npm-shrinkwrap.json",
|
|
1944
|
+
"yarn.lock",
|
|
1945
|
+
"pnpm-lock.yaml",
|
|
1946
|
+
"bun.lock",
|
|
1947
|
+
"bun.lockb"
|
|
1948
|
+
];
|
|
1949
|
+
var NEVER = ["node_modules", ".git"];
|
|
1950
|
+
var WIDENED_SHOWN = 5;
|
|
1951
|
+
var SCAN_LIMIT = 8 * 1024 * 1024;
|
|
1952
|
+
var UPWARD = /(['"`])((?:[^'"`\n]*\/)?\.\.(?:\/[^'"`\n]*)?)\1/g;
|
|
1953
|
+
var DOCUMENTS = [".md", ".markdown", ".txt", ".rst", ".adoc"];
|
|
1954
|
+
var HERE = /^\$\{(?:__dirname|import\.meta\.dirname)\}(?=\/)/;
|
|
1955
|
+
var FROM_HERE = /\b(?:__dirname|import\.meta\.dirname)((?:\s*,\s*(['"])[^'"`\n]*\2)+)(\s*\))?/g;
|
|
1956
|
+
var LITERAL = /(['"])([^'"`\n]*)\1/g;
|
|
1957
|
+
var isUpward = (segment) => segment.split("/").includes("..");
|
|
1958
|
+
function hashOf(content) {
|
|
1959
|
+
return createHash("sha256").update(content).digest("hex");
|
|
1960
|
+
}
|
|
1961
|
+
function hashFile(file) {
|
|
1962
|
+
try {
|
|
1963
|
+
return hashOf(fs5.readFileSync(file));
|
|
1964
|
+
} catch {
|
|
1327
1965
|
return null;
|
|
1328
1966
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1967
|
+
}
|
|
1968
|
+
function isInside(parent, child) {
|
|
1969
|
+
const relative = path5.relative(parent, child);
|
|
1970
|
+
return relative === "" || !relative.startsWith("..") && !path5.isAbsolute(relative);
|
|
1971
|
+
}
|
|
1972
|
+
function toPosix(file) {
|
|
1973
|
+
return file.split(path5.sep).join("/");
|
|
1974
|
+
}
|
|
1975
|
+
function gitEnvironment(env) {
|
|
1976
|
+
if (env.GIT_DIR === void 0 || env.GIT_WORK_TREE !== void 0) {
|
|
1977
|
+
return env;
|
|
1331
1978
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
last.slice(0, end.column - 1)
|
|
1336
|
-
].join("\n");
|
|
1979
|
+
const rest = { ...env };
|
|
1980
|
+
delete rest.GIT_DIR;
|
|
1981
|
+
return rest;
|
|
1337
1982
|
}
|
|
1338
|
-
function
|
|
1339
|
-
|
|
1983
|
+
function git(cwd, args) {
|
|
1984
|
+
const result = spawnSync("git", args, {
|
|
1985
|
+
cwd,
|
|
1986
|
+
env: gitEnvironment(process.env),
|
|
1987
|
+
encoding: "utf8",
|
|
1988
|
+
maxBuffer: 1024 * 1024 * 1024
|
|
1989
|
+
});
|
|
1990
|
+
return result.error || result.status !== 0 ? null : result.stdout;
|
|
1340
1991
|
}
|
|
1341
|
-
function
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
replacement: normalizeCode(entry.replacement),
|
|
1357
|
-
...entry.sameKeyCount > 1 ? { occurrence: entry.occurrence } : {},
|
|
1358
|
-
reason: ""
|
|
1992
|
+
function gitLister(root) {
|
|
1993
|
+
return (directory, recursive) => {
|
|
1994
|
+
const relative = toPosix(path5.relative(root, directory));
|
|
1995
|
+
const pattern = recursive ? `${relative === "" ? "" : `${relative}/`}**` : `${relative === "" ? "" : `${relative}/`}*`;
|
|
1996
|
+
const listed2 = git(root, [
|
|
1997
|
+
"ls-files",
|
|
1998
|
+
"-z",
|
|
1999
|
+
"--cached",
|
|
2000
|
+
"--others",
|
|
2001
|
+
"--exclude-standard",
|
|
2002
|
+
"--",
|
|
2003
|
+
`:(glob)${pattern}`
|
|
2004
|
+
]);
|
|
2005
|
+
if (listed2 === null) {
|
|
2006
|
+
throw new SnapshotError("git could not list the files");
|
|
1359
2007
|
}
|
|
2008
|
+
return listed2.split("\0").filter((file) => file !== "");
|
|
1360
2009
|
};
|
|
1361
2010
|
}
|
|
1362
|
-
function
|
|
1363
|
-
const
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
2011
|
+
function walkLister(root) {
|
|
2012
|
+
const walk = (directory, recursive, found) => {
|
|
2013
|
+
for (const entry of fs5.readdirSync(directory, { withFileTypes: true })) {
|
|
2014
|
+
const file = path5.join(directory, entry.name);
|
|
2015
|
+
if (NEVER.includes(entry.name)) {
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
if (entry.isDirectory()) {
|
|
2019
|
+
if (recursive) {
|
|
2020
|
+
walk(file, true, found);
|
|
2021
|
+
}
|
|
2022
|
+
} else {
|
|
2023
|
+
found.push(toPosix(path5.relative(root, file)));
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return found;
|
|
1378
2027
|
};
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
2028
|
+
return (directory, recursive) => walk(directory, recursive, []);
|
|
2029
|
+
}
|
|
2030
|
+
function upTo(from, to) {
|
|
2031
|
+
const directories = [from];
|
|
2032
|
+
while (directories[directories.length - 1] !== to) {
|
|
2033
|
+
directories.push(path5.dirname(directories[directories.length - 1]));
|
|
1382
2034
|
}
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
const
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
2035
|
+
return directories;
|
|
2036
|
+
}
|
|
2037
|
+
function declaresWorkspaces(directory) {
|
|
2038
|
+
if (fs5.existsSync(path5.join(directory, "pnpm-workspace.yaml"))) {
|
|
2039
|
+
return true;
|
|
2040
|
+
}
|
|
2041
|
+
try {
|
|
2042
|
+
const manifest = JSON.parse(fs5.readFileSync(path5.join(directory, "package.json"), "utf8"));
|
|
2043
|
+
return manifest.workspaces !== void 0;
|
|
2044
|
+
} catch {
|
|
2045
|
+
return false;
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
function walkRoot(dir) {
|
|
2049
|
+
const candidates = withinBounds(dir);
|
|
2050
|
+
const repository = candidates.find((directory) => fs5.existsSync(path5.join(directory, ".git")));
|
|
2051
|
+
if (repository !== void 0) {
|
|
2052
|
+
return repository;
|
|
2053
|
+
}
|
|
2054
|
+
return candidates.filter(declaresWorkspaces).pop() ?? dir;
|
|
2055
|
+
}
|
|
2056
|
+
function withinBounds(dir) {
|
|
2057
|
+
let home = null;
|
|
2058
|
+
try {
|
|
2059
|
+
home = fs5.realpathSync(os.homedir());
|
|
2060
|
+
} catch {
|
|
2061
|
+
}
|
|
2062
|
+
return upTo(dir, path5.parse(dir).root).filter(
|
|
2063
|
+
(directory) => directory !== path5.parse(dir).root && (home === null || !isInside(directory, home))
|
|
2064
|
+
);
|
|
2065
|
+
}
|
|
2066
|
+
function linkedPackages(nodeModules, root) {
|
|
2067
|
+
const found = [];
|
|
2068
|
+
const visit = (directory, depth) => {
|
|
2069
|
+
let entries;
|
|
2070
|
+
try {
|
|
2071
|
+
entries = fs5.readdirSync(directory, { withFileTypes: true });
|
|
2072
|
+
} catch {
|
|
2073
|
+
return;
|
|
1413
2074
|
}
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
2075
|
+
for (const entry of entries) {
|
|
2076
|
+
const file = path5.join(directory, entry.name);
|
|
2077
|
+
if (entry.isSymbolicLink()) {
|
|
2078
|
+
let target;
|
|
2079
|
+
try {
|
|
2080
|
+
target = fs5.realpathSync(file);
|
|
2081
|
+
} catch {
|
|
2082
|
+
continue;
|
|
2083
|
+
}
|
|
2084
|
+
if (target.split(path5.sep).includes("node_modules") || !fs5.statSync(target).isDirectory()) {
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
if (!isInside(root, target)) {
|
|
2088
|
+
throw new SnapshotError(
|
|
2089
|
+
`${file} is a link to a directory outside of what can be listed (${target})`
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
found.push(target);
|
|
2093
|
+
} else if (depth === 0 && entry.isDirectory() && entry.name.startsWith("@")) {
|
|
2094
|
+
visit(file, 1);
|
|
1430
2095
|
}
|
|
1431
|
-
const replacement = mutant.replacement ?? "";
|
|
1432
|
-
const key = keyOf(mutant.mutatorName, original, replacement);
|
|
1433
|
-
const group = byKey.get(key) ?? [];
|
|
1434
|
-
const entry = {
|
|
1435
|
-
mutant,
|
|
1436
|
-
original,
|
|
1437
|
-
replacement,
|
|
1438
|
-
occurrence: group.length + 1,
|
|
1439
|
-
sameKeyCount: 0
|
|
1440
|
-
};
|
|
1441
|
-
group.push(entry);
|
|
1442
|
-
byKey.set(key, group);
|
|
1443
|
-
located.push(entry);
|
|
1444
2096
|
}
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
2097
|
+
};
|
|
2098
|
+
visit(nodeModules, 0);
|
|
2099
|
+
return found;
|
|
2100
|
+
}
|
|
2101
|
+
function installedVersion(start, name) {
|
|
2102
|
+
let current = start;
|
|
2103
|
+
for (; ; ) {
|
|
2104
|
+
try {
|
|
2105
|
+
const manifest = JSON.parse(
|
|
2106
|
+
fs5.readFileSync(path5.join(current, "node_modules", name, "package.json"), "utf8")
|
|
2107
|
+
);
|
|
2108
|
+
return typeof manifest.version === "string" ? manifest.version : "unknown";
|
|
2109
|
+
} catch {
|
|
2110
|
+
const parent = path5.dirname(current);
|
|
2111
|
+
if (parent === current) {
|
|
2112
|
+
return null;
|
|
1448
2113
|
}
|
|
2114
|
+
current = parent;
|
|
1449
2115
|
}
|
|
1450
|
-
|
|
1451
|
-
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
function ownVersion() {
|
|
2119
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
2120
|
+
return version;
|
|
2121
|
+
}
|
|
2122
|
+
function snapshotOf(input) {
|
|
2123
|
+
const dir = fs5.realpathSync(input.dir);
|
|
2124
|
+
const top = git(dir, ["rev-parse", "--show-toplevel"]);
|
|
2125
|
+
let root = top === null ? dir : fs5.realpathSync(top.trim());
|
|
2126
|
+
let lister = gitLister(root);
|
|
2127
|
+
let mode = "git";
|
|
2128
|
+
const manifest = toPosix(path5.relative(root, path5.join(dir, "test-gates.json")));
|
|
2129
|
+
if (top === null || !lister(dir, false).includes(manifest)) {
|
|
2130
|
+
mode = "walk";
|
|
2131
|
+
root = walkRoot(dir);
|
|
2132
|
+
lister = walkLister(root);
|
|
2133
|
+
}
|
|
2134
|
+
const listRoot = root;
|
|
2135
|
+
const list = lister;
|
|
2136
|
+
const followed = input.scope === void 0 ? null : input.scope(list(dir, true).map((file) => path5.join(listRoot, file)));
|
|
2137
|
+
const walk = walkLister(listRoot);
|
|
2138
|
+
const written = input.written.map((file) => {
|
|
2139
|
+
const target = path5.resolve(dir, file);
|
|
2140
|
+
try {
|
|
2141
|
+
return fs5.realpathSync(target);
|
|
2142
|
+
} catch {
|
|
2143
|
+
return target;
|
|
1452
2144
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
2145
|
+
});
|
|
2146
|
+
const swallowing = written.find((target) => isInside(target, path5.join(dir, "test-gates.json")));
|
|
2147
|
+
if (swallowing !== void 0) {
|
|
2148
|
+
throw new SnapshotError(`the run writes to ${swallowing}, which holds test-gates.json`);
|
|
2149
|
+
}
|
|
2150
|
+
const files = /* @__PURE__ */ Object.create(null);
|
|
2151
|
+
const regions = [dir];
|
|
2152
|
+
const widened = [];
|
|
2153
|
+
const queued = new Set(regions);
|
|
2154
|
+
const enqueue = (target, from, reference, anchored = false) => {
|
|
2155
|
+
const above2 = target !== dir && isInside(target, dir) && isInside(listRoot, target);
|
|
2156
|
+
if (above2 && anchored) {
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
const directory = above2 ? listRoot : target;
|
|
2160
|
+
if (!isInside(listRoot, directory)) {
|
|
2161
|
+
const written2 = reference === void 0 ? "" : ` with ${JSON.stringify(reference)}`;
|
|
2162
|
+
throw new SnapshotError(
|
|
2163
|
+
`${toPosix(path5.relative(dir, from))} points outside of what can be listed${written2} (${directory})`
|
|
2164
|
+
);
|
|
2165
|
+
}
|
|
2166
|
+
if (directory === listRoot && listRoot !== dir && widened.length < WIDENED_SHOWN) {
|
|
2167
|
+
const written2 = reference === void 0 ? "" : ` ${JSON.stringify(reference)}`;
|
|
2168
|
+
widened.push(`${toPosix(path5.relative(listRoot, from))}${written2}`);
|
|
2169
|
+
}
|
|
2170
|
+
if (![...queued].some((region) => isInside(region, directory))) {
|
|
2171
|
+
queued.add(directory);
|
|
2172
|
+
regions.push(directory);
|
|
2173
|
+
}
|
|
2174
|
+
};
|
|
2175
|
+
const follow = (fixed, open, anchored, file, reference) => {
|
|
2176
|
+
const target = path5.resolve(path5.dirname(file), fixed);
|
|
2177
|
+
let isDirectory = false;
|
|
2178
|
+
try {
|
|
2179
|
+
isDirectory = fs5.statSync(target).isDirectory();
|
|
2180
|
+
} catch {
|
|
2181
|
+
}
|
|
2182
|
+
if (!isDirectory && !isInside(listRoot, target) && resolveFile(target) === null) {
|
|
2183
|
+
files[toPosix(path5.relative(listRoot, target))] = "missing";
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
const directory = isDirectory ? target : path5.dirname(target);
|
|
2187
|
+
if (fs5.existsSync(directory)) {
|
|
2188
|
+
enqueue(directory, file, reference, anchored && !open);
|
|
2189
|
+
}
|
|
2190
|
+
};
|
|
2191
|
+
const scan = (text, file) => {
|
|
2192
|
+
const taken = [];
|
|
2193
|
+
for (const match of text.matchAll(FROM_HERE)) {
|
|
2194
|
+
const literals = match[1];
|
|
2195
|
+
const segments = [...literals.matchAll(LITERAL)].map((literal) => literal[2]);
|
|
2196
|
+
if (!segments.some(isUpward)) {
|
|
2197
|
+
continue;
|
|
1457
2198
|
}
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
2199
|
+
const start = (match.index ?? 0) + match[0].indexOf(literals);
|
|
2200
|
+
taken.push([start, start + literals.length]);
|
|
2201
|
+
const end = (match.index ?? 0) + match[0].length;
|
|
2202
|
+
const open = match[3] === void 0 || /^\s*[,+.}[]/.test(text.slice(end, end + 200));
|
|
2203
|
+
const reference = match[0].slice(0, match[0].length - (match[3]?.length ?? 0));
|
|
2204
|
+
follow(path5.posix.join(...segments), open, true, file, reference);
|
|
2205
|
+
}
|
|
2206
|
+
for (const match of text.matchAll(UPWARD)) {
|
|
2207
|
+
const at = match.index ?? 0;
|
|
2208
|
+
if (taken.some(([start, end]) => at >= start && at < end)) {
|
|
2209
|
+
continue;
|
|
2210
|
+
}
|
|
2211
|
+
const literal = match[2];
|
|
2212
|
+
const here = HERE.exec(literal);
|
|
2213
|
+
const relative = here === null ? literal : `.${literal.slice(here[0].length)}`;
|
|
2214
|
+
const open = relative.includes("${");
|
|
2215
|
+
const fixed = open ? relative.slice(0, relative.lastIndexOf("/", relative.indexOf("${")) + 1) : relative;
|
|
2216
|
+
if (here === null && !fixed.startsWith("../") || open && isUpward(relative.slice(relative.indexOf("${")))) {
|
|
2217
|
+
enqueue(listRoot, file, literal);
|
|
2218
|
+
} else {
|
|
2219
|
+
follow(fixed, open, here !== null, file, literal);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
};
|
|
2223
|
+
const add = (relative) => {
|
|
2224
|
+
const file = path5.join(listRoot, relative);
|
|
2225
|
+
if (relative in files || written.some((target) => isInside(target, file))) {
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
let stat;
|
|
2229
|
+
try {
|
|
2230
|
+
stat = fs5.lstatSync(file);
|
|
2231
|
+
} catch {
|
|
2232
|
+
files[relative] = "missing";
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
if (stat.isSymbolicLink()) {
|
|
2236
|
+
files[relative] = `link ${fs5.readlinkSync(file)}`;
|
|
2237
|
+
let target;
|
|
2238
|
+
try {
|
|
2239
|
+
target = fs5.realpathSync(file);
|
|
2240
|
+
} catch {
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
enqueue(fs5.statSync(target).isDirectory() ? target : path5.dirname(target), file);
|
|
2244
|
+
} else if (stat.isDirectory()) {
|
|
2245
|
+
files[relative] = "directory";
|
|
2246
|
+
walk(file, true).forEach(add);
|
|
2247
|
+
} else if (!stat.isFile()) {
|
|
2248
|
+
files[relative] = "special";
|
|
2249
|
+
} else {
|
|
2250
|
+
const content = fs5.readFileSync(file);
|
|
2251
|
+
files[relative] = hashOf(content);
|
|
2252
|
+
const document = DOCUMENTS.includes(path5.extname(file).toLowerCase());
|
|
2253
|
+
const loaded = followed === null || !isInside(dir, file) || followed.has(file);
|
|
2254
|
+
if (loaded && !document && content.length <= SCAN_LIMIT && !content.subarray(0, 8e3).includes(0)) {
|
|
2255
|
+
scan(content.toString("utf8").replace(/\\[\\/]/g, "/"), file);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
};
|
|
2259
|
+
const ancestors = upTo(dir, listRoot).slice(1);
|
|
2260
|
+
for (const directory of ancestors) {
|
|
2261
|
+
list(directory, false).forEach(add);
|
|
2262
|
+
}
|
|
2263
|
+
for (const directory of [dir, ...ancestors]) {
|
|
2264
|
+
for (const linked of linkedPackages(path5.join(directory, "node_modules"), listRoot)) {
|
|
2265
|
+
if (!isInside(linked, dir)) {
|
|
2266
|
+
enqueue(linked, path5.join(directory, "node_modules"));
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
const above = withinBounds(listRoot).slice(1);
|
|
2271
|
+
for (const directory of [dir, ...ancestors, ...above]) {
|
|
2272
|
+
const candidates = [
|
|
2273
|
+
...INSTALL_MARKERS.map((marker) => path5.join(directory, "node_modules", marker)),
|
|
2274
|
+
...above.includes(directory) ? LOCK_FILES.map((name) => path5.join(directory, name)) : []
|
|
2275
|
+
];
|
|
2276
|
+
for (const file of candidates) {
|
|
2277
|
+
const hash = hashFile(file);
|
|
2278
|
+
if (hash !== null) {
|
|
2279
|
+
files[toPosix(path5.relative(listRoot, file))] = hash;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
for (let position = 0; position < regions.length; position += 1) {
|
|
2284
|
+
list(regions[position], true).forEach(add);
|
|
2285
|
+
}
|
|
2286
|
+
const environment = {
|
|
2287
|
+
"Node.js": process.version,
|
|
2288
|
+
platform: `${process.platform} ${process.arch}`,
|
|
2289
|
+
"test-gates": ownVersion()
|
|
2290
|
+
};
|
|
2291
|
+
for (const name of TOOLS) {
|
|
2292
|
+
const version = installedVersion(dir, name);
|
|
2293
|
+
if (version !== null) {
|
|
2294
|
+
environment[name] = version;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
return { snapshot: { mode, environment, args: input.args, files }, root: listRoot, widened };
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// src/commands/mutation.ts
|
|
2301
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2302
|
+
import fs8 from "fs";
|
|
2303
|
+
import path8 from "path";
|
|
2304
|
+
import { pathToFileURL } from "url";
|
|
2305
|
+
|
|
2306
|
+
// src/affected-files.ts
|
|
2307
|
+
import fs6 from "fs";
|
|
2308
|
+
import { builtinModules as builtinModules2 } from "module";
|
|
2309
|
+
import path6 from "path";
|
|
2310
|
+
var AffectedError = class extends Error {
|
|
2311
|
+
};
|
|
2312
|
+
var RUNTIME_PATHS = /\b(?:__dirname|__filename|require\.(?:context|resolve)|process\.cwd|createRequire|toMatchFileSnapshot|readFileSync|readdirSync)\b|\bimport\.meta\.(?:url|dirname|filename|glob|resolve)\b/;
|
|
2313
|
+
var FILE_MODULES = /* @__PURE__ */ new Set([
|
|
2314
|
+
"fs",
|
|
2315
|
+
"fs/promises",
|
|
2316
|
+
"child_process",
|
|
2317
|
+
"module",
|
|
2318
|
+
"worker_threads",
|
|
2319
|
+
"fs-extra",
|
|
2320
|
+
"graceful-fs",
|
|
2321
|
+
"glob",
|
|
2322
|
+
"fast-glob",
|
|
2323
|
+
"globby"
|
|
2324
|
+
]);
|
|
2325
|
+
var bare = (specifier) => specifier.replace(/^node:/, "");
|
|
2326
|
+
function linkedPackage(specifier, from) {
|
|
2327
|
+
const name = specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
|
|
2328
|
+
for (let current = path6.dirname(from); ; current = path6.dirname(current)) {
|
|
2329
|
+
const candidate = path6.join(current, "node_modules", name);
|
|
2330
|
+
if (fs6.existsSync(candidate)) {
|
|
2331
|
+
const target = realpath(candidate);
|
|
2332
|
+
return target.split(path6.sep).includes("node_modules") ? null : target;
|
|
2333
|
+
}
|
|
2334
|
+
if (current === path6.dirname(current)) {
|
|
2335
|
+
return null;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
var toPosix2 = (file) => file.split(path6.sep).join("/");
|
|
2340
|
+
function realpath(file) {
|
|
2341
|
+
try {
|
|
2342
|
+
return fs6.realpathSync.native(file);
|
|
2343
|
+
} catch {
|
|
2344
|
+
return file;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
function runnerConfigs(dir, stryker, options) {
|
|
2348
|
+
const files = [path6.resolve(dir, stryker)];
|
|
2349
|
+
const named = options.testRunner === "vitest" ? options.vitestConfig : options.jestConfig;
|
|
2350
|
+
if (options.testRunner !== "vitest" && options.testRunner !== "jest") {
|
|
2351
|
+
throw new AffectedError(
|
|
2352
|
+
`--affected knows the test runners jest and vitest, the Stryker config uses ${String(options.testRunner)}`
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
if (typeof named === "string") {
|
|
2356
|
+
files.push(path6.resolve(dir, named));
|
|
2357
|
+
} else {
|
|
2358
|
+
const names = options.testRunner === "vitest" ? ["vitest.config", "vite.config"] : ["jest.config"];
|
|
2359
|
+
for (const name of names) {
|
|
2360
|
+
for (const extension of [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"]) {
|
|
2361
|
+
const file = path6.join(dir, name + extension);
|
|
2362
|
+
if (fs6.existsSync(file)) {
|
|
2363
|
+
files.push(file);
|
|
1470
2364
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
return { files: files.filter((file) => fs6.existsSync(file)).map(realpath) };
|
|
2369
|
+
}
|
|
2370
|
+
function fileEdges(file, context) {
|
|
2371
|
+
const { dir, aliases, cache } = context;
|
|
2372
|
+
const known = cache.get(file);
|
|
2373
|
+
if (known !== void 0) {
|
|
2374
|
+
return known;
|
|
2375
|
+
}
|
|
2376
|
+
const shown = (one) => toPosix2(path6.relative(dir, one));
|
|
2377
|
+
const target = (specifier, from) => {
|
|
2378
|
+
if (specifier.startsWith(".")) {
|
|
2379
|
+
return resolveFile(path6.resolve(path6.dirname(from), specifier));
|
|
2380
|
+
}
|
|
2381
|
+
const alias = aliases.find((candidate) => specifier.startsWith(candidate.prefix));
|
|
2382
|
+
return alias === void 0 ? null : resolveFile(path6.resolve(dir, alias.target + specifier.slice(alias.prefix.length)));
|
|
2383
|
+
};
|
|
2384
|
+
const found = { loads: [], unknown: null, computed: false, unnamed: [] };
|
|
2385
|
+
cache.set(file, found);
|
|
2386
|
+
const snapshotFile = path6.join(
|
|
2387
|
+
path6.dirname(file),
|
|
2388
|
+
"__snapshots__",
|
|
2389
|
+
`${path6.basename(file)}.snap`
|
|
2390
|
+
);
|
|
2391
|
+
if (fs6.existsSync(snapshotFile)) {
|
|
2392
|
+
found.loads.push(realpath(snapshotFile));
|
|
2393
|
+
}
|
|
2394
|
+
if (!SCRIPT.test(file) && !COMPONENT.test(file)) {
|
|
2395
|
+
return found;
|
|
2396
|
+
}
|
|
2397
|
+
const source = fs6.readFileSync(file, "utf8");
|
|
2398
|
+
if (COMPONENT.test(file)) {
|
|
2399
|
+
found.unknown = `${shown(file)} is a component, which can use others that it does not import`;
|
|
2400
|
+
}
|
|
2401
|
+
const uncheckable = findUncheckableImports(source)[0];
|
|
2402
|
+
const runtime = RUNTIME_PATHS.exec(source);
|
|
2403
|
+
if (uncheckable !== void 0) {
|
|
2404
|
+
found.unknown = `${shown(file)}:${uncheckable} imports a module whose path is not written out`;
|
|
2405
|
+
found.computed = true;
|
|
2406
|
+
} else if (runtime !== null) {
|
|
2407
|
+
found.unknown = `${shown(file)} uses ${runtime[0]}, which can reach files that no import names`;
|
|
2408
|
+
}
|
|
2409
|
+
const specifiers = /* @__PURE__ */ new Set([
|
|
2410
|
+
...parseImports(source).map((ref) => ref.module),
|
|
2411
|
+
...[...source.matchAll(RUNNER_LOAD)].map((match) => match[2])
|
|
2412
|
+
]);
|
|
2413
|
+
for (const specifier of specifiers) {
|
|
2414
|
+
if (path6.isAbsolute(specifier) || specifier.startsWith("file:")) {
|
|
2415
|
+
const resolved = resolveFile(
|
|
2416
|
+
specifier.startsWith("file:") ? new URL(specifier).pathname : specifier
|
|
2417
|
+
);
|
|
2418
|
+
if (resolved === null) {
|
|
2419
|
+
found.unknown ??= `${shown(file)} imports ${specifier}, which leads to no file`;
|
|
2420
|
+
} else {
|
|
2421
|
+
found.loads.push(realpath(resolved));
|
|
2422
|
+
}
|
|
2423
|
+
} else if (isProjectImport(specifier, aliases)) {
|
|
2424
|
+
const resolved = target(specifier, file);
|
|
2425
|
+
if (resolved === null) {
|
|
2426
|
+
found.unknown ??= `${shown(file)} imports ${specifier}, which leads to no file`;
|
|
2427
|
+
} else {
|
|
2428
|
+
found.loads.push(realpath(resolved));
|
|
2429
|
+
}
|
|
2430
|
+
} else if (FILE_MODULES.has(bare(specifier))) {
|
|
2431
|
+
found.unknown ??= `${shown(file)} imports ${specifier}, which can reach files that no import names`;
|
|
2432
|
+
} else if (!specifier.startsWith("node:") && !builtinModules2.includes(specifier)) {
|
|
2433
|
+
if (!installed(specifier, file)) {
|
|
2434
|
+
found.unknown ??= `${shown(file)} imports ${specifier}, which is neither a file nor an installed package (name an alias in settings.importAliases)`;
|
|
2435
|
+
found.unnamed.push(specifier);
|
|
2436
|
+
} else if (linkedPackage(specifier, file) !== null) {
|
|
2437
|
+
found.unknown ??= `${shown(file)} imports ${specifier}, a package of the repository linked into node_modules`;
|
|
2438
|
+
found.computed = true;
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
for (const match of source.matchAll(QUOTED)) {
|
|
2443
|
+
const text = match[2];
|
|
2444
|
+
if (text.startsWith(".") && !specifiers.has(text)) {
|
|
2445
|
+
const resolved = resolveFile(path6.resolve(path6.dirname(file), text));
|
|
2446
|
+
if (resolved !== null) {
|
|
2447
|
+
found.loads.push(realpath(resolved));
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
return found;
|
|
2452
|
+
}
|
|
2453
|
+
function affectedInputs(manifest, input) {
|
|
2454
|
+
const dir = realpath(manifest.dir);
|
|
2455
|
+
const aliases = manifest.settings.importAliases;
|
|
2456
|
+
const placed = [...input.tests, ...Object.values(input.covering).flat()];
|
|
2457
|
+
const absolute = placed.find((file) => path6.isAbsolute(file) || file.startsWith(".."));
|
|
2458
|
+
if (absolute !== void 0) {
|
|
2459
|
+
throw new AffectedError(`the report names a spec file outside of the subproject: ${absolute}`);
|
|
2460
|
+
}
|
|
2461
|
+
const keyOf2 = (file) => {
|
|
2462
|
+
const relative = path6.relative(input.root, realpath(file));
|
|
2463
|
+
if (relative.startsWith("..") || path6.isAbsolute(relative)) {
|
|
2464
|
+
throw new AffectedError(`${file} is outside of the files that are compared`);
|
|
2465
|
+
}
|
|
2466
|
+
return toPosix2(relative);
|
|
2467
|
+
};
|
|
2468
|
+
const shown = (file) => toPosix2(path6.relative(dir, file));
|
|
2469
|
+
const edges = /* @__PURE__ */ new Map();
|
|
2470
|
+
const edgesOf = (file) => fileEdges(file, { dir, aliases, cache: edges });
|
|
2471
|
+
const closure = (start, strict) => {
|
|
2472
|
+
const seen = /* @__PURE__ */ new Set([start]);
|
|
2473
|
+
const queue = [start];
|
|
2474
|
+
for (let file = queue.shift(); file !== void 0; file = queue.shift()) {
|
|
2475
|
+
const found = edgesOf(file);
|
|
2476
|
+
if (strict && found.unknown !== null) {
|
|
2477
|
+
throw new AffectedError(found.unknown);
|
|
2478
|
+
}
|
|
2479
|
+
for (const next of found.loads) {
|
|
2480
|
+
if (!seen.has(next)) {
|
|
2481
|
+
seen.add(next);
|
|
2482
|
+
queue.push(next);
|
|
1476
2483
|
}
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
return [...seen];
|
|
2487
|
+
};
|
|
2488
|
+
const isCompared = (file) => {
|
|
2489
|
+
const relative = path6.relative(input.root, realpath(file));
|
|
2490
|
+
return !relative.startsWith("..") && !path6.isAbsolute(relative) && toPosix2(relative) in input.files;
|
|
2491
|
+
};
|
|
2492
|
+
const keysOf = (files) => files.map((file) => {
|
|
2493
|
+
const key = keyOf2(file);
|
|
2494
|
+
if (!(key in input.files)) {
|
|
2495
|
+
throw new AffectedError(
|
|
2496
|
+
`${shown(file)} is loaded by a gate or a spec, but is not among the compared files (does git ignore it?)`
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
return key;
|
|
2500
|
+
});
|
|
2501
|
+
const config = /* @__PURE__ */ new Set();
|
|
2502
|
+
const named = [];
|
|
2503
|
+
for (const file of input.configs.files) {
|
|
2504
|
+
for (const loaded of closure(file, false).filter(isCompared)) {
|
|
2505
|
+
config.add(loaded);
|
|
2506
|
+
if (!SCRIPT.test(loaded)) {
|
|
2507
|
+
continue;
|
|
2508
|
+
}
|
|
2509
|
+
for (const match of fs6.readFileSync(loaded, "utf8").matchAll(QUOTED)) {
|
|
2510
|
+
const text = match[2].replace(/^<rootDir>\//, "");
|
|
2511
|
+
const found = resolveFile(path6.resolve(path6.dirname(loaded), text), false) ?? resolveFile(path6.resolve(dir, text), false);
|
|
2512
|
+
if (found !== null && !text.includes("*") && isCompared(found)) {
|
|
2513
|
+
named.push(realpath(found));
|
|
1483
2514
|
}
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
const gatePaths = new Map(
|
|
2519
|
+
manifest.gates.map((gate) => [realpath(path6.join(dir, gate.path)), gate.path])
|
|
2520
|
+
);
|
|
2521
|
+
const tests = [
|
|
2522
|
+
.../* @__PURE__ */ new Set([
|
|
2523
|
+
...input.tests,
|
|
2524
|
+
...manifest.gates.flatMap((gate) => gate.existingSpecs),
|
|
2525
|
+
...Object.values(input.covering).flat()
|
|
2526
|
+
])
|
|
2527
|
+
];
|
|
2528
|
+
const runAsSuch = new Set(
|
|
2529
|
+
[...tests, ...manifest.gates.map((gate) => gate.path)].map(
|
|
2530
|
+
(file) => realpath(path6.join(dir, file))
|
|
2531
|
+
)
|
|
2532
|
+
);
|
|
2533
|
+
const subprojectKey = toPosix2(path6.relative(input.root, dir));
|
|
2534
|
+
const isConfig = (file) => input.configs.files.includes(file) || kindOf(keyOf2(file), subprojectKey) === "config";
|
|
2535
|
+
for (const file of named.filter((one) => !isConfig(one) && !runAsSuch.has(one))) {
|
|
2536
|
+
for (const loaded of closure(file, true)) {
|
|
2537
|
+
const gate = gatePaths.get(loaded);
|
|
2538
|
+
if (gate !== void 0) {
|
|
2539
|
+
throw new AffectedError(
|
|
2540
|
+
`${gate} is loaded through the test runner config for every spec (${shown(file)})`
|
|
2541
|
+
);
|
|
2542
|
+
}
|
|
2543
|
+
config.add(loaded);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
const fromSubproject = (file) => path6.join(dir, file);
|
|
2547
|
+
const closures = /* @__PURE__ */ Object.create(null);
|
|
2548
|
+
for (const file of [...manifest.gates.map((gate) => gate.path), ...tests]) {
|
|
2549
|
+
const absolute2 = fromSubproject(file);
|
|
2550
|
+
if (!fs6.existsSync(absolute2)) {
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
closures[keysOf([absolute2])[0]] = keysOf(closure(realpath(absolute2), true));
|
|
2554
|
+
}
|
|
2555
|
+
const keyed = (files) => files.map((file) => toPosix2(path6.relative(input.root, path6.join(dir, file))));
|
|
2556
|
+
const dependencies = gateDependencies({
|
|
2557
|
+
gates: manifest.gates.map((gate) => ({
|
|
2558
|
+
key: keyed([gate.path])[0],
|
|
2559
|
+
specs: keyed(gate.existingSpecs)
|
|
2560
|
+
})),
|
|
2561
|
+
tests: keyed(tests),
|
|
2562
|
+
covering: Object.fromEntries(
|
|
2563
|
+
Object.entries(input.covering).map(([gate, specs]) => [keyed([gate])[0], keyed(specs)])
|
|
2564
|
+
),
|
|
2565
|
+
closures
|
|
2566
|
+
});
|
|
2567
|
+
const subproject = toPosix2(path6.relative(input.root, dir));
|
|
2568
|
+
const prefix = subproject === "" ? "" : `${subproject}/`;
|
|
2569
|
+
const gates = /* @__PURE__ */ Object.create(null);
|
|
2570
|
+
for (const gate of manifest.gates) {
|
|
2571
|
+
const key = keyed([gate.path])[0];
|
|
2572
|
+
const found = dependencies[key];
|
|
2573
|
+
gates[gate.path] = {
|
|
2574
|
+
key,
|
|
2575
|
+
specs: found.specs.map(
|
|
2576
|
+
(spec) => spec.startsWith(prefix) ? spec.slice(prefix.length) : spec
|
|
2577
|
+
),
|
|
2578
|
+
files: found.files
|
|
1487
2579
|
};
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
2580
|
+
}
|
|
2581
|
+
return { subproject, config: keysOf([...config]).sort(), gates };
|
|
2582
|
+
}
|
|
2583
|
+
var NOT_THE_HELPER2 = "the runner config is not made with createJestGatesConfig or createVitestGatesConfig";
|
|
2584
|
+
var OWN = /@t09tanaka\/test-gates\/(?:jest|vitest)\b/;
|
|
2585
|
+
var PROJECTS = /\b(?:projects|workspace)\b/;
|
|
2586
|
+
var HELPERS = ["createJestGatesConfig", "createVitestGatesConfig"];
|
|
2587
|
+
function isHelperResult(text) {
|
|
2588
|
+
const names = new Set(HELPERS);
|
|
2589
|
+
for (const match of text.matchAll(
|
|
2590
|
+
/\b(?:createJestGatesConfig|createVitestGatesConfig)\s*(?::|\bas\b)\s*([\w$]+)/g
|
|
2591
|
+
)) {
|
|
2592
|
+
names.add(match[1]);
|
|
2593
|
+
}
|
|
2594
|
+
const escape = (name2) => name2.replace(/\$/g, "\\$");
|
|
2595
|
+
const call = `(?:defineConfig\\s*\\(\\s*)?(?:${[...names].map(escape).join("|")})\\s*\\(`;
|
|
2596
|
+
const exported = "(?:export\\s+default|module\\.exports\\s*=)\\s*";
|
|
2597
|
+
const reached = (name2) => new RegExp(`\\b${escape(name2)}\\s*(?:\\.|\\[)`).test(text);
|
|
2598
|
+
if (new RegExp(`${exported}${call}`).test(text)) {
|
|
2599
|
+
return !reached("module.exports") && !/\bexports\s*(?:\.|\[)/.test(text.replace(/\bmodule\.exports\s*=/g, ""));
|
|
2600
|
+
}
|
|
2601
|
+
const variable = new RegExp(`\\b(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*${call}`).exec(text);
|
|
2602
|
+
if (variable === null) {
|
|
2603
|
+
return false;
|
|
2604
|
+
}
|
|
2605
|
+
const name = escape(variable[1]);
|
|
2606
|
+
const uses = text.match(new RegExp(`(?<![\\w$.])${name}(?![\\w$])`, "g")) ?? [];
|
|
2607
|
+
return uses.length === 2 && new RegExp(`${exported}${name}\\s*(?:;|$)`, "m").test(text);
|
|
2608
|
+
}
|
|
2609
|
+
var READ_BY_NAME = [
|
|
2610
|
+
/^test-gates\.json$/,
|
|
2611
|
+
/^[jt]sconfig(?:\..+)?\.json$/,
|
|
2612
|
+
/^babel\.config\.[cm]?[jt]s(?:on)?$/,
|
|
2613
|
+
/^\.babelrc(?:\.[cm]?js(?:on)?)?$/,
|
|
2614
|
+
/^\.swcrc$/
|
|
2615
|
+
];
|
|
2616
|
+
function loadedScope(manifest, configs, listed2) {
|
|
2617
|
+
const dir = realpath(manifest.dir);
|
|
2618
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2619
|
+
const context = { dir, aliases: manifest.settings.importAliases, cache };
|
|
2620
|
+
const runner = configs.files.slice(1);
|
|
2621
|
+
const texts = runner.map((file) => withoutComments(fs6.readFileSync(file, "utf8")));
|
|
2622
|
+
if (runner.length === 0 || !texts.every((text) => OWN.test(text))) {
|
|
2623
|
+
return { every: NOT_THE_HELPER2 };
|
|
2624
|
+
}
|
|
2625
|
+
if (!texts.every(isHelperResult)) {
|
|
2626
|
+
return {
|
|
2627
|
+
every: "what the runner config exports is not what the helper returns (merged with another config, or changed after the call)"
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
const parts = new Set(runner.map(realpath));
|
|
2631
|
+
for (const file of [...parts]) {
|
|
2632
|
+
if (SCRIPT.test(file)) {
|
|
2633
|
+
fileEdges(file, context).loads.forEach((loaded) => parts.add(loaded));
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
if ([...parts].some((file) => PROJECTS.test(withoutComments(fs6.readFileSync(file, "utf8"))))) {
|
|
2637
|
+
return {
|
|
2638
|
+
every: "the runner config, or a file it is put together from, names projects or a workspace"
|
|
2639
|
+
};
|
|
2640
|
+
}
|
|
2641
|
+
const named = fs6.readdirSync(dir).filter((name) => READ_BY_NAME.some((pattern) => pattern.test(name))).map((name) => path6.join(dir, name));
|
|
2642
|
+
const mocks = path6.join(dir, "__mocks__");
|
|
2643
|
+
const seeds = [
|
|
2644
|
+
...configs.files,
|
|
2645
|
+
...named,
|
|
2646
|
+
...manifest.gates.flatMap((gate) => [gate.path, ...gate.existingSpecs]).map((file) => path6.join(dir, file)),
|
|
2647
|
+
...listed2.filter((file) => file.startsWith(`${mocks}${path6.sep}`))
|
|
2648
|
+
].filter((file) => fs6.existsSync(file)).map(realpath);
|
|
2649
|
+
const naming = new Set(
|
|
2650
|
+
[...configs.files, ...named].filter((file) => fs6.existsSync(file)).map(realpath)
|
|
2651
|
+
);
|
|
2652
|
+
const seen = new Set(seeds);
|
|
2653
|
+
const queue = [...seeds];
|
|
2654
|
+
const add = (file) => {
|
|
2655
|
+
const real = realpath(file);
|
|
2656
|
+
if (!seen.has(real)) {
|
|
2657
|
+
seen.add(real);
|
|
2658
|
+
queue.push(real);
|
|
2659
|
+
}
|
|
2660
|
+
};
|
|
2661
|
+
for (let file = queue.shift(); file !== void 0; file = queue.shift()) {
|
|
2662
|
+
if (fs6.statSync(file).isDirectory()) {
|
|
2663
|
+
continue;
|
|
2664
|
+
}
|
|
2665
|
+
const edges = fileEdges(file, context);
|
|
2666
|
+
if (edges.computed) {
|
|
2667
|
+
return { every: edges.unknown ?? "a file loads modules that are not followed" };
|
|
2668
|
+
}
|
|
2669
|
+
edges.loads.forEach(add);
|
|
2670
|
+
for (const specifier of edges.unnamed) {
|
|
2671
|
+
endingAs(specifier, listed2).forEach(add);
|
|
2672
|
+
}
|
|
2673
|
+
const mock = path6.join(path6.dirname(file), "__mocks__", path6.basename(file));
|
|
2674
|
+
if (fs6.existsSync(mock)) {
|
|
2675
|
+
add(mock);
|
|
2676
|
+
}
|
|
2677
|
+
if (naming.has(file) || SCRIPT.test(file) && path6.dirname(file) === dir) {
|
|
2678
|
+
for (const named2 of namedIn(file)) {
|
|
2679
|
+
const text = named2.replace(/^<rootDir>\//, "");
|
|
2680
|
+
const found = resolveFile(path6.resolve(path6.dirname(file), text), false) ?? resolveFile(path6.resolve(dir, text), false);
|
|
2681
|
+
if (found !== null && !text.includes("*")) {
|
|
2682
|
+
if (!SCRIPT.test(found)) {
|
|
2683
|
+
naming.add(realpath(found));
|
|
1511
2684
|
}
|
|
2685
|
+
add(found);
|
|
1512
2686
|
}
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
return { files: seen };
|
|
2691
|
+
}
|
|
2692
|
+
function endingAs(specifier, files) {
|
|
2693
|
+
const parts = specifier.split("/");
|
|
2694
|
+
const rest = (parts.length > 1 ? parts.slice(1).join("/") : specifier.replace(/^[@#~$]+/, "")).replace(/\.[cm]?[jt]sx?$/, "");
|
|
2695
|
+
if (rest === "") {
|
|
2696
|
+
return [];
|
|
2697
|
+
}
|
|
2698
|
+
return files.filter((file) => {
|
|
2699
|
+
const bare2 = toPosix2(file).replace(/\.[^./]+$/, "");
|
|
2700
|
+
return [bare2, bare2.replace(/\/index$/, "")].some((one) => one.endsWith(`/${rest}`));
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
function namedIn(file) {
|
|
2704
|
+
const text = fs6.readFileSync(file, "utf8");
|
|
2705
|
+
if (SCRIPT.test(file)) {
|
|
2706
|
+
return [...text.matchAll(QUOTED)].map((match) => match[2]);
|
|
2707
|
+
}
|
|
2708
|
+
return ["extends", "plugins", "presets"].flatMap((key) => valuesOf(text, key));
|
|
2709
|
+
}
|
|
2710
|
+
function valuesOf(text, key) {
|
|
2711
|
+
const found = [];
|
|
2712
|
+
for (const match of text.matchAll(new RegExp(`"${key}"\\s*:\\s*`, "g"))) {
|
|
2713
|
+
let at = (match.index ?? 0) + match[0].length;
|
|
2714
|
+
if (text[at] === '"') {
|
|
2715
|
+
const end = text.indexOf('"', at + 1);
|
|
2716
|
+
found.push(text.slice(at + 1, end === -1 ? void 0 : end));
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
2719
|
+
if (text[at] !== "[") {
|
|
2720
|
+
continue;
|
|
2721
|
+
}
|
|
2722
|
+
let depth = 0;
|
|
2723
|
+
for (; at < text.length; at += 1) {
|
|
2724
|
+
const char = text[at];
|
|
2725
|
+
if (char === '"') {
|
|
2726
|
+
const end = text.indexOf('"', at + 1);
|
|
2727
|
+
found.push(text.slice(at + 1, end === -1 ? void 0 : end));
|
|
2728
|
+
at = end === -1 ? text.length : end;
|
|
2729
|
+
} else if (char === "[") {
|
|
2730
|
+
depth += 1;
|
|
2731
|
+
} else if (char === "]") {
|
|
2732
|
+
depth -= 1;
|
|
2733
|
+
if (depth === 0) {
|
|
2734
|
+
break;
|
|
1519
2735
|
}
|
|
1520
|
-
summary.unallowed += 1;
|
|
1521
|
-
survivors.push(describeMutant(gate.path, entry));
|
|
1522
|
-
} else {
|
|
1523
|
-
violations.push({
|
|
1524
|
-
file: gate.path,
|
|
1525
|
-
line: mutant.location.start.line,
|
|
1526
|
-
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})`
|
|
1527
|
-
});
|
|
1528
2736
|
}
|
|
1529
2737
|
}
|
|
1530
2738
|
}
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
2739
|
+
return found;
|
|
2740
|
+
}
|
|
2741
|
+
function withoutComments(text) {
|
|
2742
|
+
let out = "";
|
|
2743
|
+
for (let at = 0; at < text.length; at += 1) {
|
|
2744
|
+
const char = text[at];
|
|
2745
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
2746
|
+
let end = at + 1;
|
|
2747
|
+
while (end < text.length && text[end] !== char && text[end] !== "\n") {
|
|
2748
|
+
end += text[end] === "\\" ? 2 : 1;
|
|
2749
|
+
}
|
|
2750
|
+
out += text.slice(at, end + 1);
|
|
2751
|
+
at = end;
|
|
2752
|
+
} else if (char === "/" && text[at + 1] === "/") {
|
|
2753
|
+
const end = text.indexOf("\n", at);
|
|
2754
|
+
at = end === -1 ? text.length : end - 1;
|
|
2755
|
+
} else if (char === "/" && text[at + 1] === "*") {
|
|
2756
|
+
const end = text.indexOf("*/", at + 2);
|
|
2757
|
+
at = end === -1 ? text.length : end + 1;
|
|
2758
|
+
} else {
|
|
2759
|
+
out += char;
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
return out;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
// src/commands/mutation-result.ts
|
|
2766
|
+
import fs7 from "fs";
|
|
2767
|
+
import path7 from "path";
|
|
2768
|
+
|
|
2769
|
+
// src/core/mutation-result.ts
|
|
2770
|
+
var DETECTED = /* @__PURE__ */ new Set(["Killed", "Timeout"]);
|
|
2771
|
+
var SURVIVED = /* @__PURE__ */ new Set(["Survived", "NoCoverage"]);
|
|
2772
|
+
var NOT_EVALUABLE = /* @__PURE__ */ new Set(["RuntimeError", "CompileError"]);
|
|
2773
|
+
function normalizeCode(text) {
|
|
2774
|
+
return text.replace(/\s+/g, " ").trim();
|
|
2775
|
+
}
|
|
2776
|
+
function normalizePath(file) {
|
|
2777
|
+
return file.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
2778
|
+
}
|
|
2779
|
+
function normalizeNewlines(text) {
|
|
2780
|
+
return text.replace(/\r\n/g, "\n");
|
|
2781
|
+
}
|
|
2782
|
+
function sliceByLocation(source, location) {
|
|
2783
|
+
const lines = source.split("\n");
|
|
2784
|
+
const { start, end } = location;
|
|
2785
|
+
const first = lines[start.line - 1];
|
|
2786
|
+
const last = lines[end.line - 1];
|
|
2787
|
+
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) {
|
|
2788
|
+
return null;
|
|
2789
|
+
}
|
|
2790
|
+
if (start.line === end.line) {
|
|
2791
|
+
return first.slice(start.column - 1, end.column - 1);
|
|
2792
|
+
}
|
|
2793
|
+
return [
|
|
2794
|
+
first.slice(start.column - 1),
|
|
2795
|
+
...lines.slice(start.line, end.line - 1),
|
|
2796
|
+
last.slice(0, end.column - 1)
|
|
2797
|
+
].join("\n");
|
|
2798
|
+
}
|
|
2799
|
+
function keyOf(mutator, original, replacement) {
|
|
2800
|
+
return JSON.stringify([mutator, normalizeCode(original), normalizeCode(replacement)]);
|
|
2801
|
+
}
|
|
2802
|
+
function describeMutant(file, entry) {
|
|
2803
|
+
const { mutant } = entry;
|
|
2804
|
+
return {
|
|
2805
|
+
file,
|
|
2806
|
+
line: mutant.location.start.line,
|
|
2807
|
+
column: mutant.location.start.column,
|
|
2808
|
+
status: mutant.status,
|
|
2809
|
+
mutator: mutant.mutatorName,
|
|
2810
|
+
original: normalizeCode(entry.original),
|
|
2811
|
+
replacement: normalizeCode(entry.replacement),
|
|
2812
|
+
occurrence: entry.occurrence,
|
|
2813
|
+
sameKeyCount: entry.sameKeyCount,
|
|
2814
|
+
allowance: {
|
|
2815
|
+
mutator: mutant.mutatorName,
|
|
2816
|
+
original: normalizeCode(entry.original),
|
|
2817
|
+
replacement: normalizeCode(entry.replacement),
|
|
2818
|
+
...entry.sameKeyCount > 1 ? { occurrence: entry.occurrence } : {},
|
|
2819
|
+
reason: ""
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
function describeAllowance(entry) {
|
|
2824
|
+
const occurrence = entry.occurrence === void 0 ? "" : ` / occurrence ${entry.occurrence}`;
|
|
2825
|
+
return `${entry.mutator} / ${normalizeCode(entry.original)} \u2192 ${normalizeCode(entry.replacement)}${occurrence}`;
|
|
2826
|
+
}
|
|
2827
|
+
function judgeMutationReport(input) {
|
|
2828
|
+
const violations = [];
|
|
2829
|
+
const survivors = [];
|
|
2830
|
+
const unexpectedTimeouts = [];
|
|
2831
|
+
const summary = {
|
|
2832
|
+
total: 0,
|
|
2833
|
+
detected: 0,
|
|
2834
|
+
timeout: 0,
|
|
2835
|
+
allowed: 0,
|
|
2836
|
+
notEvaluable: 0,
|
|
2837
|
+
unallowed: 0,
|
|
2838
|
+
expectedTimeout: 0
|
|
2839
|
+
};
|
|
2840
|
+
const files = /* @__PURE__ */ new Map();
|
|
2841
|
+
for (const [file, result] of Object.entries(input.report.files ?? {})) {
|
|
2842
|
+
files.set(normalizePath(file), result);
|
|
2843
|
+
}
|
|
2844
|
+
for (const gate of input.gates) {
|
|
2845
|
+
const gatePath = normalizePath(gate.path);
|
|
2846
|
+
const shapeViolations = validateEquivalentMutants(gate.path, gate.equivalentMutants);
|
|
2847
|
+
violations.push(...shapeViolations);
|
|
2848
|
+
violations.push(
|
|
2849
|
+
...validateEquivalentMutants(gate.path, gate.expectedTimeouts, "expectedTimeouts")
|
|
2850
|
+
);
|
|
2851
|
+
const result = files.get(gatePath);
|
|
2852
|
+
if (!result || !Array.isArray(result.mutants) || result.mutants.length === 0) {
|
|
2853
|
+
violations.push({
|
|
2854
|
+
file: gate.path,
|
|
2855
|
+
message: "no mutants in the report (the gate was not mutation tested)"
|
|
2856
|
+
});
|
|
2857
|
+
continue;
|
|
2858
|
+
}
|
|
2859
|
+
const diskSource = input.readSource(gate.path);
|
|
2860
|
+
const source = result.source ?? diskSource;
|
|
2861
|
+
if (source === void 0) {
|
|
2862
|
+
violations.push({
|
|
2863
|
+
file: gate.path,
|
|
2864
|
+
message: "source is neither in the report nor readable on disk"
|
|
2865
|
+
});
|
|
2866
|
+
continue;
|
|
2867
|
+
}
|
|
2868
|
+
if (result.source !== void 0 && diskSource !== void 0 && normalizeNewlines(result.source) !== normalizeNewlines(diskSource)) {
|
|
2869
|
+
violations.push({
|
|
2870
|
+
file: gate.path,
|
|
2871
|
+
message: "the report was made from a different version of this file. Run Stryker again"
|
|
2872
|
+
});
|
|
2873
|
+
continue;
|
|
2874
|
+
}
|
|
2875
|
+
const located = [];
|
|
2876
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2877
|
+
const sorted2 = [...result.mutants].sort(
|
|
2878
|
+
(a, b) => a.location.start.line - b.location.start.line || a.location.start.column - b.location.start.column
|
|
2879
|
+
);
|
|
2880
|
+
let locationsValid = true;
|
|
2881
|
+
for (const mutant of sorted2) {
|
|
2882
|
+
const original = sliceByLocation(normalizeNewlines(source), mutant.location);
|
|
2883
|
+
if (original === null) {
|
|
2884
|
+
violations.push({
|
|
2885
|
+
file: gate.path,
|
|
2886
|
+
line: mutant.location.start.line,
|
|
2887
|
+
message: `the report location of a ${mutant.mutatorName} mutant is outside the source`
|
|
2888
|
+
});
|
|
2889
|
+
locationsValid = false;
|
|
2890
|
+
continue;
|
|
2891
|
+
}
|
|
2892
|
+
const replacement = mutant.replacement ?? "";
|
|
2893
|
+
const key = keyOf(mutant.mutatorName, original, replacement);
|
|
2894
|
+
const group = byKey.get(key) ?? [];
|
|
2895
|
+
const entry = {
|
|
2896
|
+
mutant,
|
|
2897
|
+
original,
|
|
2898
|
+
replacement,
|
|
2899
|
+
occurrence: group.length + 1,
|
|
2900
|
+
sameKeyCount: 0
|
|
2901
|
+
};
|
|
2902
|
+
group.push(entry);
|
|
2903
|
+
byKey.set(key, group);
|
|
2904
|
+
located.push(entry);
|
|
2905
|
+
}
|
|
2906
|
+
for (const group of byKey.values()) {
|
|
2907
|
+
for (const entry of group) {
|
|
2908
|
+
entry.sameKeyCount = group.length;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
if (!locationsValid) {
|
|
2912
|
+
continue;
|
|
2913
|
+
}
|
|
2914
|
+
const claim = (field, entries, noun, accept) => {
|
|
2915
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
2916
|
+
if (!Array.isArray(entries)) {
|
|
2917
|
+
return claimed;
|
|
2918
|
+
}
|
|
2919
|
+
entries.forEach((entry, index) => {
|
|
2920
|
+
if (!isUsableEquivalentMutant(entry)) {
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
const at = `${gate.path}: ${field}[${index}] (${describeAllowance(entry)})`;
|
|
2924
|
+
const group = byKey.get(keyOf(entry.mutator, entry.original, entry.replacement)) ?? [];
|
|
2925
|
+
if (entry.occurrence === void 0 && group.length > 1) {
|
|
2926
|
+
violations.push({
|
|
2927
|
+
file: MANIFEST_FILE,
|
|
2928
|
+
message: `${at}: matches ${group.length} mutants in the file. Add "occurrence" (1-based) to pick one`
|
|
2929
|
+
});
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
const target = group[(entry.occurrence ?? 1) - 1];
|
|
2933
|
+
const refusal = target ? accept(target.mutant.status) : `stale ${noun} (no such mutant in this run). Remove it`;
|
|
2934
|
+
if (!target || refusal !== null) {
|
|
2935
|
+
violations.push({ file: MANIFEST_FILE, message: `${at}: ${refusal}` });
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
if (claimed.has(target)) {
|
|
2939
|
+
violations.push({
|
|
2940
|
+
file: MANIFEST_FILE,
|
|
2941
|
+
message: `${at}: another ${noun} already covers this mutant`
|
|
2942
|
+
});
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
claimed.add(target);
|
|
2946
|
+
});
|
|
2947
|
+
return claimed;
|
|
2948
|
+
};
|
|
2949
|
+
const allowed = claim(
|
|
2950
|
+
"equivalentMutants",
|
|
2951
|
+
gate.equivalentMutants,
|
|
2952
|
+
"allowance",
|
|
2953
|
+
(status) => SURVIVED.has(status) ? null : `stale allowance (the mutant is now ${status}). Remove it`
|
|
2954
|
+
);
|
|
2955
|
+
const expected = claim(
|
|
2956
|
+
"expectedTimeouts",
|
|
2957
|
+
gate.expectedTimeouts,
|
|
2958
|
+
"entry",
|
|
2959
|
+
(status) => SURVIVED.has(status) ? `the mutant ${status === "NoCoverage" ? "has no coverage" : "survived"} instead of timing out. Detect it with a test` : null
|
|
2960
|
+
);
|
|
2961
|
+
for (const entry of located) {
|
|
2962
|
+
const { mutant } = entry;
|
|
2963
|
+
summary.total += 1;
|
|
2964
|
+
if (DETECTED.has(mutant.status)) {
|
|
2965
|
+
summary.detected += 1;
|
|
2966
|
+
if (mutant.status === "Timeout") {
|
|
2967
|
+
summary.timeout += 1;
|
|
2968
|
+
if (expected.has(entry)) {
|
|
2969
|
+
summary.expectedTimeout += 1;
|
|
2970
|
+
} else {
|
|
2971
|
+
unexpectedTimeouts.push(describeMutant(gate.path, entry));
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
} else if (NOT_EVALUABLE.has(mutant.status)) {
|
|
2975
|
+
summary.notEvaluable += 1;
|
|
2976
|
+
} else if (SURVIVED.has(mutant.status)) {
|
|
2977
|
+
if (allowed.has(entry)) {
|
|
2978
|
+
summary.allowed += 1;
|
|
2979
|
+
continue;
|
|
2980
|
+
}
|
|
2981
|
+
summary.unallowed += 1;
|
|
2982
|
+
survivors.push(describeMutant(gate.path, entry));
|
|
2983
|
+
} else {
|
|
2984
|
+
violations.push({
|
|
2985
|
+
file: gate.path,
|
|
2986
|
+
line: mutant.location.start.line,
|
|
2987
|
+
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})`
|
|
2988
|
+
});
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
if (summary.total === 0) {
|
|
2993
|
+
violations.push({
|
|
2994
|
+
file: MANIFEST_FILE,
|
|
2995
|
+
message: "no mutants were evaluated (the mutation test ran on nothing)"
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
2998
|
+
const maxTimeouts = input.maxTimeouts ?? 0;
|
|
2999
|
+
if (unexpectedTimeouts.length > maxTimeouts) {
|
|
3000
|
+
violations.push({
|
|
3001
|
+
file: MANIFEST_FILE,
|
|
3002
|
+
message: `${unexpectedTimeouts.length} mutant(s) timed out outside expectedTimeouts; settings.mutation.maxTimeouts allows ${maxTimeouts}`
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
return { violations, survivors, unexpectedTimeouts, summary };
|
|
3006
|
+
}
|
|
3007
|
+
function formatSurvivor(survivor) {
|
|
3008
|
+
const occurrence = survivor.sameKeyCount > 1 ? ` / occurrence ${survivor.occurrence} of ${survivor.sameKeyCount}` : "";
|
|
3009
|
+
return `${survivor.file}:${survivor.line}:${survivor.column} / ${survivor.mutator} / ${survivor.original} \u2192 ${survivor.replacement}${occurrence} (${survivor.status})`;
|
|
3010
|
+
}
|
|
3011
|
+
function formatSummary(summary) {
|
|
3012
|
+
return `mutants ${summary.total} / detected ${summary.detected} (timeout ${summary.timeout}) / allowed equivalent ${summary.allowed} / not evaluable ${summary.notEvaluable} / unallowed survivors ${summary.unallowed}`;
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// src/commands/mutation-result.ts
|
|
3016
|
+
function reportPathOf(manifest, override) {
|
|
3017
|
+
return path7.resolve(manifest.dir, override ?? manifest.settings.strykerReportFile);
|
|
3018
|
+
}
|
|
3019
|
+
function runMutationResult(dir, reportOverride, io2, only = [], seen = () => void 0) {
|
|
3020
|
+
const manifest = readManifest(dir);
|
|
3021
|
+
const gates = only.length === 0 ? manifest.gates : manifest.gates.filter((gate) => only.includes(gate.path));
|
|
3022
|
+
const caveat = only.length === 0 ? "" : gateRunCaveat(gates.length, manifest.gates.length);
|
|
3023
|
+
const reportPath = reportPathOf(manifest, reportOverride);
|
|
3024
|
+
const shown = path7.relative(manifest.dir, reportPath) || reportPath;
|
|
3025
|
+
let report;
|
|
3026
|
+
try {
|
|
3027
|
+
report = JSON.parse(fs7.readFileSync(reportPath, "utf8"));
|
|
3028
|
+
} catch (error) {
|
|
3029
|
+
io2.err(
|
|
3030
|
+
`test-gates mutation: cannot read the Stryker JSON report ${shown} (${error.message}). Did Stryker finish, with the "json" reporter writing to this path?`
|
|
3031
|
+
);
|
|
3032
|
+
return 1;
|
|
3033
|
+
}
|
|
3034
|
+
if (typeof report !== "object" || report === null) {
|
|
3035
|
+
io2.err(`test-gates mutation: ${shown} is not a Stryker JSON report`);
|
|
3036
|
+
return 1;
|
|
3037
|
+
}
|
|
3038
|
+
const verdict = judgeMutationReport({
|
|
3039
|
+
gates,
|
|
3040
|
+
report,
|
|
3041
|
+
maxTimeouts: manifest.settings.maxTimeouts,
|
|
3042
|
+
readSource: (gatePath) => {
|
|
3043
|
+
try {
|
|
3044
|
+
return fs7.readFileSync(path7.join(manifest.dir, gatePath), "utf8");
|
|
3045
|
+
} catch {
|
|
3046
|
+
return void 0;
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
});
|
|
3050
|
+
seen({ report, verdict });
|
|
3051
|
+
const summary = formatSummary(verdict.summary);
|
|
3052
|
+
if (verdict.violations.length === 0 && verdict.survivors.length === 0) {
|
|
3053
|
+
io2.out(`test-gates mutation: OK (${summary})${caveat}`);
|
|
3054
|
+
return 0;
|
|
3055
|
+
}
|
|
3056
|
+
if (verdict.survivors.length > 0) {
|
|
3057
|
+
io2.err(
|
|
3058
|
+
`test-gates mutation: ${verdict.survivors.length} surviving mutant(s) not in the allow list`
|
|
3059
|
+
);
|
|
3060
|
+
for (const survivor of verdict.survivors) {
|
|
3061
|
+
io2.err(` - ${formatSurvivor(survivor)}`);
|
|
3062
|
+
}
|
|
3063
|
+
io2.err("");
|
|
3064
|
+
io2.err(
|
|
3065
|
+
"Fix the test so that it detects the mutant. Only when the mutant cannot be observed from"
|
|
3066
|
+
);
|
|
3067
|
+
io2.err("outside (same return value, exception and side effects for every input), add it to");
|
|
3068
|
+
io2.err('"equivalentMutants" of the gate in test-gates.json and fill in "reason":');
|
|
3069
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
3070
|
+
for (const survivor of verdict.survivors) {
|
|
3071
|
+
byFile.set(survivor.file, [...byFile.get(survivor.file) ?? [], survivor]);
|
|
3072
|
+
}
|
|
3073
|
+
for (const [file, survivors] of byFile) {
|
|
3074
|
+
io2.err("");
|
|
3075
|
+
io2.err(` ${file}`);
|
|
3076
|
+
survivors.forEach((survivor, index) => {
|
|
3077
|
+
const comma = index === survivors.length - 1 ? "" : ",";
|
|
3078
|
+
io2.err(` ${JSON.stringify(survivor.allowance)}${comma}`);
|
|
3079
|
+
});
|
|
3080
|
+
}
|
|
3081
|
+
io2.err("");
|
|
3082
|
+
}
|
|
3083
|
+
const maxTimeouts = manifest.settings.maxTimeouts;
|
|
3084
|
+
if (verdict.unexpectedTimeouts.length > maxTimeouts) {
|
|
3085
|
+
io2.err(
|
|
3086
|
+
`test-gates mutation: ${verdict.unexpectedTimeouts.length} mutant(s) timed out (settings.mutation.maxTimeouts: ${maxTimeouts})`
|
|
3087
|
+
);
|
|
3088
|
+
for (const mutant of verdict.unexpectedTimeouts) {
|
|
3089
|
+
io2.err(` - ${formatSurvivor(mutant)}`);
|
|
3090
|
+
}
|
|
3091
|
+
io2.err("");
|
|
3092
|
+
io2.err("A timeout counts as detected, but on a loaded machine a mutant that would survive");
|
|
3093
|
+
io2.err("can time out instead. Run again with less load (-- --concurrency 1). Only for a");
|
|
3094
|
+
io2.err("mutant that can never finish (a loop that no longer ends), add it to");
|
|
3095
|
+
io2.err('"expectedTimeouts" of the gate in test-gates.json and fill in "reason"');
|
|
3096
|
+
io2.err("(settings.mutation.maxTimeouts raises the limit for the whole project):");
|
|
3097
|
+
for (const mutant of verdict.unexpectedTimeouts) {
|
|
3098
|
+
io2.err(` ${mutant.file}: ${JSON.stringify(mutant.allowance)}`);
|
|
3099
|
+
}
|
|
3100
|
+
io2.err("");
|
|
3101
|
+
}
|
|
3102
|
+
if (verdict.violations.length > 0) {
|
|
3103
|
+
printViolations(io2, "mutation", verdict.violations);
|
|
3104
|
+
}
|
|
3105
|
+
io2.err(`test-gates mutation: FAILED (${summary})${caveat}`);
|
|
3106
|
+
return 1;
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
// src/commands/mutation.ts
|
|
3110
|
+
var CONFIG_MARK = "\0test-gates-stryker-config\0";
|
|
3111
|
+
function loadStrykerConfig(manifest, config) {
|
|
3112
|
+
const file = path8.resolve(manifest.dir, config);
|
|
3113
|
+
if (file.endsWith(".json")) {
|
|
3114
|
+
try {
|
|
3115
|
+
return JSON.parse(fs8.readFileSync(file, "utf8"));
|
|
3116
|
+
} catch {
|
|
3117
|
+
return void 0;
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
const script = `const loaded = await import(${JSON.stringify(pathToFileURL(file).href)});const config = loaded.default?.default ?? loaded.default ?? loaded;process.stdout.write(${JSON.stringify(CONFIG_MARK)} + JSON.stringify({incremental: config.incremental, force: config.force,incrementalFile: config.incrementalFile, tempDirName: config.tempDirName,testRunner: config.testRunner, vitestConfig: config.vitest?.configFile,jestConfig: config.jest?.configFile,}) + ${JSON.stringify(CONFIG_MARK)});`;
|
|
3121
|
+
const result = spawnSync2(process.execPath, ["--input-type=module", "--eval", script], {
|
|
3122
|
+
cwd: manifest.dir,
|
|
3123
|
+
encoding: "utf8",
|
|
3124
|
+
timeout: 6e4
|
|
3125
|
+
});
|
|
3126
|
+
const printed = result.stdout?.split(CONFIG_MARK)[1];
|
|
3127
|
+
if (result.status !== 0 || printed === void 0) {
|
|
3128
|
+
return void 0;
|
|
3129
|
+
}
|
|
3130
|
+
return JSON.parse(printed);
|
|
3131
|
+
}
|
|
3132
|
+
function modifiedAt(file) {
|
|
3133
|
+
try {
|
|
3134
|
+
const stat = fs8.statSync(file, { bigint: true });
|
|
3135
|
+
return `${stat.mtimeNs} ${stat.ino}`;
|
|
3136
|
+
} catch {
|
|
3137
|
+
return null;
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
function guardRecordPath(incrementalFile) {
|
|
3141
|
+
return `${incrementalFile}.test-gates.json`;
|
|
3142
|
+
}
|
|
3143
|
+
function coveringSpecs(report, gates) {
|
|
3144
|
+
const fileOf = /* @__PURE__ */ new Map();
|
|
3145
|
+
for (const [file, entry] of Object.entries(report.testFiles ?? {})) {
|
|
3146
|
+
for (const test of entry.tests ?? []) {
|
|
3147
|
+
fileOf.set(test.id, file);
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
const covering = /* @__PURE__ */ Object.create(null);
|
|
3151
|
+
for (const gate of gates) {
|
|
3152
|
+
const specs = /* @__PURE__ */ new Set();
|
|
3153
|
+
let placed = true;
|
|
3154
|
+
const key = withoutDots(gate);
|
|
3155
|
+
for (const mutant of report.files?.[key]?.mutants ?? []) {
|
|
3156
|
+
for (const id of [...mutant.coveredBy ?? [], ...mutant.killedBy ?? []]) {
|
|
3157
|
+
const file = fileOf.get(id);
|
|
3158
|
+
if (file === void 0) {
|
|
3159
|
+
placed = false;
|
|
3160
|
+
} else {
|
|
3161
|
+
specs.add(file);
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
covering[gate] = placed ? [...specs].sort() : null;
|
|
3166
|
+
}
|
|
3167
|
+
return covering;
|
|
3168
|
+
}
|
|
3169
|
+
function decideReuse(manifest, config, strykerArgs, part, affected) {
|
|
3170
|
+
const loaded = loadStrykerConfig(manifest, config);
|
|
3171
|
+
const options = incrementalOptions(loaded, strykerArgs);
|
|
3172
|
+
const base = fs8.realpathSync(manifest.dir);
|
|
3173
|
+
const configs = () => runnerConfigs(base, config, loaded ?? {});
|
|
3174
|
+
const decision = {
|
|
3175
|
+
incremental: false,
|
|
3176
|
+
incrementalFile: null,
|
|
3177
|
+
recordFile: null,
|
|
3178
|
+
incrementalHash: null,
|
|
3179
|
+
forced: options.force,
|
|
3180
|
+
snapshot: null,
|
|
3181
|
+
failure: null,
|
|
3182
|
+
root: base,
|
|
3183
|
+
widened: [],
|
|
3184
|
+
scope: null,
|
|
3185
|
+
force: null,
|
|
3186
|
+
plan: null,
|
|
3187
|
+
last: void 0,
|
|
3188
|
+
ci: affected ? affectedRefused(process.env) : null,
|
|
3189
|
+
configs
|
|
3190
|
+
};
|
|
3191
|
+
if (loaded !== void 0 && !options.incremental) {
|
|
3192
|
+
return decision;
|
|
3193
|
+
}
|
|
3194
|
+
decision.incremental = true;
|
|
3195
|
+
const incrementalFile = path8.resolve(base, options.incrementalFile);
|
|
3196
|
+
const recordFile = guardRecordPath(incrementalFile);
|
|
3197
|
+
decision.incrementalFile = incrementalFile;
|
|
3198
|
+
decision.recordFile = recordFile;
|
|
3199
|
+
let snapshot = null;
|
|
3200
|
+
let root = base;
|
|
3201
|
+
let failure = "the Stryker config could not be loaded to see where the incremental file is";
|
|
3202
|
+
if (loaded !== void 0) {
|
|
3203
|
+
const scope = (listed2) => {
|
|
3204
|
+
try {
|
|
3205
|
+
const found = loadedScope(manifest, configs(), listed2);
|
|
3206
|
+
decision.scope = "files" in found ? { files: found.files.size } : found;
|
|
3207
|
+
return "files" in found ? found.files : null;
|
|
3208
|
+
} catch (error) {
|
|
3209
|
+
decision.scope = { every: error.message };
|
|
3210
|
+
return null;
|
|
3211
|
+
}
|
|
3212
|
+
};
|
|
3213
|
+
try {
|
|
3214
|
+
let widened;
|
|
3215
|
+
({ snapshot, root, widened } = snapshotOf({
|
|
3216
|
+
scope,
|
|
3217
|
+
dir: manifest.dir,
|
|
3218
|
+
written: [
|
|
3219
|
+
incrementalFile,
|
|
3220
|
+
recordFile,
|
|
3221
|
+
path8.resolve(base, options.tempDirName),
|
|
3222
|
+
path8.resolve(base, manifest.settings.strykerReportFile)
|
|
3223
|
+
],
|
|
3224
|
+
args: comparableArgs(strykerArgs)
|
|
3225
|
+
}));
|
|
3226
|
+
decision.widened = widened;
|
|
3227
|
+
} catch (error) {
|
|
3228
|
+
failure = `the files the run depends on could not be listed (${error.message})`;
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
decision.snapshot = snapshot;
|
|
3232
|
+
decision.failure = snapshot === null ? failure : null;
|
|
3233
|
+
decision.root = root;
|
|
3234
|
+
const incremental = hashFile(incrementalFile);
|
|
3235
|
+
decision.incrementalHash = incremental;
|
|
3236
|
+
if (part) {
|
|
3237
|
+
decision.force = incremental !== null && !options.force ? "with --gate nothing is taken over from an earlier run" : null;
|
|
3238
|
+
return decision;
|
|
3239
|
+
}
|
|
3240
|
+
let stored;
|
|
3241
|
+
try {
|
|
3242
|
+
stored = JSON.parse(fs8.readFileSync(recordFile, "utf8"));
|
|
3243
|
+
} catch {
|
|
3244
|
+
stored = void 0;
|
|
3245
|
+
}
|
|
3246
|
+
const ci = decision.ci;
|
|
3247
|
+
let force = null;
|
|
3248
|
+
let plan = null;
|
|
3249
|
+
let last;
|
|
3250
|
+
if (incremental !== null && !options.force) {
|
|
3251
|
+
if (snapshot === null) {
|
|
3252
|
+
force = failure;
|
|
3253
|
+
} else if (!affected) {
|
|
3254
|
+
force = refuseReuse({ record: stored, incremental, snapshot });
|
|
3255
|
+
} else if (ci !== null) {
|
|
3256
|
+
force = ci;
|
|
3257
|
+
} else {
|
|
3258
|
+
const read2 = readRecord({ record: stored, incremental, snapshot });
|
|
3259
|
+
if ("refused" in read2) {
|
|
3260
|
+
force = read2.refused;
|
|
3261
|
+
} else {
|
|
3262
|
+
last = read2.record.affected;
|
|
3263
|
+
try {
|
|
3264
|
+
const inputs = affectedInputs(manifest, {
|
|
3265
|
+
root,
|
|
3266
|
+
files: snapshot.files,
|
|
3267
|
+
configs: configs(),
|
|
3268
|
+
tests: last?.tests ?? [],
|
|
3269
|
+
covering: Object.fromEntries(
|
|
3270
|
+
Object.entries(last?.gates ?? {}).map(([gate, entry]) => [gate, entry.specs])
|
|
3271
|
+
)
|
|
3272
|
+
});
|
|
3273
|
+
plan = planAffected({
|
|
3274
|
+
before: { files: read2.record.snapshot.files, affected: last },
|
|
3275
|
+
now: snapshot.files,
|
|
3276
|
+
subproject: inputs.subproject,
|
|
3277
|
+
config: inputs.config,
|
|
3278
|
+
gates: manifest.gates.map((gate) => ({
|
|
3279
|
+
path: gate.path,
|
|
3280
|
+
files: inputs.gates[gate.path]?.files ?? []
|
|
3281
|
+
}))
|
|
3282
|
+
});
|
|
3283
|
+
} catch (error) {
|
|
3284
|
+
if (!(error instanceof AffectedError)) {
|
|
3285
|
+
throw error;
|
|
3286
|
+
}
|
|
3287
|
+
plan = { full: `what a gate depends on cannot be told: ${error.message}` };
|
|
3288
|
+
}
|
|
3289
|
+
if ("full" in plan) {
|
|
3290
|
+
force = plan.full;
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
} else if (affected && incremental === null && !options.force) {
|
|
3295
|
+
force = "there is no incremental file of an earlier run";
|
|
3296
|
+
}
|
|
3297
|
+
decision.force = force;
|
|
3298
|
+
decision.plan = plan;
|
|
3299
|
+
decision.last = last;
|
|
3300
|
+
return decision;
|
|
3301
|
+
}
|
|
3302
|
+
function guardIncremental(manifest, config, strykerArgs, part, affected) {
|
|
3303
|
+
const nothing = { force: null, mutate: null, said: [], record: () => void 0 };
|
|
3304
|
+
const decision = decideReuse(manifest, config, strykerArgs, part, affected);
|
|
3305
|
+
if (!decision.incremental) {
|
|
3306
|
+
return affected ? {
|
|
3307
|
+
...nothing,
|
|
3308
|
+
said: [
|
|
3309
|
+
"test-gates mutation --affected: Stryker does not run in incremental mode, so every mutant is run"
|
|
3310
|
+
]
|
|
3311
|
+
} : nothing;
|
|
3312
|
+
}
|
|
3313
|
+
const { snapshot, root, plan, last, ci, configs, force } = decision;
|
|
3314
|
+
const incrementalFile = decision.incrementalFile;
|
|
3315
|
+
const recordFile = decision.recordFile;
|
|
3316
|
+
const incremental = decision.incrementalHash;
|
|
3317
|
+
const options = { force: decision.forced };
|
|
3318
|
+
if (part) {
|
|
3319
|
+
fs8.rmSync(recordFile, { force: true });
|
|
3320
|
+
return { ...nothing, force };
|
|
3321
|
+
}
|
|
3322
|
+
fs8.rmSync(recordFile, { force: true });
|
|
3323
|
+
const before = modifiedAt(incrementalFile);
|
|
3324
|
+
const rerun = plan !== null && "rerun" in plan ? plan.rerun.map((entry) => entry.gate) : null;
|
|
3325
|
+
const everyGate = force ?? (rerun !== null && rerun.length > 0 && rerun.length === manifest.gates.length ? "every gate is affected" : null);
|
|
3326
|
+
let ranges = [];
|
|
3327
|
+
if (everyGate === null && incremental !== null && !options.force) {
|
|
3328
|
+
const kept = rerun === null ? manifest.gates.map((gate) => gate.path) : manifest.gates.map((gate) => gate.path).filter((gate) => !rerun.includes(gate));
|
|
3329
|
+
try {
|
|
3330
|
+
ranges = timeoutRanges(
|
|
3331
|
+
JSON.parse(fs8.readFileSync(incrementalFile, "utf8")),
|
|
3332
|
+
kept
|
|
3333
|
+
);
|
|
3334
|
+
} catch {
|
|
3335
|
+
ranges = [];
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
const subset = rerun !== null && rerun.length > 0 && everyGate === null ? rerun : [];
|
|
3339
|
+
let said = [];
|
|
3340
|
+
if (affected && !options.force) {
|
|
3341
|
+
if (force !== null) {
|
|
3342
|
+
said = describePlan({ full: force });
|
|
3343
|
+
} else if (plan !== null) {
|
|
3344
|
+
said = describePlan(plan, 5, ranges.length);
|
|
3345
|
+
}
|
|
3346
|
+
} else if (ranges.length > 0) {
|
|
3347
|
+
said = [
|
|
3348
|
+
`test-gates mutation: ${ranges.length} mutant(s) that timed out in the last run are run again, the other results are reused`
|
|
3349
|
+
];
|
|
3350
|
+
}
|
|
3351
|
+
return {
|
|
3352
|
+
// Every gate is run again: the same as a run of every mutant.
|
|
3353
|
+
force: everyGate,
|
|
3354
|
+
mutate: subset.length + ranges.length > 0 ? [...subset, ...ranges] : null,
|
|
3355
|
+
said,
|
|
3356
|
+
record: (judged) => {
|
|
3357
|
+
const written = hashFile(incrementalFile);
|
|
3358
|
+
if (snapshot === null || written === null || modifiedAt(incrementalFile) === before) {
|
|
3359
|
+
return;
|
|
3360
|
+
}
|
|
3361
|
+
const record = { format: RECORD_FORMAT, incremental: written, snapshot };
|
|
3362
|
+
if (affected && ci === null && judged !== null) {
|
|
3363
|
+
record.affected = affectedRecord(judged, rerun, last);
|
|
3364
|
+
}
|
|
3365
|
+
try {
|
|
3366
|
+
fs8.writeFileSync(recordFile, `${JSON.stringify(record)}
|
|
3367
|
+
`);
|
|
3368
|
+
} catch {
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
};
|
|
3372
|
+
function affectedRecord(judged, ran, previous) {
|
|
3373
|
+
const gates = manifest.gates.map((gate) => gate.path);
|
|
3374
|
+
const dirty = new Set(
|
|
3375
|
+
[...judged.verdict.survivors, ...judged.verdict.unexpectedTimeouts].map((one) => one.file)
|
|
3376
|
+
);
|
|
3377
|
+
const fresh = coveringSpecs(judged.report, gates);
|
|
3378
|
+
const covering = /* @__PURE__ */ Object.create(null);
|
|
3379
|
+
for (const gate of gates) {
|
|
3380
|
+
const specs = ran === null || ran.includes(gate) ? fresh[gate] : previous?.gates[gate]?.specs ?? null;
|
|
3381
|
+
if (specs !== null && specs !== void 0 && !dirty.has(gate)) {
|
|
3382
|
+
covering[gate] = specs;
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
try {
|
|
3386
|
+
const inputs = affectedInputs(manifest, {
|
|
3387
|
+
root,
|
|
3388
|
+
files: snapshot.files,
|
|
3389
|
+
configs: configs(),
|
|
3390
|
+
tests: Object.keys(judged.report.testFiles ?? {}),
|
|
3391
|
+
covering
|
|
3392
|
+
});
|
|
3393
|
+
const recorded = /* @__PURE__ */ Object.create(null);
|
|
3394
|
+
for (const gate of Object.keys(covering)) {
|
|
3395
|
+
const entry = inputs.gates[gate];
|
|
3396
|
+
if (entry !== void 0) {
|
|
3397
|
+
recorded[gate] = { specs: entry.specs, files: entry.files };
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
return { tests: Object.keys(judged.report.testFiles ?? {}).sort(), gates: recorded };
|
|
3401
|
+
} catch (error) {
|
|
3402
|
+
if (error instanceof AffectedError) {
|
|
3403
|
+
return void 0;
|
|
3404
|
+
}
|
|
3405
|
+
throw error;
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
function runMutation(dir, strykerArgs, io2, only = [], affected = false) {
|
|
3410
|
+
const part = only.length > 0;
|
|
3411
|
+
if (affected && strykerArgs.some((arg) => /^(?:--mutate|-m)(?:=|$)/.test(arg))) {
|
|
3412
|
+
throw new UsageError(
|
|
3413
|
+
"--affected and --mutate cannot be combined: --affected sets --mutate itself"
|
|
3414
|
+
);
|
|
3415
|
+
}
|
|
3416
|
+
if (part) {
|
|
3417
|
+
const refused = gateRunRefused(process.env);
|
|
3418
|
+
if (refused !== null) {
|
|
3419
|
+
throw new UsageError(refused);
|
|
3420
|
+
}
|
|
3421
|
+
if (strykerArgs.some((arg) => /^(?:--mutate|-m)(?:=|$)/.test(arg))) {
|
|
3422
|
+
throw new UsageError("--gate and --mutate cannot be combined: --gate sets --mutate itself");
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
const manifest = readManifest(dir);
|
|
3426
|
+
const chosen = selectControls({
|
|
3427
|
+
gates: manifest.gates.map((gate) => gate.path),
|
|
3428
|
+
mode: "all",
|
|
3429
|
+
only,
|
|
3430
|
+
shard: null
|
|
3431
|
+
});
|
|
3432
|
+
if ("error" in chosen) {
|
|
3433
|
+
throw new UsageError(chosen.error);
|
|
3434
|
+
}
|
|
3435
|
+
const config = findStrykerConfig(manifest);
|
|
3436
|
+
if (config === null) {
|
|
3437
|
+
throw new ManifestError(
|
|
3438
|
+
"no Stryker config found (stryker.gates.config.mjs). Create one, or set settings.stryker.configFile"
|
|
3439
|
+
);
|
|
3440
|
+
}
|
|
3441
|
+
const stryker = findLocalBin(manifest.dir, "stryker");
|
|
3442
|
+
if (stryker === null) {
|
|
3443
|
+
throw new ManifestError(
|
|
3444
|
+
"stryker is not installed in this project (node_modules/.bin/stryker). Add @stryker-mutator/core as a devDependency"
|
|
3445
|
+
);
|
|
3446
|
+
}
|
|
3447
|
+
const reportFile = reportPathOf(manifest, null);
|
|
3448
|
+
fs8.rmSync(reportFile, { force: true });
|
|
3449
|
+
const guard = guardIncremental(manifest, config, strykerArgs, part, affected);
|
|
3450
|
+
if (guard.said.length > 0) {
|
|
3451
|
+
guard.said.forEach((line) => io2.out(line));
|
|
3452
|
+
} else if (guard.force !== null) {
|
|
3453
|
+
io2.out(
|
|
3454
|
+
`test-gates mutation: running every mutant again (--force), the results of the last run are not reused: ${guard.force}`
|
|
3455
|
+
);
|
|
3456
|
+
}
|
|
3457
|
+
const guardArgs = guard.force === null && guard.mutate === null ? [] : ["--force"];
|
|
3458
|
+
const partArgs = part ? ["--mutate", chosen.selected.join(",")] : guard.mutate !== null ? ["--mutate", guard.mutate.join(",")] : [];
|
|
3459
|
+
const result = spawnSync2(stryker, ["run", config, ...strykerArgs, ...partArgs, ...guardArgs], {
|
|
3460
|
+
cwd: manifest.dir,
|
|
3461
|
+
stdio: "inherit",
|
|
3462
|
+
shell: process.platform === "win32"
|
|
3463
|
+
});
|
|
3464
|
+
if (result.error) {
|
|
3465
|
+
io2.err(`test-gates mutation: cannot start Stryker (${result.error.message})`);
|
|
3466
|
+
return 1;
|
|
3467
|
+
}
|
|
3468
|
+
if (result.status !== 0) {
|
|
3469
|
+
io2.err(
|
|
3470
|
+
`test-gates mutation: Stryker failed (${result.signal ? `signal ${result.signal}` : `exit ${result.status}`}). The result was not judged`
|
|
3471
|
+
);
|
|
3472
|
+
return 1;
|
|
3473
|
+
}
|
|
3474
|
+
let judged = null;
|
|
3475
|
+
const status = runMutationResult(dir, null, io2, part ? chosen.selected : [], (seen) => {
|
|
3476
|
+
judged = seen;
|
|
3477
|
+
});
|
|
3478
|
+
guard.record(judged);
|
|
3479
|
+
if (part) {
|
|
3480
|
+
fs8.rmSync(reportFile, { force: true });
|
|
3481
|
+
}
|
|
3482
|
+
return status;
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
// src/commands/doctor.ts
|
|
3486
|
+
function read(file) {
|
|
3487
|
+
try {
|
|
3488
|
+
return fs9.readFileSync(file, "utf8");
|
|
3489
|
+
} catch {
|
|
3490
|
+
return null;
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
function git2(dir, args) {
|
|
3494
|
+
const result = spawnSync3("git", args, {
|
|
3495
|
+
cwd: dir,
|
|
3496
|
+
env: gitEnvironment(process.env),
|
|
3497
|
+
encoding: "utf8"
|
|
3498
|
+
});
|
|
3499
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
3500
|
+
}
|
|
3501
|
+
function hooksOf(dir) {
|
|
3502
|
+
const root = git2(dir, ["rev-parse", "--show-toplevel"]) ?? dir;
|
|
3503
|
+
const hooksDir = git2(dir, ["rev-parse", "--path-format=absolute", "--git-path", "hooks"]);
|
|
3504
|
+
const candidates = [
|
|
3505
|
+
...["pre-push", "pre-commit"].map((name) => path9.join(root, ".husky", name)),
|
|
3506
|
+
...hooksDir === null ? [] : ["pre-push", "pre-commit"].map((name) => path9.join(hooksDir, name)),
|
|
3507
|
+
...["lefthook.yml", ".lefthook.yml"].map((name) => path9.join(root, name))
|
|
3508
|
+
];
|
|
3509
|
+
let script = "";
|
|
3510
|
+
try {
|
|
3511
|
+
const manifest = JSON.parse(read(path9.join(dir, "package.json")) ?? "{}");
|
|
3512
|
+
script = manifest.scripts?.["test:gates:mutation"] ?? "";
|
|
3513
|
+
} catch {
|
|
3514
|
+
}
|
|
3515
|
+
const subproject = path9.relative(fs9.realpathSync(root), dir).split(path9.sep).join("/");
|
|
3516
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3517
|
+
const uses = [];
|
|
3518
|
+
for (const file of candidates) {
|
|
3519
|
+
const text = read(file);
|
|
3520
|
+
if (text === null) {
|
|
3521
|
+
continue;
|
|
3522
|
+
}
|
|
3523
|
+
const real = fs9.realpathSync(file);
|
|
3524
|
+
if (seen.has(real)) {
|
|
3525
|
+
continue;
|
|
3526
|
+
}
|
|
3527
|
+
seen.add(real);
|
|
3528
|
+
const shown = path9.relative(root, file).split(path9.sep).join("/");
|
|
3529
|
+
uses.push(...hookUses({ file: shown, text, subproject, script }));
|
|
3530
|
+
}
|
|
3531
|
+
return uses;
|
|
3532
|
+
}
|
|
3533
|
+
function runDoctor(dir, io2) {
|
|
3534
|
+
const manifest = readManifest(dir);
|
|
3535
|
+
const config = findStrykerConfig(manifest);
|
|
3536
|
+
if (config === null) {
|
|
3537
|
+
throw new ManifestError(
|
|
3538
|
+
"no Stryker config found (stryker.gates.config.mjs). Create one, or set settings.stryker.configFile"
|
|
3539
|
+
);
|
|
3540
|
+
}
|
|
3541
|
+
const plain2 = decideReuse(manifest, config, [], false, false);
|
|
3542
|
+
const affected = decideReuse(manifest, config, [], false, true);
|
|
3543
|
+
io2.out(`test-gates doctor: ${manifest.dir} (${config})`);
|
|
3544
|
+
if (plain2.snapshot !== null) {
|
|
3545
|
+
const subproject = path9.relative(plain2.root, fs9.realpathSync(manifest.dir));
|
|
3546
|
+
const prefix = subproject === "" ? "" : `${subproject.split(path9.sep).join("/")}/`;
|
|
3547
|
+
const keys = Object.keys(plain2.snapshot.files);
|
|
3548
|
+
const outside = keys.filter((key) => !key.startsWith(prefix)).length;
|
|
3549
|
+
const followed = plain2.scope === null ? "" : "files" in plain2.scope ? `, paths followed in the ${plain2.scope.files} files the run loads` : ", paths followed in every file";
|
|
3550
|
+
io2.out(` compared: ${keys.length} files, ${outside} outside the subproject${followed}`);
|
|
3551
|
+
}
|
|
3552
|
+
if (plain2.incremental) {
|
|
3553
|
+
const reuse = plain2.forced ? "the Stryker config sets force" : plain2.force ?? (plain2.incrementalHash === null ? "there is no incremental file of an earlier run" : null);
|
|
3554
|
+
io2.out(
|
|
3555
|
+
reuse === null ? " next run: reuses the results of the last run" : ` next run: every mutant, ${reuse}`
|
|
3556
|
+
);
|
|
3557
|
+
if (affected.ci !== null) {
|
|
3558
|
+
io2.out(` next run with --affected: every mutant, ${affected.ci}`);
|
|
3559
|
+
} else if (affected.plan !== null && "rerun" in affected.plan) {
|
|
3560
|
+
const [first = "", ...rest] = describePlan(affected.plan);
|
|
3561
|
+
io2.out(
|
|
3562
|
+
` next run with --affected: ${first.replace(/^test-gates mutation --affected: /, "")}`
|
|
3563
|
+
);
|
|
3564
|
+
rest.forEach((line) => io2.out(` ${line}`));
|
|
3565
|
+
} else {
|
|
3566
|
+
const why = affected.forced ? "the Stryker config sets force" : affected.force;
|
|
3567
|
+
io2.out(` next run with --affected: every mutant, ${why ?? "there is nothing to reuse"}`);
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
const { lines, status } = formatFindings(
|
|
3571
|
+
diagnose({
|
|
3572
|
+
incremental: plain2.incremental,
|
|
3573
|
+
forced: plain2.forced,
|
|
3574
|
+
failure: plain2.failure,
|
|
3575
|
+
widened: plain2.widened,
|
|
3576
|
+
scope: plain2.scope,
|
|
3577
|
+
hooks: hooksOf(fs9.realpathSync(manifest.dir))
|
|
3578
|
+
})
|
|
3579
|
+
);
|
|
3580
|
+
lines.forEach((line) => io2.out(line));
|
|
3581
|
+
return status;
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
// src/commands/lcov.ts
|
|
3585
|
+
import fs10 from "fs";
|
|
3586
|
+
import path10 from "path";
|
|
3587
|
+
|
|
3588
|
+
// src/core/lcov.ts
|
|
3589
|
+
function normalizePath2(file, rootDir) {
|
|
3590
|
+
let normalized = file.replace(/\\/g, "/");
|
|
3591
|
+
if (rootDir !== void 0) {
|
|
3592
|
+
const root = rootDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
3593
|
+
if (normalized.startsWith(`${root}/`)) {
|
|
3594
|
+
normalized = normalized.slice(root.length + 1);
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
return normalized.replace(/^\.\//, "");
|
|
3598
|
+
}
|
|
3599
|
+
function parseLcov(text, rootDir) {
|
|
3600
|
+
const files = /* @__PURE__ */ new Map();
|
|
3601
|
+
let record = null;
|
|
3602
|
+
const closeRecord = () => {
|
|
3603
|
+
if (record && !record.hasDa) {
|
|
3604
|
+
record.file.found += record.found;
|
|
3605
|
+
record.file.hit += record.hit;
|
|
3606
|
+
}
|
|
3607
|
+
record = null;
|
|
3608
|
+
};
|
|
3609
|
+
for (const rawLine of text.split("\n")) {
|
|
3610
|
+
const line = rawLine.trim();
|
|
3611
|
+
if (line.startsWith("SF:")) {
|
|
3612
|
+
closeRecord();
|
|
3613
|
+
const path13 = normalizePath2(line.slice(3), rootDir);
|
|
3614
|
+
const file = files.get(path13) ?? { lines: /* @__PURE__ */ new Map(), found: 0, hit: 0 };
|
|
3615
|
+
files.set(path13, file);
|
|
3616
|
+
record = { file, hasDa: false, found: 0, hit: 0 };
|
|
3617
|
+
} else if (line === "end_of_record") {
|
|
3618
|
+
closeRecord();
|
|
3619
|
+
} else if (record) {
|
|
3620
|
+
const open = record;
|
|
3621
|
+
if (line.startsWith("DA:")) {
|
|
3622
|
+
const [lineNumber, hits] = line.slice(3).split(",");
|
|
3623
|
+
const number = Number(lineNumber);
|
|
3624
|
+
const count = Number(hits);
|
|
3625
|
+
if (Number.isInteger(number) && Number.isFinite(count)) {
|
|
3626
|
+
open.hasDa = true;
|
|
3627
|
+
open.file.lines.set(number, Math.max(open.file.lines.get(number) ?? 0, count));
|
|
3628
|
+
}
|
|
3629
|
+
} else if (line.startsWith("LF:")) {
|
|
3630
|
+
open.found = Number(line.slice(3)) || 0;
|
|
3631
|
+
} else if (line.startsWith("LH:")) {
|
|
3632
|
+
open.hit = Number(line.slice(3)) || 0;
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
closeRecord();
|
|
3637
|
+
const result = /* @__PURE__ */ new Map();
|
|
3638
|
+
for (const [file, accumulator] of files) {
|
|
3639
|
+
const uncovered = [...accumulator.lines.entries()].filter(([, hits]) => hits <= 0).map(([lineNumber]) => lineNumber).sort((a, b) => a - b);
|
|
3640
|
+
result.set(file, {
|
|
3641
|
+
found: accumulator.lines.size + accumulator.found,
|
|
3642
|
+
hit: accumulator.lines.size - uncovered.length + accumulator.hit,
|
|
3643
|
+
uncovered
|
|
3644
|
+
});
|
|
3645
|
+
}
|
|
3646
|
+
return result;
|
|
3647
|
+
}
|
|
3648
|
+
function judgeLcov(gatePaths, coverage) {
|
|
3649
|
+
const violations = [];
|
|
3650
|
+
const results = [];
|
|
3651
|
+
for (const gatePath of gatePaths) {
|
|
3652
|
+
const file = coverage.get(normalizePath2(gatePath));
|
|
3653
|
+
if (!file) {
|
|
3654
|
+
violations.push({ file: gatePath, message: "no record in the lcov file" });
|
|
3655
|
+
results.push({ path: gatePath, found: 0, hit: 0, ok: false });
|
|
3656
|
+
continue;
|
|
3657
|
+
}
|
|
3658
|
+
if (file.found === 0) {
|
|
3659
|
+
violations.push({ file: gatePath, message: "no instrumented lines in the lcov record" });
|
|
3660
|
+
results.push({ path: gatePath, found: 0, hit: 0, ok: false });
|
|
3661
|
+
continue;
|
|
3662
|
+
}
|
|
3663
|
+
const ok = file.hit === file.found;
|
|
3664
|
+
if (!ok) {
|
|
3665
|
+
const shown = file.uncovered.slice(0, 20).join(", ");
|
|
3666
|
+
const more = file.uncovered.length > 20 ? ", \u2026" : "";
|
|
3667
|
+
violations.push({
|
|
3668
|
+
file: gatePath,
|
|
3669
|
+
...file.uncovered[0] === void 0 ? {} : { line: file.uncovered[0] },
|
|
3670
|
+
message: `${file.hit}/${file.found} lines covered` + (shown === "" ? "" : ` (uncovered: ${shown}${more})`)
|
|
3671
|
+
});
|
|
3672
|
+
}
|
|
3673
|
+
results.push({ path: gatePath, found: file.found, hit: file.hit, ok });
|
|
3674
|
+
}
|
|
3675
|
+
return { violations, results };
|
|
3676
|
+
}
|
|
3677
|
+
function summarizeLcov(coverage, exclude) {
|
|
3678
|
+
let found = 0;
|
|
3679
|
+
let hit = 0;
|
|
3680
|
+
for (const [file, lines] of coverage) {
|
|
3681
|
+
if (exclude.some((regex) => regex.test(file))) {
|
|
3682
|
+
continue;
|
|
3683
|
+
}
|
|
3684
|
+
found += lines.found;
|
|
3685
|
+
hit += lines.hit;
|
|
3686
|
+
}
|
|
3687
|
+
return { found, hit };
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
// src/commands/lcov.ts
|
|
3691
|
+
function runLcov(dir, fileOption, io2) {
|
|
3692
|
+
const manifest = readManifest(dir);
|
|
3693
|
+
const file = fileOption ?? manifest.settings.lcovFile;
|
|
3694
|
+
if (file === null) {
|
|
3695
|
+
throw new ManifestError(
|
|
3696
|
+
"no lcov file given. Pass --file <lcov.info> or set settings.lcov.file"
|
|
3697
|
+
);
|
|
3698
|
+
}
|
|
3699
|
+
const lcovPath = path10.resolve(manifest.dir, file);
|
|
3700
|
+
let text;
|
|
3701
|
+
try {
|
|
3702
|
+
text = fs10.readFileSync(lcovPath, "utf8");
|
|
3703
|
+
} catch (error) {
|
|
3704
|
+
io2.err(
|
|
3705
|
+
`test-gates lcov: cannot read ${file} (${error.message}). Run the tests with coverage first`
|
|
3706
|
+
);
|
|
3707
|
+
return 1;
|
|
3708
|
+
}
|
|
3709
|
+
const violations = [...manifest.violations];
|
|
3710
|
+
const listing = /* @__PURE__ */ new Map();
|
|
3711
|
+
const existing = manifest.gates.filter((gate) => {
|
|
3712
|
+
const exists = existsExact(manifest.dir, gate.path, "file", listing);
|
|
3713
|
+
if (!exists) {
|
|
3714
|
+
violations.push({
|
|
3715
|
+
file: gate.path,
|
|
3716
|
+
message: "file not found (the check is case-sensitive). Update test-gates.json if it moved"
|
|
3717
|
+
});
|
|
3718
|
+
}
|
|
3719
|
+
return exists;
|
|
3720
|
+
});
|
|
3721
|
+
const coverage = parseLcov(text, manifest.dir);
|
|
3722
|
+
const total = summarizeLcov(coverage, manifest.settings.lcovSummaryExclude);
|
|
3723
|
+
io2.out(
|
|
3724
|
+
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)`
|
|
3725
|
+
);
|
|
3726
|
+
const verdict = judgeLcov(
|
|
3727
|
+
existing.map((gate) => gate.path),
|
|
3728
|
+
coverage
|
|
3729
|
+
);
|
|
3730
|
+
for (const result of verdict.results) {
|
|
3731
|
+
if (result.ok) {
|
|
3732
|
+
io2.out(` \u2713 ${result.path}: ${result.hit}/${result.found} lines`);
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
violations.push(...verdict.violations);
|
|
3736
|
+
if (violations.length > 0) {
|
|
3737
|
+
printViolations(io2, "lcov", violations);
|
|
3738
|
+
return 1;
|
|
3739
|
+
}
|
|
3740
|
+
io2.out(`test-gates lcov: OK (${verdict.results.length} gate(s) at 100% line coverage)`);
|
|
3741
|
+
return 0;
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
// src/commands/selfcheck.ts
|
|
3745
|
+
import { spawn, spawnSync as spawnSync4 } from "child_process";
|
|
3746
|
+
import fs12 from "fs";
|
|
3747
|
+
import os2 from "os";
|
|
3748
|
+
import path12 from "path";
|
|
3749
|
+
|
|
3750
|
+
// src/core/jobs.ts
|
|
3751
|
+
var SWITCHES = {
|
|
3752
|
+
jest: ["--runInBand", "-i", "--ci"],
|
|
3753
|
+
vitest: ["--no-file-parallelism", "--fileParallelism=false", "--pool=forks", "--pool=threads"]
|
|
3754
|
+
};
|
|
3755
|
+
var COUNTS = {
|
|
3756
|
+
jest: ["--maxWorkers", "-w"],
|
|
3757
|
+
vitest: ["--maxWorkers", "--minWorkers"]
|
|
3758
|
+
};
|
|
3759
|
+
var COUNT = /^\d+%?$/;
|
|
3760
|
+
function knownCommand(program, args) {
|
|
3761
|
+
const runner = ["jest", "vitest"].find((name) => name === program);
|
|
3762
|
+
if (runner === void 0) {
|
|
3763
|
+
return { stray: program };
|
|
3764
|
+
}
|
|
3765
|
+
if (runner === "vitest" && args[0] !== "run") {
|
|
3766
|
+
return { stray: args[0] ?? 'vitest without "run"' };
|
|
1536
3767
|
}
|
|
1537
|
-
const
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
}
|
|
3768
|
+
const rest = runner === "vitest" ? args.slice(1) : [...args];
|
|
3769
|
+
let configs = 0;
|
|
3770
|
+
for (let arg = rest.shift(); arg !== void 0; arg = rest.shift()) {
|
|
3771
|
+
if (SWITCHES[runner].includes(arg)) {
|
|
3772
|
+
continue;
|
|
3773
|
+
}
|
|
3774
|
+
const equals = arg.indexOf("=");
|
|
3775
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
3776
|
+
const value = equals === -1 ? rest.shift() : arg.slice(equals + 1);
|
|
3777
|
+
if (value !== void 0 && (name === "--config" || name === "-c")) {
|
|
3778
|
+
configs += 1;
|
|
3779
|
+
} else if (!COUNTS[runner].includes(name) || !COUNT.test(String(value))) {
|
|
3780
|
+
return { stray: arg };
|
|
3781
|
+
}
|
|
1543
3782
|
}
|
|
1544
|
-
return {
|
|
1545
|
-
}
|
|
1546
|
-
function formatSurvivor(survivor) {
|
|
1547
|
-
const occurrence = survivor.sameKeyCount > 1 ? ` / occurrence ${survivor.occurrence} of ${survivor.sameKeyCount}` : "";
|
|
1548
|
-
return `${survivor.file}:${survivor.line}:${survivor.column} / ${survivor.mutator} / ${survivor.original} \u2192 ${survivor.replacement}${occurrence} (${survivor.status})`;
|
|
3783
|
+
return configs === 1 ? { runner } : { stray: configs === 0 ? "no --config" : "two --config" };
|
|
1549
3784
|
}
|
|
1550
|
-
function
|
|
1551
|
-
return
|
|
3785
|
+
function jobFlags(runner, dir) {
|
|
3786
|
+
return runner === "jest" ? [`--coverageDirectory=${dir}/coverage`, `--cacheDirectory=${dir}/cache`] : [`--coverage.reportsDirectory=${dir}/coverage`, "--no-cache"];
|
|
1552
3787
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
3788
|
+
var REACHES = [
|
|
3789
|
+
"process.",
|
|
3790
|
+
"process?.",
|
|
3791
|
+
"process[",
|
|
3792
|
+
"globalThis",
|
|
3793
|
+
"global.",
|
|
3794
|
+
"global[",
|
|
3795
|
+
"import.meta.env",
|
|
3796
|
+
"Date.now",
|
|
3797
|
+
"Date()",
|
|
3798
|
+
"performance.now",
|
|
3799
|
+
"setTimeout",
|
|
3800
|
+
"setInterval",
|
|
3801
|
+
"setImmediate",
|
|
3802
|
+
"waitFor",
|
|
3803
|
+
"waitUntil",
|
|
3804
|
+
"expect.poll",
|
|
3805
|
+
"retryTimes",
|
|
3806
|
+
"retry:",
|
|
3807
|
+
"fetch(",
|
|
3808
|
+
"WebSocket",
|
|
3809
|
+
"XMLHttpRequest",
|
|
3810
|
+
"navigator",
|
|
3811
|
+
"Atomics",
|
|
3812
|
+
"WeakRef",
|
|
3813
|
+
"FinalizationRegistry",
|
|
3814
|
+
"eval(",
|
|
3815
|
+
"Function("
|
|
3816
|
+
];
|
|
3817
|
+
var COMPUTED_LOAD = /(?:require|import)\((?!['"])|\brequire(?!\(|\.resolve\b|[\w$])|\bimport\.meta\.glob\b/;
|
|
3818
|
+
var COMPUTED = "loads a module whose name is computed";
|
|
3819
|
+
var RUNNER_PACKAGES = ["vitest", "@jest/globals"];
|
|
3820
|
+
function reachesOutside(file, trusted) {
|
|
3821
|
+
const dense = file.source.replace(/\s/g, "");
|
|
3822
|
+
const mark = REACHES.find((text) => dense.includes(text));
|
|
3823
|
+
if (mark !== void 0) {
|
|
3824
|
+
return `uses ${mark.replace(/[.?[(:]+$/, "")}`;
|
|
3825
|
+
}
|
|
3826
|
+
if (COMPUTED_LOAD.test(dense)) {
|
|
3827
|
+
return COMPUTED;
|
|
3828
|
+
}
|
|
3829
|
+
for (const { specifier, builtin } of file.imports) {
|
|
3830
|
+
if (builtin) {
|
|
3831
|
+
return `imports ${specifier}`;
|
|
3832
|
+
} else if (![...RUNNER_PACKAGES, ...trusted].some(
|
|
3833
|
+
(name) => specifier === name || specifier.startsWith(`${name}/`)
|
|
3834
|
+
)) {
|
|
3835
|
+
return `imports the package ${specifier}, which is not looked into`;
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
return null;
|
|
1557
3839
|
}
|
|
1558
|
-
function
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
const shown = path5.relative(manifest.dir, reportPath) || reportPath;
|
|
1562
|
-
let report;
|
|
1563
|
-
try {
|
|
1564
|
-
report = JSON.parse(fs5.readFileSync(reportPath, "utf8"));
|
|
1565
|
-
} catch (error) {
|
|
1566
|
-
io2.err(
|
|
1567
|
-
`test-gates mutation: cannot read the Stryker JSON report ${shown} (${error.message}). Did Stryker finish, with the "json" reporter writing to this path?`
|
|
1568
|
-
);
|
|
1569
|
-
return 1;
|
|
3840
|
+
function jobsRefused(input) {
|
|
3841
|
+
if (input.failurePattern) {
|
|
3842
|
+
return "with settings.selfcheck.failurePattern, a run that was disturbed cannot be told from one that was not";
|
|
1570
3843
|
}
|
|
1571
|
-
if (
|
|
1572
|
-
|
|
1573
|
-
return 1;
|
|
3844
|
+
if ("stray" in input.command) {
|
|
3845
|
+
return `the gate command is not known to keep runs at the same time apart: only jest or "vitest run" with --config and options for the number of workers is (found: ${input.command.stray})`;
|
|
1574
3846
|
}
|
|
1575
|
-
const
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
3847
|
+
const helper = input.command.runner === "jest" ? "createJestGatesConfig" : "createVitestGatesConfig";
|
|
3848
|
+
if (input.configSource === null || !input.configSource.includes(helper)) {
|
|
3849
|
+
return `the gate config does not use ${helper}`;
|
|
3850
|
+
}
|
|
3851
|
+
return null;
|
|
3852
|
+
}
|
|
3853
|
+
function jobsPlan(input) {
|
|
3854
|
+
const byPath = new Map(input.files.map((file) => [file.path, file]));
|
|
3855
|
+
const causes = input.roots.flatMap((root) => {
|
|
3856
|
+
const loaded = root.files.flatMap((name) => {
|
|
3857
|
+
const file = byPath.get(name);
|
|
3858
|
+
return file === void 0 ? [] : [file];
|
|
3859
|
+
});
|
|
3860
|
+
let computed = false;
|
|
3861
|
+
const reaches = loaded.flatMap((file) => {
|
|
3862
|
+
const reach = reachesOutside(file, input.trusted);
|
|
3863
|
+
const said = reach === null ? [] : [`${file.path} ${reach}`];
|
|
3864
|
+
if (!COMPUTED_LOAD.test(file.source.replace(/\s/g, ""))) {
|
|
3865
|
+
return said;
|
|
1584
3866
|
}
|
|
3867
|
+
computed = true;
|
|
3868
|
+
return reach === COMPUTED ? said : [...said, `${file.path} ${COMPUTED}`];
|
|
3869
|
+
});
|
|
3870
|
+
const reasons = [
|
|
3871
|
+
...root.lost === null ? [] : [`${root.lost} cannot be followed`],
|
|
3872
|
+
...reaches
|
|
3873
|
+
];
|
|
3874
|
+
if (reasons.length === 0) {
|
|
3875
|
+
return [];
|
|
1585
3876
|
}
|
|
3877
|
+
const any = root.spec === null || root.lost !== null || computed;
|
|
3878
|
+
const gates = input.gates.filter(
|
|
3879
|
+
(gate) => gate.spec !== root.spec && (any || root.files.includes(withoutDots(gate.path)))
|
|
3880
|
+
).map((gate) => gate.path);
|
|
3881
|
+
const by = root.spec === null ? "run with what the gate config names" : `${any ? "maybe loaded" : "loaded"} by ${root.spec}`;
|
|
3882
|
+
return gates.length === 0 ? [] : [{ gates, reason: `${by}, in which ${reasons.join("; ")}` }];
|
|
1586
3883
|
});
|
|
1587
|
-
const
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
3884
|
+
const kept = new Set(causes.flatMap((cause) => cause.gates));
|
|
3885
|
+
const paths = input.gates.map((gate) => gate.path);
|
|
3886
|
+
return {
|
|
3887
|
+
atOnce: paths.filter((gate) => !kept.has(gate)),
|
|
3888
|
+
alone: paths.filter((gate) => kept.has(gate)),
|
|
3889
|
+
causes
|
|
3890
|
+
};
|
|
3891
|
+
}
|
|
3892
|
+
var TROUBLE = [
|
|
3893
|
+
"fail",
|
|
3894
|
+
"fails",
|
|
3895
|
+
"failed",
|
|
3896
|
+
"failing",
|
|
3897
|
+
"failure",
|
|
3898
|
+
"failures",
|
|
3899
|
+
"err",
|
|
3900
|
+
"timeout",
|
|
3901
|
+
"timeouts",
|
|
3902
|
+
"timed",
|
|
3903
|
+
"unhandled",
|
|
3904
|
+
"uncaught",
|
|
3905
|
+
"killed",
|
|
3906
|
+
"terminated",
|
|
3907
|
+
"crashed",
|
|
3908
|
+
"aborted",
|
|
3909
|
+
"skipped",
|
|
3910
|
+
"todo",
|
|
3911
|
+
"retry",
|
|
3912
|
+
"retried",
|
|
3913
|
+
"obsolete",
|
|
3914
|
+
"sigkill",
|
|
3915
|
+
"sigterm",
|
|
3916
|
+
"sigabrt",
|
|
3917
|
+
"sigsegv",
|
|
3918
|
+
"sigint"
|
|
3919
|
+
];
|
|
3920
|
+
var TROUBLE_SIGNS = ["\u2717", "\xD7", "\u2715", "\u2716"];
|
|
3921
|
+
function troubled(line) {
|
|
3922
|
+
const words = line.toLowerCase().split(/[^a-z]/);
|
|
3923
|
+
return (
|
|
3924
|
+
// (`TypeError`, `errors`)
|
|
3925
|
+
words.some((word) => TROUBLE.includes(word) || /errors?$/.test(word)) || TROUBLE_SIGNS.some((sign) => line.includes(sign))
|
|
3926
|
+
);
|
|
3927
|
+
}
|
|
3928
|
+
function plain(line) {
|
|
3929
|
+
return line.split("\x1B[").map((part, index) => index === 0 ? part : part.slice(part.indexOf("m") + 1)).join("").trim();
|
|
3930
|
+
}
|
|
3931
|
+
var SUMMARY = {
|
|
3932
|
+
jest: {
|
|
3933
|
+
files: "Test Suites:",
|
|
3934
|
+
tests: "Tests:",
|
|
3935
|
+
all: (count) => `${count} passed, ${count} total`
|
|
3936
|
+
},
|
|
3937
|
+
vitest: { files: "Test Files", tests: "Tests", all: (count) => `${count} passed (${count})` }
|
|
3938
|
+
};
|
|
3939
|
+
function cleanHold(input) {
|
|
3940
|
+
const { output, gatePath, others } = input;
|
|
3941
|
+
if (!input.held || input.signal !== null) {
|
|
3942
|
+
return false;
|
|
1591
3943
|
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
io2.err(
|
|
1601
|
-
"Fix the test so that it detects the mutant. Only when the mutant cannot be observed from"
|
|
1602
|
-
);
|
|
1603
|
-
io2.err("outside (same return value, exception and side effects for every input), add it to");
|
|
1604
|
-
io2.err('"equivalentMutants" of the gate in test-gates.json and fill in "reason":');
|
|
1605
|
-
const byFile = /* @__PURE__ */ new Map();
|
|
1606
|
-
for (const survivor of verdict.survivors) {
|
|
1607
|
-
byFile.set(survivor.file, [...byFile.get(survivor.file) ?? [], survivor]);
|
|
1608
|
-
}
|
|
1609
|
-
for (const [file, survivors] of byFile) {
|
|
1610
|
-
io2.err("");
|
|
1611
|
-
io2.err(` ${file}`);
|
|
1612
|
-
survivors.forEach((survivor, index) => {
|
|
1613
|
-
const comma = index === survivors.length - 1 ? "" : ",";
|
|
1614
|
-
io2.err(` ${JSON.stringify(survivor.allowance)}${comma}`);
|
|
1615
|
-
});
|
|
1616
|
-
}
|
|
1617
|
-
io2.err("");
|
|
3944
|
+
const lines = output.split("\n").map(plain);
|
|
3945
|
+
const summary = SUMMARY[input.runner];
|
|
3946
|
+
const after = (label) => lines.filter((line) => line.startsWith(label)).map((line) => line.slice(label.length).trim());
|
|
3947
|
+
const files = after(summary.files);
|
|
3948
|
+
const tests = after(summary.tests);
|
|
3949
|
+
const count = (tests[0] ?? "").split(" ")[0];
|
|
3950
|
+
if (files.length !== 1 || files[0] !== summary.all(String(input.specs)) || tests.length !== 1 || !/^\d+$/.test(count) || tests[0] !== summary.all(count)) {
|
|
3951
|
+
return false;
|
|
1618
3952
|
}
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
);
|
|
1624
|
-
for (const mutant of verdict.unexpectedTimeouts) {
|
|
1625
|
-
io2.err(` - ${formatSurvivor(mutant)}`);
|
|
1626
|
-
}
|
|
1627
|
-
io2.err("");
|
|
1628
|
-
io2.err("A timeout counts as detected, but on a loaded machine a mutant that would survive");
|
|
1629
|
-
io2.err("can time out instead. Run again with less load (-- --concurrency 1). Only for a");
|
|
1630
|
-
io2.err("mutant that can never finish (a loop that no longer ends), add it to");
|
|
1631
|
-
io2.err('"expectedTimeouts" of the gate in test-gates.json and fill in "reason"');
|
|
1632
|
-
io2.err("(settings.mutation.maxTimeouts raises the limit for the whole project):");
|
|
1633
|
-
for (const mutant of verdict.unexpectedTimeouts) {
|
|
1634
|
-
io2.err(` ${mutant.file}: ${JSON.stringify(mutant.allowance)}`);
|
|
3953
|
+
return lines.every((line) => {
|
|
3954
|
+
const lower = line.toLowerCase();
|
|
3955
|
+
if (lower.includes("threshold") || lower.includes("coverage data for")) {
|
|
3956
|
+
return line.includes(gatePath) && !others.some((other) => line.includes(other));
|
|
1635
3957
|
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
printViolations(io2, "mutation", verdict.violations);
|
|
1640
|
-
}
|
|
1641
|
-
io2.err(`test-gates mutation: FAILED (${summary})`);
|
|
1642
|
-
return 1;
|
|
3958
|
+
return line.split("|").length >= 5 || // What stands around the names of the project, piece by piece.
|
|
3959
|
+
!input.names.reduce((pieces, name) => pieces.flatMap((piece) => piece.split(name)), [line]).some(troubled);
|
|
3960
|
+
});
|
|
1643
3961
|
}
|
|
1644
3962
|
|
|
1645
|
-
// src/
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
)
|
|
3963
|
+
// src/touch.ts
|
|
3964
|
+
import { createHash as createHash2 } from "crypto";
|
|
3965
|
+
import fs11 from "fs";
|
|
3966
|
+
import path11 from "path";
|
|
3967
|
+
import { fileURLToPath } from "url";
|
|
3968
|
+
var TOUCH_ENV = "TEST_GATES_TOUCH_DIR";
|
|
3969
|
+
function isObject(value) {
|
|
3970
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3971
|
+
}
|
|
3972
|
+
function countersOf(coverage) {
|
|
3973
|
+
return {
|
|
3974
|
+
s: Object.values(coverage.s ?? {}),
|
|
3975
|
+
f: Object.values(coverage.f ?? {}),
|
|
3976
|
+
b: Object.values(coverage.b ?? {}).flat()
|
|
3977
|
+
};
|
|
3978
|
+
}
|
|
3979
|
+
function countersOfAll(coverage) {
|
|
3980
|
+
if (!isObject(coverage)) {
|
|
3981
|
+
return null;
|
|
1659
3982
|
}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
if (result.error) {
|
|
1667
|
-
io2.err(`test-gates mutation: cannot start Stryker (${result.error.message})`);
|
|
1668
|
-
return 1;
|
|
3983
|
+
const files = {};
|
|
3984
|
+
for (const [file, counters] of Object.entries(coverage)) {
|
|
3985
|
+
if (!isObject(counters)) {
|
|
3986
|
+
return null;
|
|
3987
|
+
}
|
|
3988
|
+
files[file] = countersOf(counters);
|
|
1669
3989
|
}
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
3990
|
+
return files;
|
|
3991
|
+
}
|
|
3992
|
+
function isCounters(value) {
|
|
3993
|
+
return isObject(value) && ["s", "f", "b"].every(
|
|
3994
|
+
(key) => Array.isArray(value[key]) && value[key].every((count) => typeof count === "number")
|
|
3995
|
+
);
|
|
3996
|
+
}
|
|
3997
|
+
function readNotes(directory) {
|
|
3998
|
+
const read2 = (file) => {
|
|
3999
|
+
try {
|
|
4000
|
+
return JSON.parse(fs11.readFileSync(path11.join(directory, file), "utf8"));
|
|
4001
|
+
} catch {
|
|
4002
|
+
return null;
|
|
4003
|
+
}
|
|
4004
|
+
};
|
|
4005
|
+
const run = read2("run.json");
|
|
4006
|
+
let notes = [];
|
|
4007
|
+
try {
|
|
4008
|
+
for (const name of fs11.readdirSync(path11.join(directory, "specs"))) {
|
|
4009
|
+
const note = read2(path11.join("specs", name));
|
|
4010
|
+
if (!isObject(note) || typeof note.spec !== "string" || !isObject(note.files) || !Object.values(note.files).every(isCounters)) {
|
|
4011
|
+
throw new Error("not a note");
|
|
4012
|
+
}
|
|
4013
|
+
notes.push(note);
|
|
4014
|
+
}
|
|
4015
|
+
} catch {
|
|
4016
|
+
notes = null;
|
|
1675
4017
|
}
|
|
1676
|
-
return
|
|
4018
|
+
return {
|
|
4019
|
+
run: isObject(run) && typeof run.runner === "string" && typeof run.version === "string" && typeof run.provider === "string" && typeof run.pool === "string" && typeof run.isolate === "boolean" && typeof run.projects === "number" && (run.problem === null || typeof run.problem === "string") ? run : null,
|
|
4020
|
+
notes,
|
|
4021
|
+
totals: countersOfAll(read2(path11.join("coverage", "coverage-final.json")))
|
|
4022
|
+
};
|
|
1677
4023
|
}
|
|
1678
4024
|
|
|
1679
4025
|
// src/commands/selfcheck.ts
|
|
1680
|
-
import { spawnSync as spawnSync2 } from "child_process";
|
|
1681
|
-
import fs7 from "fs";
|
|
1682
|
-
import path6 from "path";
|
|
1683
4026
|
var JEST_CONFIGS = ["js", "cjs", "mjs", "ts", "cts", "mts"].map(
|
|
1684
4027
|
(extension) => `jest.gates.config.${extension}`
|
|
1685
4028
|
);
|
|
@@ -1690,7 +4033,7 @@ function resolveGateCommand(manifest) {
|
|
|
1690
4033
|
if (manifest.settings.gateCommand !== null) {
|
|
1691
4034
|
return manifest.settings.gateCommand;
|
|
1692
4035
|
}
|
|
1693
|
-
const exists = (name) =>
|
|
4036
|
+
const exists = (name) => fs12.existsSync(path12.join(manifest.dir, name));
|
|
1694
4037
|
const jest = JEST_CONFIGS.find(exists);
|
|
1695
4038
|
const vitest = VITEST_CONFIGS.find(exists);
|
|
1696
4039
|
if (jest && vitest) {
|
|
@@ -1708,16 +4051,271 @@ function resolveGateCommand(manifest) {
|
|
|
1708
4051
|
"no gate config found (jest.gates.config.* / vitest.gates.config.*). Set settings.gateCommand"
|
|
1709
4052
|
);
|
|
1710
4053
|
}
|
|
1711
|
-
|
|
4054
|
+
var TOUCH_PREFIX = ".test-gates-touch-";
|
|
4055
|
+
function realPath(file) {
|
|
4056
|
+
try {
|
|
4057
|
+
return fs12.realpathSync(file);
|
|
4058
|
+
} catch {
|
|
4059
|
+
return file;
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
function shareOneRun(manifest, targets, runGate) {
|
|
4063
|
+
let touchDir;
|
|
4064
|
+
try {
|
|
4065
|
+
for (const name of fs12.readdirSync(manifest.dir)) {
|
|
4066
|
+
const left = path12.join(manifest.dir, name);
|
|
4067
|
+
if (name.startsWith(TOUCH_PREFIX) && Date.now() - fs12.statSync(left).mtimeMs > 36e5) {
|
|
4068
|
+
fs12.rmSync(left, { recursive: true, force: true });
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
touchDir = fs12.mkdtempSync(path12.join(manifest.dir, TOUCH_PREFIX));
|
|
4072
|
+
} catch {
|
|
4073
|
+
return { reason: "the subproject cannot be written to" };
|
|
4074
|
+
}
|
|
4075
|
+
let plan;
|
|
4076
|
+
try {
|
|
4077
|
+
const recorded = runGate({ touchDir });
|
|
4078
|
+
if (recorded.status !== 0) {
|
|
4079
|
+
return { reason: "the gate run with every spec does not pass" };
|
|
4080
|
+
}
|
|
4081
|
+
const base = realPath(manifest.dir);
|
|
4082
|
+
const absolute = (file) => realPath(path12.resolve(base, file));
|
|
4083
|
+
const rekeyed = (files) => Object.fromEntries(Object.entries(files).map(([file, value]) => [absolute(file), value]));
|
|
4084
|
+
const left = readNotes(touchDir);
|
|
4085
|
+
plan = planControls({
|
|
4086
|
+
all: manifest.gates.map((gate) => ({
|
|
4087
|
+
path: gate.path,
|
|
4088
|
+
file: path12.join(base, gate.path),
|
|
4089
|
+
specFile: path12.join(base, gate.spec ?? "")
|
|
4090
|
+
})),
|
|
4091
|
+
targets: targets.map((gate) => gate.path),
|
|
4092
|
+
run: left.run,
|
|
4093
|
+
notes: left.notes?.map((note) => ({ spec: absolute(note.spec), files: rekeyed(note.files) })) ?? null,
|
|
4094
|
+
totals: left.totals === null ? null : rekeyed(left.totals),
|
|
4095
|
+
failurePattern: manifest.settings.selfcheckFailurePattern
|
|
4096
|
+
});
|
|
4097
|
+
} finally {
|
|
4098
|
+
fs12.rmSync(touchDir, { recursive: true, force: true });
|
|
4099
|
+
}
|
|
4100
|
+
if (plan.reason !== null) {
|
|
4101
|
+
return { reason: plan.reason };
|
|
4102
|
+
}
|
|
4103
|
+
const together = plan.together;
|
|
4104
|
+
const gates = targets.filter((gate) => together.includes(gate.path));
|
|
4105
|
+
return { gates, result: runGate({ excludeAll: gates.map((gate) => gate.spec) }) };
|
|
4106
|
+
}
|
|
4107
|
+
function installedVersion2(start, name) {
|
|
4108
|
+
let current = path12.resolve(start);
|
|
4109
|
+
for (; ; ) {
|
|
4110
|
+
try {
|
|
4111
|
+
const manifest = JSON.parse(
|
|
4112
|
+
fs12.readFileSync(path12.join(current, "node_modules", name, "package.json"), "utf8")
|
|
4113
|
+
);
|
|
4114
|
+
return typeof manifest.version === "string" ? manifest.version : null;
|
|
4115
|
+
} catch {
|
|
4116
|
+
const parent = path12.dirname(current);
|
|
4117
|
+
if (parent === current) {
|
|
4118
|
+
return null;
|
|
4119
|
+
}
|
|
4120
|
+
current = parent;
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
function gateConfigFile(args) {
|
|
4125
|
+
const joined = args.find((arg) => arg.startsWith("--config="));
|
|
4126
|
+
const position = args.findIndex((arg) => arg === "--config" || arg === "-c");
|
|
4127
|
+
return joined?.slice("--config=".length) ?? (position === -1 ? null : args[position + 1] ?? null);
|
|
4128
|
+
}
|
|
4129
|
+
function gateConfigSource(manifest, args) {
|
|
4130
|
+
const file = gateConfigFile(args);
|
|
4131
|
+
if (file === null) {
|
|
4132
|
+
return null;
|
|
4133
|
+
}
|
|
4134
|
+
try {
|
|
4135
|
+
return readText(manifest.dir, file);
|
|
4136
|
+
} catch {
|
|
4137
|
+
return null;
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
var JOBS_PREFIX = "test-gates-jobs-";
|
|
4141
|
+
async function runAtOnce(manifest, gates, jobs, runner, executable, args) {
|
|
4142
|
+
const results = /* @__PURE__ */ new Map();
|
|
4143
|
+
let root;
|
|
4144
|
+
try {
|
|
4145
|
+
for (const name of fs12.readdirSync(os2.tmpdir())) {
|
|
4146
|
+
const left = path12.join(os2.tmpdir(), name);
|
|
4147
|
+
if (name.startsWith(JOBS_PREFIX) && Date.now() - fs12.statSync(left).mtimeMs > 36e5) {
|
|
4148
|
+
fs12.rmSync(left, { recursive: true, force: true });
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
root = fs12.realpathSync(fs12.mkdtempSync(path12.join(os2.tmpdir(), JOBS_PREFIX)));
|
|
4152
|
+
} catch {
|
|
4153
|
+
return results;
|
|
4154
|
+
}
|
|
4155
|
+
const queue = [...gates];
|
|
4156
|
+
const line = async (dir) => {
|
|
4157
|
+
for (let gate = queue.shift(); gate !== void 0; gate = queue.shift()) {
|
|
4158
|
+
const spec = gate.spec;
|
|
4159
|
+
results.set(
|
|
4160
|
+
gate,
|
|
4161
|
+
await new Promise((resolve) => {
|
|
4162
|
+
const child = spawn(executable, [...args, ...jobFlags(runner, dir)], {
|
|
4163
|
+
cwd: manifest.dir,
|
|
4164
|
+
env: { ...process.env, [EXCLUDE_ENV]: spec, [EXCLUDE_ALL_ENV]: "", [TOUCH_ENV]: "" },
|
|
4165
|
+
shell: process.platform === "win32"
|
|
4166
|
+
});
|
|
4167
|
+
let stdout = "";
|
|
4168
|
+
let stderr = "";
|
|
4169
|
+
child.stdout.on("data", (chunk) => stdout += chunk.toString());
|
|
4170
|
+
child.stderr.on("data", (chunk) => stderr += chunk.toString());
|
|
4171
|
+
child.stdin.on("error", () => void 0);
|
|
4172
|
+
child.stdin.end();
|
|
4173
|
+
child.on("error", () => resolve({ status: null, signal: null, stdout, stderr }));
|
|
4174
|
+
child.on("close", (status, signal) => resolve({ status, signal, stdout, stderr }));
|
|
4175
|
+
})
|
|
4176
|
+
);
|
|
4177
|
+
}
|
|
4178
|
+
};
|
|
4179
|
+
try {
|
|
4180
|
+
await Promise.all(
|
|
4181
|
+
Array.from(
|
|
4182
|
+
{ length: Math.min(jobs, gates.length) },
|
|
4183
|
+
(_unused, index) => line(path12.join(root, `line-${index}`))
|
|
4184
|
+
)
|
|
4185
|
+
);
|
|
4186
|
+
} finally {
|
|
4187
|
+
fs12.rmSync(root, { recursive: true, force: true });
|
|
4188
|
+
}
|
|
4189
|
+
return results;
|
|
4190
|
+
}
|
|
4191
|
+
async function runSelfcheck(dir, selection, commandOverride, io2) {
|
|
4192
|
+
const refused = selection.quick === true ? quickRefused(process.env) : null;
|
|
4193
|
+
if (refused !== null) {
|
|
4194
|
+
throw new UsageError(refused);
|
|
4195
|
+
}
|
|
1712
4196
|
const manifest = readManifest(dir);
|
|
1713
4197
|
if (manifest.violations.length > 0 || manifest.gates.length === 0) {
|
|
1714
4198
|
io2.err('test-gates selfcheck: test-gates.json has problems. Run "test-gates check" first');
|
|
1715
4199
|
return 1;
|
|
1716
4200
|
}
|
|
4201
|
+
const chosen = selectControls({
|
|
4202
|
+
gates: manifest.gates.map((gate) => gate.path),
|
|
4203
|
+
mode: selection.mode ?? manifest.settings.selfcheckMode,
|
|
4204
|
+
only: selection.gates,
|
|
4205
|
+
shard: selection.shard
|
|
4206
|
+
});
|
|
4207
|
+
if ("error" in chosen) {
|
|
4208
|
+
throw new UsageError(chosen.error);
|
|
4209
|
+
}
|
|
4210
|
+
const targets = manifest.gates.filter((gate) => chosen.selected.includes(gate.path));
|
|
4211
|
+
const skipped = manifest.gates.length - targets.length;
|
|
4212
|
+
const notSelected = selection.shard !== null ? `, ${skipped} gate(s) not selected by --shard ${selection.shard.index}/${selection.shard.total}` : selection.gates.length > 0 ? `, ${skipped} gate(s) not selected by --gate` : "";
|
|
1717
4213
|
const command = commandOverride.length > 0 ? commandOverride : resolveGateCommand(manifest);
|
|
1718
4214
|
const [program, ...args] = command;
|
|
1719
4215
|
const executable = findLocalBin(manifest.dir, program) ?? program;
|
|
1720
|
-
const
|
|
4216
|
+
const runGate = (env) => spawnSync4(executable, args, {
|
|
4217
|
+
cwd: manifest.dir,
|
|
4218
|
+
env: {
|
|
4219
|
+
...process.env,
|
|
4220
|
+
[EXCLUDE_ENV]: env.exclude ?? "",
|
|
4221
|
+
[EXCLUDE_ALL_ENV]: env.excludeAll === void 0 ? "" : JSON.stringify(env.excludeAll),
|
|
4222
|
+
[TOUCH_ENV]: env.touchDir ?? ""
|
|
4223
|
+
},
|
|
4224
|
+
encoding: "utf8",
|
|
4225
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
4226
|
+
shell: process.platform === "win32"
|
|
4227
|
+
});
|
|
4228
|
+
const judge = (result, gatePath) => result.error ? `cannot start "${command.join(" ")}" (${result.error.message})` : judgeSelfcheck({
|
|
4229
|
+
status: result.status,
|
|
4230
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
|
4231
|
+
gatePath,
|
|
4232
|
+
failurePattern: manifest.settings.selfcheckFailurePattern
|
|
4233
|
+
});
|
|
4234
|
+
const heldTogether = /* @__PURE__ */ new Map();
|
|
4235
|
+
const single = targets.filter((gate) => gate.spec !== null && gate.existingSpecs.length === 1);
|
|
4236
|
+
if (selection.quick === true && single.length >= 3) {
|
|
4237
|
+
const configSource = gateConfigSource(manifest, args);
|
|
4238
|
+
const shared = sharedRunPrecheck({
|
|
4239
|
+
program,
|
|
4240
|
+
version: installedVersion2(manifest.dir, program),
|
|
4241
|
+
configSource,
|
|
4242
|
+
specs: manifest.gates.flatMap(
|
|
4243
|
+
(gate) => gate.existingSpecs.map((spec) => ({ path: spec, source: readText(manifest.dir, spec) }))
|
|
4244
|
+
)
|
|
4245
|
+
}) ?? shareOneRun(manifest, single, runGate);
|
|
4246
|
+
if (typeof shared === "string" || "reason" in shared) {
|
|
4247
|
+
const reason = typeof shared === "string" ? shared : shared.reason;
|
|
4248
|
+
io2.out(`test-gates selfcheck: a run of the gate command for every gate (${reason})`);
|
|
4249
|
+
} else {
|
|
4250
|
+
for (const gate of shared.gates) {
|
|
4251
|
+
if (judge(shared.result, gate.path) === null) {
|
|
4252
|
+
heldTogether.set(gate.path, shared.result.status);
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
const heldAtOnce = /* @__PURE__ */ new Map();
|
|
4258
|
+
const waiting = single.filter((gate) => !heldTogether.has(gate.path));
|
|
4259
|
+
const jobs = selection.jobs ?? 1;
|
|
4260
|
+
if (jobs > 1 && waiting.length > 1) {
|
|
4261
|
+
const known = knownCommand(program, args);
|
|
4262
|
+
const refusal = jobsRefused({
|
|
4263
|
+
failurePattern: manifest.settings.selfcheckFailurePattern,
|
|
4264
|
+
command: known,
|
|
4265
|
+
configSource: gateConfigSource(manifest, args)
|
|
4266
|
+
});
|
|
4267
|
+
if (refusal !== null || !("runner" in known)) {
|
|
4268
|
+
io2.out(`test-gates selfcheck: one negative control at a time (${refusal})`);
|
|
4269
|
+
} else {
|
|
4270
|
+
const plan = jobsPlan({
|
|
4271
|
+
gates: waiting.map((gate) => ({ path: gate.path, spec: gate.spec })),
|
|
4272
|
+
...loadedFiles(manifest, gateConfigFile(args)),
|
|
4273
|
+
trusted: manifest.settings.importMode === "allowlist" ? manifest.settings.importAllow.flatMap(
|
|
4274
|
+
(rule) => rule.module === void 0 ? [] : [rule.module]
|
|
4275
|
+
) : []
|
|
4276
|
+
});
|
|
4277
|
+
if (plan.alone.length > 0) {
|
|
4278
|
+
io2.out(
|
|
4279
|
+
`test-gates selfcheck: ${plan.alone.length} of ${waiting.length} negative control(s) run one at a time, since in their runs something that loads the gate also loads a file that may meet what another run is doing:`
|
|
4280
|
+
);
|
|
4281
|
+
for (const { gates, reason } of plan.causes) {
|
|
4282
|
+
io2.out(` ${gates.join(", ")}: ${reason}`);
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
const atOnce = waiting.filter((gate) => plan.atOnce.includes(gate.path));
|
|
4286
|
+
if (atOnce.length > 1) {
|
|
4287
|
+
const specs = new Set(manifest.gates.map((gate) => gate.spec)).size - 1;
|
|
4288
|
+
const names = [
|
|
4289
|
+
manifest.dir,
|
|
4290
|
+
realPath(manifest.dir),
|
|
4291
|
+
...manifest.gates.flatMap((gate) => [gate.path, gate.spec])
|
|
4292
|
+
].sort((left, right) => right.length - left.length);
|
|
4293
|
+
const results = await runAtOnce(manifest, atOnce, jobs, known.runner, executable, args);
|
|
4294
|
+
for (const [gate, result] of results) {
|
|
4295
|
+
const output = `${result.stdout}${result.stderr}`;
|
|
4296
|
+
const clean = cleanHold({
|
|
4297
|
+
held: judgeSelfcheck({ status: result.status, output, gatePath: gate.path }) === null,
|
|
4298
|
+
signal: result.signal,
|
|
4299
|
+
output,
|
|
4300
|
+
runner: known.runner,
|
|
4301
|
+
specs,
|
|
4302
|
+
gatePath: gate.path,
|
|
4303
|
+
others: manifest.gates.map((other) => other.path).filter((other) => other !== gate.path),
|
|
4304
|
+
names
|
|
4305
|
+
});
|
|
4306
|
+
if (clean) {
|
|
4307
|
+
heldAtOnce.set(gate.path, result.status);
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
const again = atOnce.length - heldAtOnce.size;
|
|
4311
|
+
if (again > 0) {
|
|
4312
|
+
io2.out(
|
|
4313
|
+
`test-gates selfcheck: ${again} of ${atOnce.length} negative control(s) did not hold cleanly next to others and run again on their own`
|
|
4314
|
+
);
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
1721
4319
|
let failed = 0;
|
|
1722
4320
|
for (const gate of targets) {
|
|
1723
4321
|
if (gate.spec === null || gate.existingSpecs.length !== 1) {
|
|
@@ -1725,20 +4323,19 @@ function runSelfcheck(dir, mode, commandOverride, io2) {
|
|
|
1725
4323
|
failed += 1;
|
|
1726
4324
|
continue;
|
|
1727
4325
|
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
4326
|
+
if (heldTogether.has(gate.path)) {
|
|
4327
|
+
io2.out(
|
|
4328
|
+
` \u2713 without ${gate.spec} the gate fails (exit ${heldTogether.get(gate.path)}, in the shared run)`
|
|
4329
|
+
);
|
|
4330
|
+
continue;
|
|
4331
|
+
}
|
|
4332
|
+
if (heldAtOnce.has(gate.path)) {
|
|
4333
|
+
io2.out(` \u2713 without ${gate.spec} the gate fails (exit ${heldAtOnce.get(gate.path)})`);
|
|
4334
|
+
continue;
|
|
4335
|
+
}
|
|
4336
|
+
const result = runGate({ exclude: gate.spec });
|
|
1735
4337
|
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
1736
|
-
const reason = result
|
|
1737
|
-
status: result.status,
|
|
1738
|
-
output,
|
|
1739
|
-
gatePath: gate.path,
|
|
1740
|
-
failurePattern: manifest.settings.selfcheckFailurePattern
|
|
1741
|
-
});
|
|
4338
|
+
const reason = judge(result, gate.path);
|
|
1742
4339
|
if (reason === null) {
|
|
1743
4340
|
io2.out(` \u2713 without ${gate.spec} the gate fails (exit ${result.status})`);
|
|
1744
4341
|
} else {
|
|
@@ -1763,10 +4360,14 @@ function runSelfcheck(dir, mode, commandOverride, io2) {
|
|
|
1763
4360
|
}
|
|
1764
4361
|
}
|
|
1765
4362
|
if (failed > 0) {
|
|
1766
|
-
io2.err(
|
|
4363
|
+
io2.err(
|
|
4364
|
+
`test-gates selfcheck: FAILED (${failed} of ${targets.length} negative control(s)${notSelected})${quickCaveat(heldTogether.size)}`
|
|
4365
|
+
);
|
|
1767
4366
|
return 1;
|
|
1768
4367
|
}
|
|
1769
|
-
io2.out(
|
|
4368
|
+
io2.out(
|
|
4369
|
+
`test-gates selfcheck: OK (${targets.length} negative control(s) failed as they should${notSelected})${quickCaveat(heldTogether.size)}`
|
|
4370
|
+
);
|
|
1770
4371
|
return 0;
|
|
1771
4372
|
}
|
|
1772
4373
|
|
|
@@ -1781,12 +4382,26 @@ Commands:
|
|
|
1781
4382
|
Unknown arguments and everything after "--" go to Stryker
|
|
1782
4383
|
mutation-result Judge an existing Stryker JSON report
|
|
1783
4384
|
selfcheck [-- <command>] Negative control: leave one spec out, the gate must fail
|
|
1784
|
-
lcov --file <lcov.info>
|
|
4385
|
+
lcov [--file <lcov.info>] Per-file 100% line coverage from an lcov tracefile
|
|
4386
|
+
doctor Why a mutation run would not reuse the last results, and what
|
|
4387
|
+
to change. Runs nothing; exit 1 when runs are full ones needlessly
|
|
1785
4388
|
|
|
1786
4389
|
Options:
|
|
1787
4390
|
--dir <subproject> Directory that holds test-gates.json (default: .)
|
|
1788
4391
|
--report <path> mutation-result: report to judge (default: settings.stryker.reportFile)
|
|
1789
4392
|
--all | --first selfcheck: every gate in turn (default) or only the first
|
|
4393
|
+
--gate <path> selfcheck: only this gate (may be given more than once).
|
|
4394
|
+
mutation: only the mutants of this gate, while working on it.
|
|
4395
|
+
Not the final check, and refused when CI is set
|
|
4396
|
+
--affected mutation: run again only the gates that a change since the last
|
|
4397
|
+
run can affect, and reuse the results of the others. Every
|
|
4398
|
+
mutant is run when CI is set
|
|
4399
|
+
--shard <index>/<total> selfcheck: one of <total> parts of the gates, for parallel CI jobs
|
|
4400
|
+
--jobs <n> selfcheck: run <n> negative controls at the same time. One that does
|
|
4401
|
+
not hold cleanly there is run again on its own
|
|
4402
|
+
--quick selfcheck: one run for the gates only their own spec touches.
|
|
4403
|
+
Not the final check: it assumes that no spec depends on which
|
|
4404
|
+
other specs run. Refused when CI is set
|
|
1790
4405
|
--file <path> lcov: tracefile (default: settings.lcov.file)
|
|
1791
4406
|
-h, --help Show this help
|
|
1792
4407
|
-v, --version Show the version
|
|
@@ -1802,7 +4417,7 @@ var io = {
|
|
|
1802
4417
|
function main(argv) {
|
|
1803
4418
|
const args = parseArgs(argv);
|
|
1804
4419
|
if (args.version) {
|
|
1805
|
-
const { version } =
|
|
4420
|
+
const { version } = createRequire2(import.meta.url)("../package.json");
|
|
1806
4421
|
io.out(version);
|
|
1807
4422
|
return 0;
|
|
1808
4423
|
}
|
|
@@ -1813,12 +4428,25 @@ function main(argv) {
|
|
|
1813
4428
|
switch (args.command) {
|
|
1814
4429
|
case "check":
|
|
1815
4430
|
return runCheck(args.dir, io);
|
|
4431
|
+
case "doctor":
|
|
4432
|
+
return runDoctor(args.dir, io);
|
|
1816
4433
|
case "mutation":
|
|
1817
|
-
return runMutation(args.dir, args.rest, io);
|
|
4434
|
+
return runMutation(args.dir, args.rest, io, args.gates, args.affected);
|
|
1818
4435
|
case "mutation-result":
|
|
1819
4436
|
return runMutationResult(args.dir, args.report, io);
|
|
1820
4437
|
case "selfcheck":
|
|
1821
|
-
return runSelfcheck(
|
|
4438
|
+
return runSelfcheck(
|
|
4439
|
+
args.dir,
|
|
4440
|
+
{
|
|
4441
|
+
mode: args.mode,
|
|
4442
|
+
gates: args.gates,
|
|
4443
|
+
shard: args.shard,
|
|
4444
|
+
quick: args.quick,
|
|
4445
|
+
jobs: args.jobs
|
|
4446
|
+
},
|
|
4447
|
+
args.rest,
|
|
4448
|
+
io
|
|
4449
|
+
);
|
|
1822
4450
|
case "lcov":
|
|
1823
4451
|
return runLcov(args.dir, args.file, io);
|
|
1824
4452
|
case null:
|
|
@@ -1826,7 +4454,7 @@ function main(argv) {
|
|
|
1826
4454
|
}
|
|
1827
4455
|
}
|
|
1828
4456
|
try {
|
|
1829
|
-
process.exitCode = main(process.argv.slice(2));
|
|
4457
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
1830
4458
|
} catch (error) {
|
|
1831
4459
|
if (error instanceof UsageError) {
|
|
1832
4460
|
io.err(`test-gates: ${error.message}
|