@fairfox/polly 0.91.0 → 0.92.1
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/dist/cli/polly.js +3 -3
- package/dist/cli/polly.js.map +3 -3
- package/dist/gallery/gallery.css +2 -2
- package/dist/gallery/gallery.js +248 -241
- package/dist/src/background/index.js +27 -11
- package/dist/src/background/index.js.map +2 -2
- package/dist/src/background/message-router.js +27 -11
- package/dist/src/background/message-router.js.map +2 -2
- package/dist/src/client/index.js +56 -56
- package/dist/src/client/index.js.map +4 -4
- package/dist/src/elysia/index.js +16 -7
- package/dist/src/elysia/index.js.map +4 -4
- package/dist/src/elysia/plugin.d.ts +4 -4
- package/dist/src/index.js +27 -11
- package/dist/src/index.js.map +2 -2
- package/dist/src/mesh.js +2 -2
- package/dist/src/mesh.js.map +3 -3
- package/dist/src/peer.js +6 -6
- package/dist/src/peer.js.map +3 -3
- package/dist/src/polly-ui/index.css +2 -2
- package/dist/src/polly-ui/markdown.js +1844 -1010
- package/dist/src/polly-ui/markdown.js.map +5 -5
- package/dist/src/polly-ui/styles.css +2 -2
- package/dist/src/shared/adapters/index.js +27 -11
- package/dist/src/shared/adapters/index.js.map +2 -2
- package/dist/src/shared/lib/context-helpers.js +27 -11
- package/dist/src/shared/lib/context-helpers.js.map +2 -2
- package/dist/src/shared/lib/message-bus.js +27 -11
- package/dist/src/shared/lib/message-bus.js.map +2 -2
- package/dist/src/shared/lib/resource.js +17 -1
- package/dist/src/shared/lib/resource.js.map +2 -2
- package/dist/src/shared/lib/state.js +17 -1
- package/dist/src/shared/lib/state.js.map +2 -2
- package/dist/src/shared/state/app-state.js +17 -1
- package/dist/src/shared/state/app-state.js.map +2 -2
- package/dist/tools/bdd/src/cli.js +20 -2
- package/dist/tools/bdd/src/cli.js.map +4 -4
- package/dist/tools/bdd/src/index.js +20 -2
- package/dist/tools/bdd/src/index.js.map +3 -3
- package/dist/tools/init/templates/pwa/package.json.template +2 -3
- package/dist/tools/mutate/src/args.d.ts +25 -3
- package/dist/tools/mutate/src/cli.js +3238 -312
- package/dist/tools/mutate/src/cli.js.map +32 -12
- package/dist/tools/mutate/src/config.d.ts +13 -2
- package/dist/tools/mutate/src/define.d.ts +76 -0
- package/dist/tools/mutate/src/engine/ast.d.ts +13 -0
- package/dist/tools/mutate/src/engine/baseline.d.ts +20 -0
- package/dist/tools/mutate/src/engine/cache.d.ts +32 -0
- package/dist/tools/mutate/src/engine/coverage.d.ts +60 -0
- package/dist/tools/mutate/src/engine/generate.d.ts +23 -0
- package/dist/tools/mutate/src/engine/graph.d.ts +11 -0
- package/dist/tools/mutate/src/engine/index.d.ts +16 -0
- package/dist/tools/mutate/src/engine/junit.d.ts +38 -0
- package/dist/tools/mutate/src/engine/operators.d.ts +14 -0
- package/dist/tools/mutate/src/engine/preload.d.ts +10 -0
- package/dist/tools/mutate/src/engine/preload.js +180 -0
- package/dist/tools/mutate/src/engine/preload.js.map +10 -0
- package/dist/tools/mutate/src/engine/probes.d.ts +12 -0
- package/dist/tools/mutate/src/engine/report-writer.d.ts +44 -0
- package/dist/tools/mutate/src/engine/runner.d.ts +31 -0
- package/dist/tools/mutate/src/engine/stray-writes.d.ts +25 -0
- package/dist/tools/mutate/src/engine/typecheck.d.ts +37 -0
- package/dist/tools/mutate/src/engine/types.d.ts +191 -0
- package/dist/tools/mutate/src/engine/verify-callsites.d.ts +9 -0
- package/dist/tools/mutate/src/engine/workdir.d.ts +16 -0
- package/dist/tools/mutate/src/engine-config.d.ts +7 -0
- package/dist/tools/mutate/src/index.d.ts +7 -3
- package/dist/tools/mutate/src/index.js +2821 -193
- package/dist/tools/mutate/src/index.js.map +26 -7
- package/dist/tools/mutate/src/ingest.d.ts +30 -1
- package/dist/tools/mutate/src/init.d.ts +2 -1
- package/dist/tools/mutate/src/migrate.d.ts +32 -0
- package/dist/tools/mutate/src/report.d.ts +3 -0
- package/dist/tools/mutate/src/run.d.ts +17 -8
- package/dist/tools/mutate/src/verify-matrix.d.ts +12 -15
- package/dist/tools/quality/src/cli.js +9 -9
- package/dist/tools/quality/src/cli.js.map +4 -4
- package/dist/tools/quality/src/index.js +9 -9
- package/dist/tools/quality/src/index.js.map +4 -4
- package/dist/tools/quality/src/no-tautology-ensures.d.ts +2 -2
- package/dist/tools/quality/src/plugins/import-checks.d.ts +1 -1
- package/dist/tools/test/src/adapters/index.js +3 -2
- package/dist/tools/test/src/adapters/index.js.map +3 -3
- package/dist/tools/test/src/coverage-policy/cli.d.ts +2 -2
- package/dist/tools/test/src/coverage-policy/cli.js +217 -17
- package/dist/tools/test/src/coverage-policy/cli.js.map +7 -5
- package/dist/tools/test/src/coverage-policy/index.d.ts +1 -1
- package/dist/tools/test/src/coverage-policy/index.js +213 -12
- package/dist/tools/test/src/coverage-policy/index.js.map +7 -5
- package/dist/tools/test/src/coverage-policy/mutate-targets.d.ts +29 -12
- package/dist/tools/test/src/e2e-mesh/index.js +6 -6
- package/dist/tools/test/src/e2e-mesh/index.js.map +4 -4
- package/dist/tools/test/src/e2e-relay/index.js +230 -226
- package/dist/tools/test/src/e2e-relay/index.js.map +7 -7
- package/dist/tools/test/src/index.js +3 -2
- package/dist/tools/test/src/index.js.map +3 -3
- package/dist/tools/test/src/visual/index.js +684 -511
- package/dist/tools/test/src/visual/index.js.map +11 -11
- package/dist/tools/verify/src/cli.js +17 -2
- package/dist/tools/verify/src/cli.js.map +4 -4
- package/dist/tools/verify/src/config.d.ts +1 -1
- package/dist/tools/verify/src/config.js +68 -3
- package/dist/tools/verify/src/config.js.map +4 -4
- package/dist/tools/verify/src/primitives/index.d.ts +34 -0
- package/dist/tools/visualize/src/cli.js +16 -1
- package/dist/tools/visualize/src/cli.js.map +3 -3
- package/package.json +29 -39
- package/dist/tools/verify/src/stryker/index.d.ts +0 -32
- package/dist/tools/verify/src/stryker/index.js +0 -86
- package/dist/tools/verify/src/stryker/index.js.map +0 -10
|
@@ -1,8 +1,272 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
// tools/mutate/src/cli.ts
|
|
4
|
+
import { resolve as resolve5 } from "node:path";
|
|
5
|
+
|
|
6
|
+
// tools/test/src/coverage-policy/mutate-targets.ts
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { Glob } from "bun";
|
|
10
|
+
|
|
11
|
+
// src/shared/lib/guards.ts
|
|
12
|
+
function isRecord(input) {
|
|
13
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// tools/mutate/src/define.ts
|
|
17
|
+
var MUTATION_CONFIG_FILE = "polly.mutate.ts";
|
|
18
|
+
function defineMutation(config) {
|
|
19
|
+
return config;
|
|
20
|
+
}
|
|
21
|
+
var MUTATOR_NAMES = new Set([
|
|
22
|
+
"ArithmeticOperator",
|
|
23
|
+
"ArrayDeclaration",
|
|
24
|
+
"ArrowFunction",
|
|
25
|
+
"AssignmentOperator",
|
|
26
|
+
"BlockStatement",
|
|
27
|
+
"BooleanLiteral",
|
|
28
|
+
"ConditionalExpression",
|
|
29
|
+
"EqualityOperator",
|
|
30
|
+
"LogicalOperator",
|
|
31
|
+
"MethodExpression",
|
|
32
|
+
"ObjectLiteral",
|
|
33
|
+
"OptionalChaining",
|
|
34
|
+
"Regex",
|
|
35
|
+
"StringLiteral",
|
|
36
|
+
"UnaryOperator",
|
|
37
|
+
"UpdateOperator"
|
|
38
|
+
]);
|
|
39
|
+
function isMutatorName(value) {
|
|
40
|
+
return typeof value === "string" && MUTATOR_NAMES.has(value);
|
|
41
|
+
}
|
|
42
|
+
function strings(value, where) {
|
|
43
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
44
|
+
throw new Error(`${where} must be an array of strings`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function optionalNumber(value, where) {
|
|
49
|
+
if (value === undefined)
|
|
50
|
+
return;
|
|
51
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
52
|
+
throw new Error(`${where} must be a number`);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function optionalString(value, where) {
|
|
56
|
+
if (value === undefined)
|
|
57
|
+
return;
|
|
58
|
+
if (typeof value !== "string")
|
|
59
|
+
throw new Error(`${where} must be a string`);
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function isEngineMode(v) {
|
|
63
|
+
return v === "fast" || v === "matrix";
|
|
64
|
+
}
|
|
65
|
+
function isCoverageMode(v) {
|
|
66
|
+
return v === "dynamic" || v === "static";
|
|
67
|
+
}
|
|
68
|
+
function parseRun(raw, where) {
|
|
69
|
+
const mode = raw["mode"];
|
|
70
|
+
if (mode !== undefined && !isEngineMode(mode)) {
|
|
71
|
+
throw new Error(`${where}: mode must be "fast" or "matrix"`);
|
|
72
|
+
}
|
|
73
|
+
const concurrency = optionalNumber(raw["concurrency"], `${where}: concurrency`);
|
|
74
|
+
if (concurrency !== undefined && !(Number.isInteger(concurrency) && concurrency > 0)) {
|
|
75
|
+
throw new Error(`${where}: concurrency must be a positive integer`);
|
|
76
|
+
}
|
|
77
|
+
const coverage = raw["coverage"];
|
|
78
|
+
if (coverage !== undefined && !isCoverageMode(coverage)) {
|
|
79
|
+
throw new Error(`${where}: coverage must be "dynamic" or "static"`);
|
|
80
|
+
}
|
|
81
|
+
const common = { targets: strings(raw["targets"], `${where}: targets`) };
|
|
82
|
+
if (isEngineMode(mode))
|
|
83
|
+
common.mode = mode;
|
|
84
|
+
if (isCoverageMode(coverage))
|
|
85
|
+
common.coverage = coverage;
|
|
86
|
+
if (concurrency !== undefined)
|
|
87
|
+
common.concurrency = concurrency;
|
|
88
|
+
const timeout = raw["timeout"];
|
|
89
|
+
if (timeout !== undefined) {
|
|
90
|
+
if (!isRecord(timeout))
|
|
91
|
+
throw new Error(`${where}: timeout must be { factor, extraMs }`);
|
|
92
|
+
const factor = optionalNumber(timeout["factor"], `${where}: timeout.factor`) ?? 2;
|
|
93
|
+
const extraMs = optionalNumber(timeout["extraMs"], `${where}: timeout.extraMs`) ?? 3000;
|
|
94
|
+
common.timeout = { factor, extraMs };
|
|
95
|
+
}
|
|
96
|
+
return common;
|
|
97
|
+
}
|
|
98
|
+
function parseMutants(raw, where) {
|
|
99
|
+
const out = {};
|
|
100
|
+
const mutators = raw["mutators"];
|
|
101
|
+
if (mutators !== undefined) {
|
|
102
|
+
if (!isRecord(mutators))
|
|
103
|
+
throw new Error(`${where}: mutators must be { exclude }`);
|
|
104
|
+
const exclude = mutators["exclude"] === undefined ? [] : mutators["exclude"];
|
|
105
|
+
if (!Array.isArray(exclude) || !exclude.every(isMutatorName)) {
|
|
106
|
+
throw new Error(`${where}: mutators.exclude holds an unknown mutator name`);
|
|
107
|
+
}
|
|
108
|
+
out.mutators = { exclude };
|
|
109
|
+
}
|
|
110
|
+
const ignore = raw["ignoreVerifyCallsites"];
|
|
111
|
+
if (ignore !== undefined) {
|
|
112
|
+
if (typeof ignore !== "boolean")
|
|
113
|
+
throw new Error(`${where}: ignoreVerifyCallsites must be boolean`);
|
|
114
|
+
out.ignoreVerifyCallsites = ignore;
|
|
115
|
+
}
|
|
116
|
+
const typecheck = raw["typecheck"];
|
|
117
|
+
if (typecheck !== undefined) {
|
|
118
|
+
if (typeof typecheck !== "boolean")
|
|
119
|
+
throw new Error(`${where}: typecheck must be boolean`);
|
|
120
|
+
out.typecheck = typecheck;
|
|
121
|
+
}
|
|
122
|
+
return { ...out, ...parseContracts(raw, where) };
|
|
123
|
+
}
|
|
124
|
+
function isContractsMode(v) {
|
|
125
|
+
return v === "off" || v === "ensures" || v === "all";
|
|
126
|
+
}
|
|
127
|
+
function parseContracts(raw, where) {
|
|
128
|
+
const contracts = raw["contracts"];
|
|
129
|
+
if (contracts === undefined)
|
|
130
|
+
return {};
|
|
131
|
+
if (!isContractsMode(contracts)) {
|
|
132
|
+
throw new Error(`${where}: contracts must be "off", "ensures" or "all"`);
|
|
133
|
+
}
|
|
134
|
+
return { contracts };
|
|
135
|
+
}
|
|
136
|
+
function parseOutputs(raw, where) {
|
|
137
|
+
const out = {};
|
|
138
|
+
const thresholds = raw["thresholds"];
|
|
139
|
+
if (thresholds !== undefined) {
|
|
140
|
+
if (!isRecord(thresholds))
|
|
141
|
+
throw new Error(`${where}: thresholds must be { break }`);
|
|
142
|
+
const brk = optionalNumber(thresholds["break"], `${where}: thresholds.break`);
|
|
143
|
+
out.thresholds = brk === undefined ? {} : { break: brk };
|
|
144
|
+
}
|
|
145
|
+
const report = optionalString(raw["report"], `${where}: report`);
|
|
146
|
+
if (report !== undefined)
|
|
147
|
+
out.report = report;
|
|
148
|
+
const decisions = optionalString(raw["decisions"], `${where}: decisions`);
|
|
149
|
+
if (decisions !== undefined)
|
|
150
|
+
out.decisions = decisions;
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
function parseCommon(raw, where) {
|
|
154
|
+
return { ...parseRun(raw, where), ...parseMutants(raw, where), ...parseOutputs(raw, where) };
|
|
155
|
+
}
|
|
156
|
+
function parseMutationConfig(raw, where) {
|
|
157
|
+
if (!isRecord(raw))
|
|
158
|
+
throw new Error(`${where}: the default export must be a config object`);
|
|
159
|
+
const { targets, ...common } = parseCommon(raw, where);
|
|
160
|
+
const runner = raw["runner"];
|
|
161
|
+
if (isRecord(runner) && runner["kind"] === "command") {
|
|
162
|
+
const argv = strings(runner["argv"], `${where}: runner.argv`);
|
|
163
|
+
if (argv.length === 0)
|
|
164
|
+
throw new Error(`${where}: runner.argv is empty`);
|
|
165
|
+
return { targets, runner: { kind: "command", argv }, ...common };
|
|
166
|
+
}
|
|
167
|
+
if (runner !== undefined && !(isRecord(runner) && runner["kind"] === "bun-test")) {
|
|
168
|
+
throw new Error(`${where}: runner.kind must be "bun-test" or "command"`);
|
|
169
|
+
}
|
|
170
|
+
const tests = raw["tests"];
|
|
171
|
+
if (!isRecord(tests))
|
|
172
|
+
throw new Error(`${where}: tests.include is required`);
|
|
173
|
+
const include = strings(tests["include"], `${where}: tests.include`);
|
|
174
|
+
if (include.length === 0)
|
|
175
|
+
throw new Error(`${where}: tests.include is empty`);
|
|
176
|
+
const preload = tests["preload"] === undefined ? undefined : strings(tests["preload"], `${where}: tests.preload`);
|
|
177
|
+
return { targets, tests: preload ? { include, preload } : { include }, ...common };
|
|
178
|
+
}
|
|
179
|
+
async function loadMutationConfig(path) {
|
|
180
|
+
const mod = await import(path);
|
|
181
|
+
const raw = isRecord(mod) ? mod["default"] : undefined;
|
|
182
|
+
return parseMutationConfig(raw, path);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// tools/test/src/coverage-policy/mutate-targets.ts
|
|
186
|
+
async function findStrykerConfigs(root) {
|
|
187
|
+
const found = [];
|
|
188
|
+
const single = join(root, "stryker.conf.json");
|
|
189
|
+
if (existsSync(single))
|
|
190
|
+
found.push(single);
|
|
191
|
+
const glob = new Glob("stryker/*.{json,conf.json}");
|
|
192
|
+
for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {
|
|
193
|
+
found.push(join(root, rel));
|
|
194
|
+
}
|
|
195
|
+
return found.sort();
|
|
196
|
+
}
|
|
197
|
+
async function findMutateConfigs(root) {
|
|
198
|
+
const out = [];
|
|
199
|
+
const polly = join(root, MUTATION_CONFIG_FILE);
|
|
200
|
+
if (existsSync(polly)) {
|
|
201
|
+
const config = await loadMutationConfig(polly);
|
|
202
|
+
out.push({
|
|
203
|
+
path: polly,
|
|
204
|
+
format: "polly",
|
|
205
|
+
targets: { field: "targets", patterns: config.targets },
|
|
206
|
+
tests: { field: "tests.include", patterns: config.tests?.include ?? [] }
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
for (const path of await findStrykerConfigs(root)) {
|
|
210
|
+
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
211
|
+
out.push({
|
|
212
|
+
path,
|
|
213
|
+
format: "stryker",
|
|
214
|
+
targets: { field: "mutate", patterns: config.mutate ?? [] },
|
|
215
|
+
tests: { field: "testFiles", patterns: config.testFiles ?? config.bun?.testFiles ?? [] }
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
function isGlob(pattern) {
|
|
221
|
+
return pattern.includes("*") || pattern.includes("?") || pattern.includes("[");
|
|
222
|
+
}
|
|
223
|
+
var LINE_RANGE = /:\d+(?::\d+)?-\d+(?::\d+)?$/;
|
|
224
|
+
async function resolvesToFile(entry, cwd) {
|
|
225
|
+
if (entry.startsWith("!"))
|
|
226
|
+
return true;
|
|
227
|
+
const pattern = entry.replace(LINE_RANGE, "");
|
|
228
|
+
if (!isGlob(pattern))
|
|
229
|
+
return existsSync(resolve(cwd, pattern));
|
|
230
|
+
const glob = new Glob(pattern);
|
|
231
|
+
for await (const _ of glob.scan({ cwd, onlyFiles: true }))
|
|
232
|
+
return true;
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
async function checkField(configPath, field, patterns, cwd) {
|
|
236
|
+
const issues = [];
|
|
237
|
+
for (const pattern of patterns) {
|
|
238
|
+
if (!await resolvesToFile(pattern, cwd)) {
|
|
239
|
+
issues.push({ config: configPath, field, pattern });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return issues;
|
|
243
|
+
}
|
|
244
|
+
async function validateMutateTargets(root) {
|
|
245
|
+
const configs = await findMutateConfigs(root);
|
|
246
|
+
const issues = [];
|
|
247
|
+
for (const c of configs) {
|
|
248
|
+
const cwd = c.format === "polly" ? dirname(c.path) : root;
|
|
249
|
+
issues.push(...await checkField(c.path, c.targets.field, c.targets.patterns, cwd));
|
|
250
|
+
issues.push(...await checkField(c.path, c.tests.field, c.tests.patterns, cwd));
|
|
251
|
+
}
|
|
252
|
+
return { configs: configs.map((c) => c.path), issues };
|
|
253
|
+
}
|
|
254
|
+
|
|
3
255
|
// tools/mutate/src/args.ts
|
|
4
|
-
var KNOWN_VERBS = new Set(["run", "report", "decisions", "verify", "init", "help"]);
|
|
5
|
-
var VALUE_FLAGS = new Set([
|
|
256
|
+
var KNOWN_VERBS = new Set(["run", "report", "decisions", "verify", "init", "migrate", "help"]);
|
|
257
|
+
var VALUE_FLAGS = new Set([
|
|
258
|
+
"--config",
|
|
259
|
+
"--report",
|
|
260
|
+
"--decisions",
|
|
261
|
+
"--db",
|
|
262
|
+
"--engine",
|
|
263
|
+
"--mode",
|
|
264
|
+
"--coverage",
|
|
265
|
+
"--concurrency",
|
|
266
|
+
"--files",
|
|
267
|
+
"--since",
|
|
268
|
+
"--contracts"
|
|
269
|
+
]);
|
|
6
270
|
function parseMutateArgs(argv) {
|
|
7
271
|
const positionals = [];
|
|
8
272
|
const flags = new Map;
|
|
@@ -36,79 +300,198 @@ function parseMutateArgs(argv) {
|
|
|
36
300
|
noReport: bools.has("--no-report"),
|
|
37
301
|
run: bools.has("--run"),
|
|
38
302
|
force: bools.has("--force"),
|
|
39
|
-
|
|
303
|
+
write: bools.has("--write"),
|
|
304
|
+
inTree: bools.has("--in-tree"),
|
|
305
|
+
noCache: bools.has("--no-cache"),
|
|
306
|
+
noTypecheck: bools.has("--no-typecheck"),
|
|
307
|
+
help,
|
|
308
|
+
engine: flags.get("--engine"),
|
|
309
|
+
mode: flags.get("--mode"),
|
|
310
|
+
coverage: flags.get("--coverage"),
|
|
311
|
+
concurrency: flags.get("--concurrency"),
|
|
312
|
+
files: flags.get("--files"),
|
|
313
|
+
since: flags.get("--since"),
|
|
314
|
+
contracts: flags.get("--contracts")
|
|
40
315
|
};
|
|
41
316
|
}
|
|
42
317
|
|
|
43
318
|
// tools/mutate/src/config.ts
|
|
44
|
-
import {
|
|
319
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
320
|
+
import { dirname as dirname3, resolve as resolve2 } from "node:path";
|
|
45
321
|
|
|
46
|
-
// tools/
|
|
47
|
-
import { existsSync
|
|
48
|
-
import { join,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
322
|
+
// tools/mutate/src/migrate.ts
|
|
323
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
324
|
+
import { dirname as dirname2, join as join2, relative } from "node:path";
|
|
325
|
+
var MAPPED = new Set([
|
|
326
|
+
"mutate",
|
|
327
|
+
"testRunner",
|
|
328
|
+
"bun",
|
|
329
|
+
"commandRunner",
|
|
330
|
+
"concurrency",
|
|
331
|
+
"timeoutFactor",
|
|
332
|
+
"timeoutMS",
|
|
333
|
+
"disableBail",
|
|
334
|
+
"mutator",
|
|
335
|
+
"thresholds",
|
|
336
|
+
"ignorers"
|
|
337
|
+
]);
|
|
338
|
+
var SILENT = new Set(["$schema", "_comment"]);
|
|
339
|
+
var SHELL_SYNTAX = /[|&;<>()$`"'\\*?{}[\]~]/;
|
|
340
|
+
function preloadArgs(nodeArgs) {
|
|
341
|
+
const out = [];
|
|
342
|
+
nodeArgs.forEach((arg, i) => {
|
|
343
|
+
const value = arg === "--preload" ? nodeArgs[i + 1] : arg.startsWith("--preload=") ? arg.slice(10) : undefined;
|
|
344
|
+
if (value)
|
|
345
|
+
out.push(value);
|
|
346
|
+
});
|
|
347
|
+
return out;
|
|
60
348
|
}
|
|
61
|
-
function
|
|
62
|
-
|
|
349
|
+
function commandArgv(command) {
|
|
350
|
+
const trimmed = command.trim();
|
|
351
|
+
return SHELL_SYNTAX.test(trimmed) ? ["sh", "-c", trimmed] : trimmed.split(/\s+/);
|
|
63
352
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return true;
|
|
67
|
-
if (!isGlob(pattern))
|
|
68
|
-
return existsSync(resolve(cwd, pattern));
|
|
69
|
-
const glob = new Glob(pattern);
|
|
70
|
-
for await (const _ of glob.scan({ cwd, onlyFiles: true }))
|
|
71
|
-
return true;
|
|
72
|
-
return false;
|
|
353
|
+
function stringList(value) {
|
|
354
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
73
355
|
}
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
356
|
+
function timeoutOf(raw) {
|
|
357
|
+
const factor = raw["timeoutFactor"];
|
|
358
|
+
const ms = raw["timeoutMS"];
|
|
359
|
+
if (typeof factor !== "number" && typeof ms !== "number")
|
|
360
|
+
return;
|
|
361
|
+
return {
|
|
362
|
+
factor: typeof factor === "number" ? factor : 1.5,
|
|
363
|
+
extraMs: typeof ms === "number" ? ms : 5000
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function testsOf(raw, where) {
|
|
367
|
+
const bun = isRecord(raw["bun"]) ? raw["bun"] : {};
|
|
368
|
+
const include = stringList(bun["testFiles"]);
|
|
369
|
+
if (include.length === 0)
|
|
370
|
+
throw new Error(`${where} has no bun.testFiles`);
|
|
371
|
+
const preload = preloadArgs(stringList(bun["nodeArgs"]));
|
|
372
|
+
return preload.length > 0 ? { include, preload } : { include };
|
|
373
|
+
}
|
|
374
|
+
function runnerOf(raw, where) {
|
|
375
|
+
const runnerName = raw["testRunner"];
|
|
376
|
+
if (runnerName === "command") {
|
|
377
|
+
const cmd = isRecord(raw["commandRunner"]) ? raw["commandRunner"]["command"] : undefined;
|
|
378
|
+
const argv = commandArgv(typeof cmd === "string" ? cmd : "npm test");
|
|
379
|
+
return { runner: { kind: "command", argv } };
|
|
80
380
|
}
|
|
81
|
-
|
|
381
|
+
if (runnerName === undefined || runnerName === "bun") {
|
|
382
|
+
return {
|
|
383
|
+
tests: testsOf(raw, where),
|
|
384
|
+
...raw["disableBail"] === true ? { mode: "matrix" } : {}
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
throw new Error(`${where}: testRunner "${String(runnerName)}" has no polly counterpart (bun or command)`);
|
|
82
388
|
}
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
389
|
+
function tuningOf(raw) {
|
|
390
|
+
const out = {};
|
|
391
|
+
if (typeof raw["concurrency"] === "number")
|
|
392
|
+
out["concurrency"] = raw["concurrency"];
|
|
393
|
+
const timeout = timeoutOf(raw);
|
|
394
|
+
if (timeout)
|
|
395
|
+
out["timeout"] = timeout;
|
|
396
|
+
const excluded = isRecord(raw["mutator"]) ? stringList(raw["mutator"]["excludedMutations"]) : [];
|
|
397
|
+
if (excluded.length > 0)
|
|
398
|
+
out["mutators"] = { exclude: excluded };
|
|
399
|
+
const brk = isRecord(raw["thresholds"]) ? raw["thresholds"]["break"] : undefined;
|
|
400
|
+
if (typeof brk === "number")
|
|
401
|
+
out["thresholds"] = { break: brk };
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
function strykerToMutation(raw, where) {
|
|
405
|
+
if (!isRecord(raw))
|
|
406
|
+
throw new Error(`${where} is not a JSON object`);
|
|
407
|
+
const draft = { targets: stringList(raw["mutate"]), ...runnerOf(raw, where), ...tuningOf(raw) };
|
|
408
|
+
const notes = [];
|
|
409
|
+
if (!stringList(raw["ignorers"]).includes("polly-verify")) {
|
|
410
|
+
notes.push("ignoreVerifyCallsites defaults to true: requires/ensures mutants are reported Ignored (polly#143)");
|
|
91
411
|
}
|
|
92
|
-
|
|
412
|
+
const dropped = Object.keys(raw).filter((k) => !MAPPED.has(k) && !SILENT.has(k));
|
|
413
|
+
if (dropped.length > 0)
|
|
414
|
+
notes.push(`dropped Stryker-only keys: ${dropped.join(", ")}`);
|
|
415
|
+
return { config: parseMutationConfig(draft, where), notes };
|
|
416
|
+
}
|
|
417
|
+
async function readStrykerConfig(path) {
|
|
418
|
+
return strykerToMutation(await Bun.file(path).json(), path);
|
|
419
|
+
}
|
|
420
|
+
function defineImportSpecifier(dir) {
|
|
421
|
+
try {
|
|
422
|
+
Bun.resolveSync("@fairfox/polly/mutate", dir);
|
|
423
|
+
return "@fairfox/polly/mutate";
|
|
424
|
+
} catch {
|
|
425
|
+
const local = join2(import.meta.dir, "define.ts");
|
|
426
|
+
if (!existsSync2(local))
|
|
427
|
+
return "@fairfox/polly/mutate";
|
|
428
|
+
const rel = relative(dir, local);
|
|
429
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
var LINE_WIDTH = 100;
|
|
433
|
+
function literal(value) {
|
|
434
|
+
return JSON.stringify(value, null, 2).replace(/^(\s*)"([A-Za-z_$][\w$]*)":/gm, "$1$2:").replace(/([^\s[{,])(?=\n\s*[}\]])/g, "$1,").replace(/^([ ]*)([\w$]+: )?\[\n((?:[ ]*(?:"[^"\n]*"|-?\d+(?:\.\d+)?|true|false),\n)+)([ ]*)\](,?)$/gm, (m, indent, key, items, _close, comma) => {
|
|
435
|
+
const inline = `${indent}${key ?? ""}[${items.trim().replace(/,$/, "").split(/,\n\s*/).join(", ")}]${comma}`;
|
|
436
|
+
return inline.length <= LINE_WIDTH ? inline : m;
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function renderMutationConfig(config, specifier) {
|
|
440
|
+
return [
|
|
441
|
+
`import { defineMutation } from "${specifier}";`,
|
|
442
|
+
"",
|
|
443
|
+
`export default defineMutation(${literal(config)});`,
|
|
444
|
+
""
|
|
445
|
+
].join(`
|
|
446
|
+
`);
|
|
447
|
+
}
|
|
448
|
+
async function migrateConfig(strykerPath, opts = {}) {
|
|
449
|
+
const { config, notes } = await readStrykerConfig(strykerPath);
|
|
450
|
+
const dir = dirname2(strykerPath);
|
|
451
|
+
const path = join2(dir, MUTATION_CONFIG_FILE);
|
|
452
|
+
const source = renderMutationConfig(config, defineImportSpecifier(dir));
|
|
453
|
+
const existed = existsSync2(path);
|
|
454
|
+
const written = Boolean(opts.write) && (!existed || Boolean(opts.force));
|
|
455
|
+
if (written)
|
|
456
|
+
await Bun.write(path, source);
|
|
457
|
+
return { path, source, notes, written, existed };
|
|
93
458
|
}
|
|
94
459
|
|
|
95
460
|
// tools/mutate/src/config.ts
|
|
96
461
|
var DEFAULT_REPORT = "reports/mutation/mutation.json";
|
|
97
462
|
var DEFAULT_DECISIONS = ".polly/test-debt/decisions.jsonl";
|
|
463
|
+
async function loadAt(path) {
|
|
464
|
+
if (path.endsWith(".json")) {
|
|
465
|
+
const { config, notes } = await readStrykerConfig(path);
|
|
466
|
+
return { path, format: "stryker", config, notes };
|
|
467
|
+
}
|
|
468
|
+
return { path, format: "polly", config: await loadMutationConfig(path), notes: [] };
|
|
469
|
+
}
|
|
98
470
|
async function resolveMutateConfig(cwd, args) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
471
|
+
const explicit = args.config ? resolve2(cwd, args.config) : null;
|
|
472
|
+
const discoveredStryker = (await findStrykerConfigs(cwd))[0] ?? null;
|
|
473
|
+
const local = resolve2(cwd, MUTATION_CONFIG_FILE);
|
|
474
|
+
const mutationPath = explicit ?? (existsSync3(local) ? local : discoveredStryker);
|
|
475
|
+
let mutation = null;
|
|
476
|
+
let mutationError = null;
|
|
477
|
+
if (mutationPath?.endsWith(".json")) {
|
|
478
|
+
try {
|
|
479
|
+
mutation = await loadAt(mutationPath);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
mutationError = err instanceof Error ? err.message : String(err);
|
|
482
|
+
}
|
|
483
|
+
} else if (mutationPath) {
|
|
484
|
+
mutation = await loadAt(mutationPath);
|
|
105
485
|
}
|
|
486
|
+
const base = mutation ? dirname3(mutation.path) : cwd;
|
|
487
|
+
const fromConfig = (flag, key, fallback) => flag ? resolve2(cwd, flag) : resolve2(base, key ?? fallback);
|
|
106
488
|
return {
|
|
107
489
|
cwd,
|
|
108
|
-
|
|
109
|
-
|
|
490
|
+
mutation,
|
|
491
|
+
mutationError,
|
|
492
|
+
reportPath: fromConfig(args.report, mutation?.config.report, DEFAULT_REPORT),
|
|
110
493
|
dbPath: args.db ? resolve2(cwd, args.db) : ":memory:",
|
|
111
|
-
decisionsPath:
|
|
494
|
+
decisionsPath: fromConfig(args.decisions, mutation?.config.decisions, DEFAULT_DECISIONS)
|
|
112
495
|
};
|
|
113
496
|
}
|
|
114
497
|
|
|
@@ -123,6 +506,10 @@ function buildDb(report, dbPath = ":memory:") {
|
|
|
123
506
|
db.exec(`
|
|
124
507
|
DROP TABLE IF EXISTS kills;
|
|
125
508
|
DROP TABLE IF EXISTS covers;
|
|
509
|
+
DROP TABLE IF EXISTS contract_kills;
|
|
510
|
+
DROP TABLE IF EXISTS contract_sites;
|
|
511
|
+
DROP TABLE IF EXISTS contract_violations;
|
|
512
|
+
DROP TABLE IF EXISTS report_meta;
|
|
126
513
|
DROP TABLE IF EXISTS mutants;
|
|
127
514
|
DROP TABLE IF EXISTS tests;
|
|
128
515
|
CREATE TABLE tests (
|
|
@@ -136,8 +523,30 @@ function buildDb(report, dbPath = ":memory:") {
|
|
|
136
523
|
mutator TEXT NOT NULL,
|
|
137
524
|
file TEXT NOT NULL,
|
|
138
525
|
line INTEGER,
|
|
139
|
-
status TEXT NOT NULL
|
|
526
|
+
status TEXT NOT NULL,
|
|
527
|
+
contract_only INTEGER NOT NULL DEFAULT 0
|
|
528
|
+
);
|
|
529
|
+
-- requires/ensures sites and the mutants each one killed (bun engine, contracts on)
|
|
530
|
+
CREATE TABLE contract_sites (
|
|
531
|
+
id TEXT PRIMARY KEY,
|
|
532
|
+
file TEXT NOT NULL,
|
|
533
|
+
line INTEGER NOT NULL,
|
|
534
|
+
primitive TEXT NOT NULL,
|
|
535
|
+
message TEXT,
|
|
536
|
+
handler_from INTEGER,
|
|
537
|
+
handler_to INTEGER
|
|
538
|
+
);
|
|
539
|
+
CREATE TABLE contract_kills (
|
|
540
|
+
mutant_id TEXT NOT NULL REFERENCES mutants(id),
|
|
541
|
+
contract_id TEXT NOT NULL,
|
|
542
|
+
PRIMARY KEY (mutant_id, contract_id)
|
|
543
|
+
);
|
|
544
|
+
CREATE TABLE contract_violations (
|
|
545
|
+
target TEXT NOT NULL,
|
|
546
|
+
test TEXT NOT NULL,
|
|
547
|
+
contract_id TEXT
|
|
140
548
|
);
|
|
549
|
+
CREATE TABLE report_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
141
550
|
-- the kill matrix: (mutant, test) where test detects mutant
|
|
142
551
|
CREATE TABLE kills (
|
|
143
552
|
mutant_id TEXT NOT NULL REFERENCES mutants(id),
|
|
@@ -154,9 +563,13 @@ function buildDb(report, dbPath = ":memory:") {
|
|
|
154
563
|
CREATE INDEX idx_kills_mutant ON kills(mutant_id);
|
|
155
564
|
`);
|
|
156
565
|
const insTest = db.prepare("INSERT OR IGNORE INTO tests (id, file, name, line) VALUES (?, ?, ?, ?)");
|
|
157
|
-
const insMut = db.prepare("INSERT INTO mutants (id, mutator, file, line, status) VALUES (?, ?, ?, ?, ?)");
|
|
566
|
+
const insMut = db.prepare("INSERT INTO mutants (id, mutator, file, line, status, contract_only) VALUES (?, ?, ?, ?, ?, ?)");
|
|
158
567
|
const insKill = db.prepare("INSERT OR IGNORE INTO kills (mutant_id, test_id) VALUES (?, ?)");
|
|
159
568
|
const insCover = db.prepare("INSERT OR IGNORE INTO covers (mutant_id, test_id) VALUES (?, ?)");
|
|
569
|
+
const insContractKill = db.prepare("INSERT OR IGNORE INTO contract_kills (mutant_id, contract_id) VALUES (?, ?)");
|
|
570
|
+
const insSite = db.prepare("INSERT OR IGNORE INTO contract_sites (id, file, line, primitive, message, handler_from, handler_to) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
|
571
|
+
const insViolation = db.prepare("INSERT INTO contract_violations (target, test, contract_id) VALUES (?, ?, ?)");
|
|
572
|
+
const insMeta = db.prepare("INSERT INTO report_meta (key, value) VALUES (?, ?)");
|
|
160
573
|
const ingest = db.transaction(() => {
|
|
161
574
|
for (const [file, tf] of Object.entries(report.testFiles ?? {})) {
|
|
162
575
|
for (const t of tf.tests) {
|
|
@@ -165,215 +578,2607 @@ function buildDb(report, dbPath = ":memory:") {
|
|
|
165
578
|
}
|
|
166
579
|
for (const [file, f] of Object.entries(report.files)) {
|
|
167
580
|
for (const m of f.mutants) {
|
|
168
|
-
insMut.run(m.id, m.mutatorName, file, m.location.start.line, m.status);
|
|
581
|
+
insMut.run(m.id, m.mutatorName, file, m.location.start.line, m.status, m.contractOnly ? 1 : 0);
|
|
169
582
|
for (const tid of m.killedBy ?? [])
|
|
170
583
|
insKill.run(m.id, tid);
|
|
171
584
|
for (const tid of m.coveredBy ?? [])
|
|
172
585
|
insCover.run(m.id, tid);
|
|
586
|
+
for (const cid of m.killedByContract ?? [])
|
|
587
|
+
insContractKill.run(m.id, cid);
|
|
173
588
|
}
|
|
174
589
|
}
|
|
590
|
+
const contracts = report.contracts;
|
|
591
|
+
if (contracts) {
|
|
592
|
+
insMeta.run("contracts_mode", contracts.mode);
|
|
593
|
+
insMeta.run("runner", contracts.runner);
|
|
594
|
+
for (const s of contracts.sites) {
|
|
595
|
+
insSite.run(s.id, s.file, s.line, s.primitive, s.message ?? null, s.handler?.from ?? null, s.handler?.to ?? null);
|
|
596
|
+
}
|
|
597
|
+
for (const v of contracts.violations) {
|
|
598
|
+
if (v.contracts.length === 0)
|
|
599
|
+
insViolation.run(v.target, v.test, null);
|
|
600
|
+
for (const cid of v.contracts)
|
|
601
|
+
insViolation.run(v.target, v.test, cid);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
ingest();
|
|
606
|
+
return db;
|
|
607
|
+
}
|
|
608
|
+
function queryAll(db, sql) {
|
|
609
|
+
return db.query(sql).all();
|
|
610
|
+
}
|
|
611
|
+
function queryGet(db, sql) {
|
|
612
|
+
return db.query(sql).get();
|
|
613
|
+
}
|
|
614
|
+
async function loadMutationReport(path) {
|
|
615
|
+
return await Bun.file(path).json();
|
|
616
|
+
}
|
|
617
|
+
if (false) {}
|
|
618
|
+
|
|
619
|
+
// tools/mutate/src/decisions.ts
|
|
620
|
+
var DEFAULT_LOG = ".polly/test-debt/decisions.jsonl";
|
|
621
|
+
var VERDICTS = ["keep", "prune", "rewrite", "investigate"];
|
|
622
|
+
function normalizeSource(src) {
|
|
623
|
+
return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/[^\n]*/g, "$1").replace(/\s+/g, " ").trim();
|
|
624
|
+
}
|
|
625
|
+
async function fileHash(file) {
|
|
626
|
+
const f = Bun.file(file);
|
|
627
|
+
if (!await f.exists())
|
|
628
|
+
return null;
|
|
629
|
+
return createHash("sha256").update(normalizeSource(await f.text())).digest("hex").slice(0, 16);
|
|
630
|
+
}
|
|
631
|
+
function fileSignals(db) {
|
|
632
|
+
const rows = queryAll(db, `
|
|
633
|
+
WITH file_kill AS (SELECT DISTINCT t.file file, k.mutant_id mid FROM kills k JOIN tests t ON t.id=k.test_id),
|
|
634
|
+
owner AS (SELECT mid, count(DISTINCT file) nf FROM file_kill GROUP BY mid)
|
|
635
|
+
SELECT fk.file file, count(DISTINCT fk.mid) kills,
|
|
636
|
+
sum(CASE WHEN o.nf=1 THEN 1 ELSE 0 END) unique_kills
|
|
637
|
+
FROM file_kill fk JOIN owner o ON o.mid=fk.mid GROUP BY fk.file`);
|
|
638
|
+
const m = new Map;
|
|
639
|
+
for (const r of rows)
|
|
640
|
+
m.set(r.file, {
|
|
641
|
+
kills: r.kills,
|
|
642
|
+
uniqueKills: r.unique_kills,
|
|
643
|
+
subsumed: r.kills > 0 && r.unique_kills === 0
|
|
644
|
+
});
|
|
645
|
+
return m;
|
|
646
|
+
}
|
|
647
|
+
async function loadDecisions(logPath) {
|
|
648
|
+
const m = new Map;
|
|
649
|
+
const f = Bun.file(logPath);
|
|
650
|
+
if (!await f.exists())
|
|
651
|
+
return m;
|
|
652
|
+
for (const line of (await f.text()).split(`
|
|
653
|
+
`)) {
|
|
654
|
+
const t = line.trim();
|
|
655
|
+
if (!t)
|
|
656
|
+
continue;
|
|
657
|
+
const d = JSON.parse(t);
|
|
658
|
+
m.set(d.file, d);
|
|
659
|
+
}
|
|
660
|
+
return m;
|
|
661
|
+
}
|
|
662
|
+
function staleReason(d, currentHash, current) {
|
|
663
|
+
if (!current)
|
|
664
|
+
return "file no longer kills any mutant (renamed/deleted/refactored?)";
|
|
665
|
+
if (d.hash !== currentHash)
|
|
666
|
+
return "test file changed since the decision";
|
|
667
|
+
if (d.signal.subsumed !== current.subsumed)
|
|
668
|
+
return `subsumption flipped (${d.signal.subsumed} → ${current.subsumed})`;
|
|
669
|
+
if (d.signal.uniqueKills > 0 !== current.uniqueKills > 0)
|
|
670
|
+
return `unique-kill status changed (${d.signal.uniqueKills} → ${current.uniqueKills})`;
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
async function status(db, logPath = DEFAULT_LOG) {
|
|
674
|
+
const signals = fileSignals(db);
|
|
675
|
+
const decisions = await loadDecisions(logPath);
|
|
676
|
+
const needsReview = [];
|
|
677
|
+
const stale = [];
|
|
678
|
+
const settled = [];
|
|
679
|
+
for (const [file, sig] of [...signals].sort((a, b) => b[1].kills - a[1].kills)) {
|
|
680
|
+
const d = decisions.get(file);
|
|
681
|
+
if (!d) {
|
|
682
|
+
if (sig.subsumed)
|
|
683
|
+
needsReview.push(` ${file} (${sig.kills} kills, 0 unique)`);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const reason = staleReason(d, await fileHash(file), sig);
|
|
687
|
+
if (reason)
|
|
688
|
+
stale.push(` ${file} [${d.verdict}] — ${reason}`);
|
|
689
|
+
else
|
|
690
|
+
settled.push(` ${file} [${d.verdict}] ${d.rationale ? `— ${d.rationale}` : ""}`);
|
|
691
|
+
}
|
|
692
|
+
for (const [file, d] of decisions) {
|
|
693
|
+
if (!signals.has(file))
|
|
694
|
+
stale.push(` ${file} [${d.verdict}] — file no longer in matrix`);
|
|
695
|
+
}
|
|
696
|
+
const out = [];
|
|
697
|
+
out.push(`NEEDS REVIEW (subsumed files with no decision — ${needsReview.length})`);
|
|
698
|
+
out.push(needsReview.length ? needsReview.join(`
|
|
699
|
+
`) : " none");
|
|
700
|
+
out.push(`
|
|
701
|
+
STALE (decision's basis drifted — re-review — ${stale.length})`);
|
|
702
|
+
out.push(stale.length ? stale.join(`
|
|
703
|
+
`) : " none");
|
|
704
|
+
out.push(`
|
|
705
|
+
SETTLED (fresh decisions, suppressed from action list — ${settled.length})`);
|
|
706
|
+
out.push(settled.length ? settled.join(`
|
|
707
|
+
`) : " none");
|
|
708
|
+
return out.join(`
|
|
709
|
+
`);
|
|
710
|
+
}
|
|
711
|
+
async function decide(file, verdict, rationale, db, logPath = DEFAULT_LOG) {
|
|
712
|
+
if (!VERDICTS.includes(verdict)) {
|
|
713
|
+
console.log(`✗ verdict must be one of: ${VERDICTS.join(", ")}`);
|
|
714
|
+
process.exit(1);
|
|
715
|
+
}
|
|
716
|
+
const sig = fileSignals(db).get(file);
|
|
717
|
+
if (!sig) {
|
|
718
|
+
console.log(`✗ ${file} is not a test file that kills any mutant in the current matrix`);
|
|
719
|
+
process.exit(1);
|
|
720
|
+
}
|
|
721
|
+
const decided = new Date().toISOString().slice(0, 10);
|
|
722
|
+
const record = {
|
|
723
|
+
file,
|
|
724
|
+
verdict,
|
|
725
|
+
rationale,
|
|
726
|
+
signal: sig,
|
|
727
|
+
hash: await fileHash(file),
|
|
728
|
+
decided
|
|
729
|
+
};
|
|
730
|
+
await Bun.write(logPath, (await Bun.file(logPath).exists() ? await Bun.file(logPath).text() : "") + JSON.stringify(record) + `
|
|
731
|
+
`);
|
|
732
|
+
console.log(`✓ recorded: ${file} [${verdict}]${rationale ? ` — ${rationale}` : ""}`);
|
|
733
|
+
}
|
|
734
|
+
if (false) {}
|
|
735
|
+
|
|
736
|
+
// tools/mutate/src/init.ts
|
|
737
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
738
|
+
import { join as join3 } from "node:path";
|
|
739
|
+
function detectMutate(projectDir) {
|
|
740
|
+
if (existsSync4(join3(projectDir, "src"))) {
|
|
741
|
+
return ["src/**/*.ts", "!src/**/*.test.ts", "!src/**/*.d.ts"];
|
|
742
|
+
}
|
|
743
|
+
return ["**/*.ts", "!**/*.test.ts", "!**/*.d.ts", "!dist/**", `!${MUTATION_CONFIG_FILE}`];
|
|
744
|
+
}
|
|
745
|
+
function detectTestGlob(projectDir) {
|
|
746
|
+
for (const d of ["tests", "test"]) {
|
|
747
|
+
if (existsSync4(join3(projectDir, d)))
|
|
748
|
+
return `${d}/**/*.test.ts`;
|
|
749
|
+
}
|
|
750
|
+
return "**/*.test.ts";
|
|
751
|
+
}
|
|
752
|
+
async function initConfig(projectDir, opts = {}) {
|
|
753
|
+
const configPath = join3(projectDir, MUTATION_CONFIG_FILE);
|
|
754
|
+
const warnings = [];
|
|
755
|
+
const specifier = defineImportSpecifier(projectDir);
|
|
756
|
+
if (specifier === "@fairfox/polly/mutate") {
|
|
757
|
+
try {
|
|
758
|
+
Bun.resolveSync(specifier, projectDir);
|
|
759
|
+
} catch {
|
|
760
|
+
warnings.push("@fairfox/polly does not resolve here — install it, or the config cannot load");
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const mutate = detectMutate(projectDir);
|
|
764
|
+
const testGlob = detectTestGlob(projectDir);
|
|
765
|
+
if (!existsSync4(join3(projectDir, "src"))) {
|
|
766
|
+
warnings.push("no src/ directory — adjust `targets` to point at your source");
|
|
767
|
+
}
|
|
768
|
+
if (!existsSync4(join3(projectDir, "tests")) && !existsSync4(join3(projectDir, "test"))) {
|
|
769
|
+
warnings.push(`no tests/ directory — using "${testGlob}"; adjust tests.include if needed`);
|
|
770
|
+
}
|
|
771
|
+
if (existsSync4(configPath) && !opts.force) {
|
|
772
|
+
return { configPath, created: false, specifier, mutate, testGlob, warnings };
|
|
773
|
+
}
|
|
774
|
+
const config = { targets: mutate, tests: { include: [testGlob] } };
|
|
775
|
+
await Bun.write(configPath, renderMutationConfig(config, specifier));
|
|
776
|
+
return { configPath, created: true, specifier, mutate, testGlob, warnings };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// tools/mutate/src/run.ts
|
|
780
|
+
import { relative as relative7 } from "node:path";
|
|
781
|
+
|
|
782
|
+
// tools/mutate/src/engine/index.ts
|
|
783
|
+
import { realpathSync as realpathSync4 } from "node:fs";
|
|
784
|
+
import { join as join9, relative as relative6 } from "node:path";
|
|
785
|
+
|
|
786
|
+
// tools/mutate/src/engine/baseline.ts
|
|
787
|
+
import { relative as relative3 } from "node:path";
|
|
788
|
+
|
|
789
|
+
// tools/mutate/src/engine/junit.ts
|
|
790
|
+
import { realpathSync } from "node:fs";
|
|
791
|
+
import { relative as relative2, resolve as resolve3 } from "node:path";
|
|
792
|
+
var ENTITIES = {
|
|
793
|
+
""": '"',
|
|
794
|
+
"'": "'",
|
|
795
|
+
"<": "<",
|
|
796
|
+
">": ">",
|
|
797
|
+
"&": "&"
|
|
798
|
+
};
|
|
799
|
+
function decode(s) {
|
|
800
|
+
return s.replace(/&(?:quot|apos|lt|gt|amp|#(\d+)|#x([0-9a-f]+));/gi, (m, dec, hex) => {
|
|
801
|
+
if (dec)
|
|
802
|
+
return String.fromCodePoint(Number(dec));
|
|
803
|
+
if (hex)
|
|
804
|
+
return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
805
|
+
return ENTITIES[m] ?? m;
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
function attr(attrs, key) {
|
|
809
|
+
const m = new RegExp(`\\b${key}="([^"]*)"`).exec(attrs);
|
|
810
|
+
return m?.[1] === undefined ? "" : decode(m[1]);
|
|
811
|
+
}
|
|
812
|
+
function caseStatus(body) {
|
|
813
|
+
if (/<(?:failure|error)\b/.test(body))
|
|
814
|
+
return "failed";
|
|
815
|
+
return /<skipped\b/.test(body) ? "skipped" : "passed";
|
|
816
|
+
}
|
|
817
|
+
var FAILURE = /<(?:failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/(?:failure|error)>)/;
|
|
818
|
+
function failureMessage(body) {
|
|
819
|
+
const m = FAILURE.exec(body);
|
|
820
|
+
if (!m)
|
|
821
|
+
return;
|
|
822
|
+
const parts = [attr(m[1] ?? "", "message"), decode(m[2] ?? "")].filter((s) => s.length > 0);
|
|
823
|
+
return parts.join(`
|
|
824
|
+
`);
|
|
825
|
+
}
|
|
826
|
+
var TAG = /<testsuite\b([^>]*?)(\/?)>|<\/testsuite>|<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
827
|
+
function parseJunit(xml, cwd) {
|
|
828
|
+
const cases = [];
|
|
829
|
+
const suites = [];
|
|
830
|
+
for (const [whole, suiteAttrs, selfClosing, caseAttrs, body] of xml.matchAll(TAG)) {
|
|
831
|
+
if (whole.startsWith("</testsuite"))
|
|
832
|
+
suites.pop();
|
|
833
|
+
else if (suiteAttrs !== undefined && selfClosing !== "/")
|
|
834
|
+
suites.push(attr(suiteAttrs, "name"));
|
|
835
|
+
else if (caseAttrs !== undefined)
|
|
836
|
+
cases.push(caseFrom(suites, caseAttrs, body ?? "", cwd));
|
|
837
|
+
}
|
|
838
|
+
return cases;
|
|
839
|
+
}
|
|
840
|
+
function caseFrom(suites, caseAttrs, body, cwd) {
|
|
841
|
+
const path = [...suites.slice(1), attr(caseAttrs, "name")];
|
|
842
|
+
const status = caseStatus(body);
|
|
843
|
+
const message = status === "failed" ? failureMessage(body) : undefined;
|
|
844
|
+
return {
|
|
845
|
+
file: resolve3(cwd, attr(caseAttrs, "file")),
|
|
846
|
+
name: path.join(" > "),
|
|
847
|
+
filterName: path.join(" "),
|
|
848
|
+
line: Number(attr(caseAttrs, "line")) || 0,
|
|
849
|
+
status,
|
|
850
|
+
...message === undefined ? {} : { failureMessage: message }
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
function testId(projectCwd, file, name, line) {
|
|
854
|
+
return `${relative2(projectCwd, file)}#${name}@${line}`;
|
|
855
|
+
}
|
|
856
|
+
var CONTRACT_MARKER = /\[polly-contract (requires|ensures) (.+?):(\d+)\]/g;
|
|
857
|
+
function realOr(path) {
|
|
858
|
+
try {
|
|
859
|
+
return realpathSync(path);
|
|
860
|
+
} catch {
|
|
861
|
+
return path;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
function contractsIn(text, projectCwd) {
|
|
865
|
+
const ids = new Set;
|
|
866
|
+
for (const m of text.matchAll(CONTRACT_MARKER)) {
|
|
867
|
+
if (m[2] && m[3])
|
|
868
|
+
ids.add(`${relative2(projectCwd, realOr(m[2]))}:${m[3]}`);
|
|
869
|
+
}
|
|
870
|
+
return [...ids].sort();
|
|
871
|
+
}
|
|
872
|
+
function contractKills(cases, projectCwd) {
|
|
873
|
+
const out = [];
|
|
874
|
+
for (const c of cases) {
|
|
875
|
+
if (c.status !== "failed" || c.failureMessage === undefined)
|
|
876
|
+
continue;
|
|
877
|
+
const contracts = contractsIn(c.failureMessage, projectCwd);
|
|
878
|
+
if (contracts.length > 0) {
|
|
879
|
+
out.push({ test: testId(projectCwd, c.file, c.name, c.line), contracts });
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return out;
|
|
883
|
+
}
|
|
884
|
+
var LOAD_FAILURE_NAME = "(file produced no results)";
|
|
885
|
+
function loadFailureId(projectCwd, file) {
|
|
886
|
+
return testId(projectCwd, file, LOAD_FAILURE_NAME, 0);
|
|
887
|
+
}
|
|
888
|
+
function killers(cases, baselineFiles, projectCwd) {
|
|
889
|
+
const ids = cases.filter((c) => c.status === "failed").map((c) => testId(projectCwd, c.file, c.name, c.line));
|
|
890
|
+
const reported = new Set(cases.map((c) => c.file));
|
|
891
|
+
for (const f of [...baselineFiles].sort()) {
|
|
892
|
+
if (!reported.has(f))
|
|
893
|
+
ids.push(loadFailureId(projectCwd, f));
|
|
894
|
+
}
|
|
895
|
+
return ids;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// tools/mutate/src/engine/runner.ts
|
|
899
|
+
import { randomUUID } from "node:crypto";
|
|
900
|
+
import { existsSync as existsSync5, rmSync } from "node:fs";
|
|
901
|
+
import { tmpdir } from "node:os";
|
|
902
|
+
import { join as join4 } from "node:path";
|
|
903
|
+
function preloadPath() {
|
|
904
|
+
const candidates = [
|
|
905
|
+
`${import.meta.dir}/preload.js`,
|
|
906
|
+
`${import.meta.dir}/engine/preload.js`,
|
|
907
|
+
`${import.meta.dir}/preload.ts`
|
|
908
|
+
];
|
|
909
|
+
const found = candidates.find((c) => existsSync5(c));
|
|
910
|
+
if (!found)
|
|
911
|
+
throw new Error(`polly mutate: cannot find the engine preload (looked in ${import.meta.dir})`);
|
|
912
|
+
return found;
|
|
913
|
+
}
|
|
914
|
+
function cleanEnv(extra) {
|
|
915
|
+
const env = {};
|
|
916
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
917
|
+
if (v !== undefined && !k.startsWith("POLLY_MUTATE_") && k !== "POLLY_CONTRACTS")
|
|
918
|
+
env[k] = v;
|
|
919
|
+
}
|
|
920
|
+
return { ...env, ...extra };
|
|
921
|
+
}
|
|
922
|
+
async function readJunit(path) {
|
|
923
|
+
if (!path)
|
|
924
|
+
return;
|
|
925
|
+
const file = Bun.file(path);
|
|
926
|
+
if (!await file.exists())
|
|
927
|
+
return;
|
|
928
|
+
const xml = await file.text();
|
|
929
|
+
rmSync(path, { force: true });
|
|
930
|
+
return xml;
|
|
931
|
+
}
|
|
932
|
+
function processArgv(runner, run, junitPath) {
|
|
933
|
+
const env = { ...run.env, POLLY_CONTRACTS: run.contracts ?? "off" };
|
|
934
|
+
if (runner.kind === "command") {
|
|
935
|
+
const inherited = process.env["BUN_OPTIONS"];
|
|
936
|
+
const preloads = run.preloads.map((p) => `--preload=${p}`).join(" ");
|
|
937
|
+
return {
|
|
938
|
+
argv: runner.argv,
|
|
939
|
+
env: { ...env, BUN_OPTIONS: inherited ? `${inherited} ${preloads}` : preloads }
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
return {
|
|
943
|
+
argv: [
|
|
944
|
+
"bun",
|
|
945
|
+
"test",
|
|
946
|
+
...run.preloads.flatMap((p) => ["--preload", p]),
|
|
947
|
+
...run.bail ? ["--bail"] : [],
|
|
948
|
+
...run.namePattern ? ["-t", run.namePattern] : [],
|
|
949
|
+
...junitPath ? ["--reporter=junit", `--reporter-outfile=${junitPath}`] : [],
|
|
950
|
+
...run.files
|
|
951
|
+
],
|
|
952
|
+
env
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
async function runBunTest(run, runner = { kind: "bun-test" }) {
|
|
956
|
+
const started = performance.now();
|
|
957
|
+
const junitPath = run.junit && runner.kind === "bun-test" ? join4(tmpdir(), `polly-mutate-junit-${randomUUID()}.xml`) : undefined;
|
|
958
|
+
const { argv, env } = processArgv(runner, run, junitPath);
|
|
959
|
+
const group = runner.kind === "command";
|
|
960
|
+
const proc = Bun.spawn(argv, {
|
|
961
|
+
cwd: run.cwd,
|
|
962
|
+
detached: group,
|
|
963
|
+
env: cleanEnv(env),
|
|
964
|
+
stdin: "ignore",
|
|
965
|
+
stdout: "ignore",
|
|
966
|
+
stderr: run.captureStderr ? "pipe" : "ignore"
|
|
967
|
+
});
|
|
968
|
+
let timedOut = false;
|
|
969
|
+
const timer = setTimeout(() => {
|
|
970
|
+
timedOut = true;
|
|
971
|
+
if (group)
|
|
972
|
+
process.kill(-proc.pid, 9);
|
|
973
|
+
else
|
|
974
|
+
proc.kill(9);
|
|
975
|
+
}, run.timeoutMs);
|
|
976
|
+
const stderrText = run.captureStderr && proc.stderr ? new Response(proc.stderr).text() : undefined;
|
|
977
|
+
const exitCode = await proc.exited;
|
|
978
|
+
clearTimeout(timer);
|
|
979
|
+
const ms = performance.now() - started;
|
|
980
|
+
const junitXml = await readJunit(junitPath);
|
|
981
|
+
const outcome = { exitCode, timedOut, ms, ...junitXml ? { junitXml } : {} };
|
|
982
|
+
if (stderrText === undefined)
|
|
983
|
+
return outcome;
|
|
984
|
+
return { ...outcome, stderrTail: (await stderrText).slice(-2000) };
|
|
985
|
+
}
|
|
986
|
+
async function pool(items, concurrency, fn) {
|
|
987
|
+
const results = new Array(items.length);
|
|
988
|
+
let next = 0;
|
|
989
|
+
const worker = async () => {
|
|
990
|
+
while (next < items.length) {
|
|
991
|
+
const i = next++;
|
|
992
|
+
const item = items[i];
|
|
993
|
+
if (item !== undefined)
|
|
994
|
+
results[i] = await fn(item, i);
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, worker));
|
|
998
|
+
return results;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// tools/mutate/src/engine/workdir.ts
|
|
1002
|
+
import { existsSync as existsSync6, mkdirSync, mkdtempSync, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync } from "node:fs";
|
|
1003
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
1004
|
+
import { join as join5 } from "node:path";
|
|
1005
|
+
function isRecord2(v) {
|
|
1006
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1007
|
+
}
|
|
1008
|
+
function absolutePreload(spec, projectCwd) {
|
|
1009
|
+
if (typeof spec !== "string")
|
|
1010
|
+
return spec;
|
|
1011
|
+
try {
|
|
1012
|
+
return Bun.resolveSync(spec, projectCwd);
|
|
1013
|
+
} catch {
|
|
1014
|
+
return spec;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
function absolutiseBunfig(config, projectCwd) {
|
|
1018
|
+
const fix = (v) => Array.isArray(v) ? v.map((s) => absolutePreload(s, projectCwd)) : absolutePreload(v, projectCwd);
|
|
1019
|
+
const out = { ...config };
|
|
1020
|
+
if ("preload" in out)
|
|
1021
|
+
out["preload"] = fix(out["preload"]);
|
|
1022
|
+
const test = out["test"];
|
|
1023
|
+
if (isRecord2(test) && "preload" in test)
|
|
1024
|
+
out["test"] = { ...test, preload: fix(test["preload"]) };
|
|
1025
|
+
return out;
|
|
1026
|
+
}
|
|
1027
|
+
var tomlKey = (k) => /^[A-Za-z0-9_-]+$/.test(k) ? k : JSON.stringify(k);
|
|
1028
|
+
function tomlValue(v) {
|
|
1029
|
+
if (Array.isArray(v))
|
|
1030
|
+
return `[${v.map(tomlValue).join(", ")}]`;
|
|
1031
|
+
if (isRecord2(v)) {
|
|
1032
|
+
return `{ ${Object.entries(v).map(([k, x]) => `${tomlKey(k)} = ${tomlValue(x)}`).join(", ")} }`;
|
|
1033
|
+
}
|
|
1034
|
+
if (typeof v === "string")
|
|
1035
|
+
return JSON.stringify(v);
|
|
1036
|
+
return String(v);
|
|
1037
|
+
}
|
|
1038
|
+
function renderToml(config, prefix = []) {
|
|
1039
|
+
const lines = [];
|
|
1040
|
+
const tables = [];
|
|
1041
|
+
for (const [k, v] of Object.entries(config)) {
|
|
1042
|
+
if (isRecord2(v))
|
|
1043
|
+
tables.push([k, v]);
|
|
1044
|
+
else
|
|
1045
|
+
lines.push(`${tomlKey(k)} = ${tomlValue(v)}`);
|
|
1046
|
+
}
|
|
1047
|
+
const sections = tables.map(([k, v]) => {
|
|
1048
|
+
const path = [...prefix, k];
|
|
1049
|
+
return `[${path.map(tomlKey).join(".")}]
|
|
1050
|
+
${renderToml(v, path)}`;
|
|
1051
|
+
});
|
|
1052
|
+
return [lines.join(`
|
|
1053
|
+
`), ...sections].filter(Boolean).join(`
|
|
1054
|
+
|
|
1055
|
+
`);
|
|
1056
|
+
}
|
|
1057
|
+
function createWorkdirs(projectCwd) {
|
|
1058
|
+
const root = mkdtempSync(join5(tmpdir2(), "polly-mutate-"));
|
|
1059
|
+
const files = {};
|
|
1060
|
+
const tsconfig = join5(projectCwd, "tsconfig.json");
|
|
1061
|
+
if (existsSync6(tsconfig))
|
|
1062
|
+
files["tsconfig.json"] = JSON.stringify({ extends: tsconfig });
|
|
1063
|
+
const bunfig = join5(projectCwd, "bunfig.toml");
|
|
1064
|
+
if (existsSync6(bunfig)) {
|
|
1065
|
+
const parsed = Bun.TOML.parse(readFileSync2(bunfig, "utf8"));
|
|
1066
|
+
if (isRecord2(parsed))
|
|
1067
|
+
files["bunfig.toml"] = renderToml(absolutiseBunfig(parsed, projectCwd));
|
|
1068
|
+
}
|
|
1069
|
+
return {
|
|
1070
|
+
root,
|
|
1071
|
+
create() {
|
|
1072
|
+
mkdirSync(root, { recursive: true });
|
|
1073
|
+
const dir = mkdtempSync(join5(root, "run-"));
|
|
1074
|
+
for (const [name, text] of Object.entries(files))
|
|
1075
|
+
writeFileSync(join5(dir, name), text);
|
|
1076
|
+
return dir;
|
|
1077
|
+
},
|
|
1078
|
+
release: (dir) => rmSync2(dir, { recursive: true, force: true }),
|
|
1079
|
+
remove: () => rmSync2(root, { recursive: true, force: true })
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
async function inCwd(workdirs, projectCwd, fn) {
|
|
1083
|
+
if (!workdirs)
|
|
1084
|
+
return fn(projectCwd);
|
|
1085
|
+
const dir = workdirs.create();
|
|
1086
|
+
try {
|
|
1087
|
+
return await fn(dir);
|
|
1088
|
+
} finally {
|
|
1089
|
+
workdirs.release(dir);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// tools/mutate/src/engine/baseline.ts
|
|
1094
|
+
var BASELINE_ATTEMPTS = 3;
|
|
1095
|
+
var BASELINE_TIMEOUT_MS = 300000;
|
|
1096
|
+
function failed(reason, contractViolations) {
|
|
1097
|
+
return {
|
|
1098
|
+
ok: false,
|
|
1099
|
+
ms: 0,
|
|
1100
|
+
reason,
|
|
1101
|
+
tests: [],
|
|
1102
|
+
reportedFiles: new Set,
|
|
1103
|
+
...contractViolations ? { contractViolations } : {}
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
var COMMAND = "(command)";
|
|
1107
|
+
async function commandViolatesContract(cfg, cwd, preloads, files) {
|
|
1108
|
+
const off = await runBunTest({
|
|
1109
|
+
cwd,
|
|
1110
|
+
files,
|
|
1111
|
+
preloads,
|
|
1112
|
+
contracts: "off",
|
|
1113
|
+
bail: false,
|
|
1114
|
+
timeoutMs: BASELINE_TIMEOUT_MS,
|
|
1115
|
+
captureStderr: true
|
|
1116
|
+
}, cfg.runner);
|
|
1117
|
+
return off.exitCode === 0 && !off.timedOut;
|
|
1118
|
+
}
|
|
1119
|
+
function green(cfg, cwd, outcome) {
|
|
1120
|
+
if (cfg.mode === "fast")
|
|
1121
|
+
return { ok: true, ms: outcome.ms, tests: [], reportedFiles: new Set };
|
|
1122
|
+
const cases = parseJunit(outcome.junitXml ?? "", cwd);
|
|
1123
|
+
if (cases.length === 0) {
|
|
1124
|
+
return failed("baseline passed but its junit report holds no test case (reporter broke?)");
|
|
1125
|
+
}
|
|
1126
|
+
return {
|
|
1127
|
+
ok: true,
|
|
1128
|
+
ms: outcome.ms,
|
|
1129
|
+
tests: cases.map((c) => ({
|
|
1130
|
+
id: testId(cfg.cwd, c.file, c.name, c.line),
|
|
1131
|
+
file: c.file,
|
|
1132
|
+
name: c.name,
|
|
1133
|
+
line: c.line
|
|
1134
|
+
})),
|
|
1135
|
+
reportedFiles: new Set(cases.map((c) => c.file))
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
async function measureIn(cfg, cwd, preloads, files) {
|
|
1139
|
+
let last = "";
|
|
1140
|
+
for (let attempt = 1;attempt <= BASELINE_ATTEMPTS; attempt++) {
|
|
1141
|
+
const outcome = await runBunTest({
|
|
1142
|
+
cwd,
|
|
1143
|
+
files,
|
|
1144
|
+
preloads,
|
|
1145
|
+
contracts: cfg.contracts,
|
|
1146
|
+
bail: false,
|
|
1147
|
+
timeoutMs: BASELINE_TIMEOUT_MS,
|
|
1148
|
+
captureStderr: true,
|
|
1149
|
+
junit: cfg.mode === "matrix" || cfg.contracts !== "off" && cfg.runner.kind === "bun-test"
|
|
1150
|
+
}, cfg.runner);
|
|
1151
|
+
if (outcome.exitCode === 0 && !outcome.timedOut)
|
|
1152
|
+
return green(cfg, cwd, outcome);
|
|
1153
|
+
last = outcome.timedOut ? `timed out after ${BASELINE_TIMEOUT_MS} ms` : `exit ${outcome.exitCode}`;
|
|
1154
|
+
const failLine = outcome.stderrTail?.match(/\(fail\)[^\n]*/)?.[0];
|
|
1155
|
+
if (failLine)
|
|
1156
|
+
last += `: ${failLine}`;
|
|
1157
|
+
if (cfg.contracts === "off" || outcome.timedOut)
|
|
1158
|
+
continue;
|
|
1159
|
+
const contract = await contractFailure(cfg, cwd, preloads, files, outcome.junitXml, last);
|
|
1160
|
+
if (contract)
|
|
1161
|
+
return contract;
|
|
1162
|
+
}
|
|
1163
|
+
return failed(`baseline red ${BASELINE_ATTEMPTS}× on the selected tests (${last})`);
|
|
1164
|
+
}
|
|
1165
|
+
async function contractFailure(cfg, cwd, preloads, files, junitXml, last) {
|
|
1166
|
+
if (cfg.runner.kind === "command") {
|
|
1167
|
+
if (!await commandViolatesContract(cfg, cwd, preloads, files))
|
|
1168
|
+
return null;
|
|
1169
|
+
return failed(`the command fails only with contracts on (POLLY_CONTRACTS=${cfg.contracts}): a scenario violates a contract`, [{ test: COMMAND, contracts: [] }]);
|
|
1170
|
+
}
|
|
1171
|
+
const cases = parseJunit(junitXml ?? "", cwd);
|
|
1172
|
+
const violations = contractKills(cases, cfg.cwd);
|
|
1173
|
+
if (violations.length === 0)
|
|
1174
|
+
return null;
|
|
1175
|
+
const red = cases.filter((c) => c.status === "failed").length;
|
|
1176
|
+
return failed(violations.length === red ? `${violations.length} test(s) violate contracts` : `baseline red (${last}) and ${violations.length} of ${red} failing test(s) violate contracts`, violations);
|
|
1177
|
+
}
|
|
1178
|
+
async function measureBaseline(cfg, workdirs, preloads, target, log) {
|
|
1179
|
+
const scratch = await inCwd(workdirs, cfg.cwd, (cwd) => measureIn(cfg, cwd, preloads, target.covering));
|
|
1180
|
+
if (scratch.ok || !workdirs || scratch.contractViolations) {
|
|
1181
|
+
return { ...scratch, inTree: !workdirs };
|
|
1182
|
+
}
|
|
1183
|
+
const inTree = await measureIn(cfg, cfg.cwd, preloads, target.covering);
|
|
1184
|
+
if (inTree.ok) {
|
|
1185
|
+
log(`${relative3(cfg.cwd, target.file)}: tests fail outside ${cfg.cwd} (${scratch.reason}); its mutants run in-tree`);
|
|
1186
|
+
}
|
|
1187
|
+
return { ...inTree, inTree: true };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// tools/mutate/src/engine/cache.ts
|
|
1191
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1192
|
+
import { join as join6 } from "node:path";
|
|
1193
|
+
var CACHE_VERSION = 2;
|
|
1194
|
+
function mutantSig(m) {
|
|
1195
|
+
return `${m.mutatorName}:${m.start}:${m.end}:${m.replacement}`;
|
|
1196
|
+
}
|
|
1197
|
+
async function textOrEmpty(path) {
|
|
1198
|
+
const file = Bun.file(path);
|
|
1199
|
+
return await file.exists() ? file.text() : "";
|
|
1200
|
+
}
|
|
1201
|
+
async function cacheKey(input) {
|
|
1202
|
+
const hash = new Bun.CryptoHasher("sha256");
|
|
1203
|
+
hash.update(JSON.stringify({ v: CACHE_VERSION, bun: Bun.version, settings: input.settings }));
|
|
1204
|
+
for (const m of input.mutants)
|
|
1205
|
+
hash.update(`${mutantSig(m)}|${m.ignoredReason ?? ""}
|
|
1206
|
+
`);
|
|
1207
|
+
for (const f of [...input.closure, ...input.projectFiles]) {
|
|
1208
|
+
hash.update(`\x00${f}\x00`);
|
|
1209
|
+
hash.update(await textOrEmpty(f));
|
|
1210
|
+
}
|
|
1211
|
+
return hash.digest("hex");
|
|
1212
|
+
}
|
|
1213
|
+
async function readCache(dir, key) {
|
|
1214
|
+
const file = Bun.file(join6(dir, `${key}.json`));
|
|
1215
|
+
if (!await file.exists())
|
|
1216
|
+
return null;
|
|
1217
|
+
try {
|
|
1218
|
+
const entry = await file.json();
|
|
1219
|
+
return Array.isArray(entry.results) ? entry : null;
|
|
1220
|
+
} catch {
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
function writeEntry(dir, name, value) {
|
|
1225
|
+
mkdirSync2(dir, { recursive: true });
|
|
1226
|
+
const ignore = join6(dir, ".gitignore");
|
|
1227
|
+
if (!existsSync7(ignore))
|
|
1228
|
+
writeFileSync2(ignore, `*
|
|
1229
|
+
`);
|
|
1230
|
+
writeFileSync2(join6(dir, name), `${JSON.stringify(value)}
|
|
1231
|
+
`);
|
|
1232
|
+
}
|
|
1233
|
+
function writeCache(dir, key, entry) {
|
|
1234
|
+
writeEntry(dir, `${key}.json`, entry);
|
|
1235
|
+
}
|
|
1236
|
+
function writeCoverageCache(dir, key, coverage) {
|
|
1237
|
+
writeEntry(dir, `coverage-${key}.json`, coverage);
|
|
1238
|
+
}
|
|
1239
|
+
async function readCoverageCache(dir, key) {
|
|
1240
|
+
const file = Bun.file(join6(dir, `coverage-${key}.json`));
|
|
1241
|
+
if (!await file.exists())
|
|
1242
|
+
return null;
|
|
1243
|
+
try {
|
|
1244
|
+
const coverage = await file.json();
|
|
1245
|
+
return Array.isArray(coverage.targets) ? coverage : null;
|
|
1246
|
+
} catch {
|
|
1247
|
+
return null;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
function cachedResult(r) {
|
|
1251
|
+
return {
|
|
1252
|
+
sig: mutantSig(r.mutant),
|
|
1253
|
+
status: r.status,
|
|
1254
|
+
...r.statusReason ? { statusReason: r.statusReason } : {},
|
|
1255
|
+
...r.durationMs === undefined ? {} : { durationMs: r.durationMs },
|
|
1256
|
+
...r.killedBy ? { killedBy: r.killedBy } : {},
|
|
1257
|
+
...r.coveredBy ? { coveredBy: r.coveredBy } : {},
|
|
1258
|
+
...r.killedByContract ? { killedByContract: r.killedByContract } : {},
|
|
1259
|
+
...r.contractOnly ? { contractOnly: true } : {}
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// tools/mutate/src/engine/coverage.ts
|
|
1264
|
+
import { mkdtempSync as mkdtempSync2, realpathSync as realpathSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1265
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
1266
|
+
import { join as join7 } from "node:path";
|
|
1267
|
+
|
|
1268
|
+
// tools/mutate/src/engine/probes.ts
|
|
1269
|
+
import { parseSync } from "oxc-parser";
|
|
1270
|
+
|
|
1271
|
+
// tools/mutate/src/engine/ast.ts
|
|
1272
|
+
function isAstNode(value) {
|
|
1273
|
+
return isRecord(value) && typeof value["type"] === "string" && typeof value["start"] === "number" && typeof value["end"] === "number";
|
|
1274
|
+
}
|
|
1275
|
+
function child(node, key) {
|
|
1276
|
+
const value = node[key];
|
|
1277
|
+
return isAstNode(value) ? value : undefined;
|
|
1278
|
+
}
|
|
1279
|
+
function children(node, key) {
|
|
1280
|
+
const value = node[key];
|
|
1281
|
+
return Array.isArray(value) ? value.filter(isAstNode) : [];
|
|
1282
|
+
}
|
|
1283
|
+
function stringField(node, key) {
|
|
1284
|
+
const value = node[key];
|
|
1285
|
+
return typeof value === "string" ? value : undefined;
|
|
1286
|
+
}
|
|
1287
|
+
function calleeName(call) {
|
|
1288
|
+
const callee = child(call, "callee");
|
|
1289
|
+
if (!callee)
|
|
1290
|
+
return;
|
|
1291
|
+
if (callee.type === "Identifier")
|
|
1292
|
+
return stringField(callee, "name");
|
|
1293
|
+
if (callee.type === "MemberExpression" && callee["computed"] !== true) {
|
|
1294
|
+
const property = child(callee, "property");
|
|
1295
|
+
if (property?.type === "Identifier")
|
|
1296
|
+
return stringField(property, "name");
|
|
1297
|
+
}
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// tools/mutate/src/engine/probes.ts
|
|
1302
|
+
var HIT = "globalThis.__pollyMutateHit?.";
|
|
1303
|
+
var PARSE_OPTIONS = { preserveParens: false };
|
|
1304
|
+
var LIST_PARENTS = new Set(["Program", "BlockStatement", "StaticBlock", "TSModuleBlock"]);
|
|
1305
|
+
var LONE_PARENTS = {
|
|
1306
|
+
IfStatement: ["consequent", "alternate"],
|
|
1307
|
+
ForStatement: ["body"],
|
|
1308
|
+
ForInStatement: ["body"],
|
|
1309
|
+
ForOfStatement: ["body"],
|
|
1310
|
+
WhileStatement: ["body"],
|
|
1311
|
+
DoWhileStatement: ["body"],
|
|
1312
|
+
WithStatement: ["body"]
|
|
1313
|
+
};
|
|
1314
|
+
var ANONYMOUS = new Set(["FunctionExpression", "ArrowFunctionExpression", "ClassExpression"]);
|
|
1315
|
+
function isDirective(node) {
|
|
1316
|
+
return node?.type === "ExpressionStatement" && typeof node["directive"] === "string";
|
|
1317
|
+
}
|
|
1318
|
+
function afterDirectives(statements, from) {
|
|
1319
|
+
let at = from;
|
|
1320
|
+
for (const s of statements) {
|
|
1321
|
+
if (!isDirective(s))
|
|
1322
|
+
break;
|
|
1323
|
+
at = s.end;
|
|
1324
|
+
}
|
|
1325
|
+
return at;
|
|
1326
|
+
}
|
|
1327
|
+
function inList(parent, key) {
|
|
1328
|
+
return LIST_PARENTS.has(parent.type) && key === "body" || parent.type === "SwitchCase" && key === "consequent";
|
|
1329
|
+
}
|
|
1330
|
+
function isLone(node, parent, key) {
|
|
1331
|
+
return (LONE_PARENTS[parent.type]?.includes(key) ?? false) && node.type !== "BlockStatement" && !node.type.endsWith("Declaration");
|
|
1332
|
+
}
|
|
1333
|
+
function isWrappedValue(node, parent, key) {
|
|
1334
|
+
const value = parent.type === "ArrowFunctionExpression" && key === "body" && parent["expression"] === true || (parent.type === "PropertyDefinition" || parent.type === "AccessorProperty") && key === "value" || parent.type === "AssignmentPattern" && key === "right";
|
|
1335
|
+
const named = parent.type !== "ArrowFunctionExpression" && ANONYMOUS.has(node.type);
|
|
1336
|
+
return value && !named;
|
|
1337
|
+
}
|
|
1338
|
+
function sitesOf(node, parent, key) {
|
|
1339
|
+
const sites = [];
|
|
1340
|
+
const span = { start: node.start, end: node.end };
|
|
1341
|
+
if (node.type === "BlockStatement" || node.type === "StaticBlock") {
|
|
1342
|
+
sites.push({
|
|
1343
|
+
kind: "block",
|
|
1344
|
+
...span,
|
|
1345
|
+
at: afterDirectives(children(node, "body"), node.start + 1)
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
if (!parent)
|
|
1349
|
+
return sites;
|
|
1350
|
+
if (inList(parent, key)) {
|
|
1351
|
+
sites.push({ kind: "statement", ...span, at: isDirective(node) ? node.end : node.start });
|
|
1352
|
+
} else if (isLone(node, parent, key)) {
|
|
1353
|
+
sites.push({ kind: "lone", ...span, at: node.start });
|
|
1354
|
+
} else if (isWrappedValue(node, parent, key)) {
|
|
1355
|
+
sites.push({ kind: "expression", ...span, at: node.start });
|
|
1356
|
+
}
|
|
1357
|
+
return sites;
|
|
1358
|
+
}
|
|
1359
|
+
function collectSites(program) {
|
|
1360
|
+
const sites = [];
|
|
1361
|
+
const visit = (node, parent, key) => {
|
|
1362
|
+
sites.push(...sitesOf(node, parent, key));
|
|
1363
|
+
for (const [k, value] of Object.entries(node)) {
|
|
1364
|
+
const list = Array.isArray(value) ? value : [value];
|
|
1365
|
+
for (const c of list)
|
|
1366
|
+
if (isAstNode(c))
|
|
1367
|
+
visit(c, node, k);
|
|
1368
|
+
}
|
|
1369
|
+
};
|
|
1370
|
+
for (const s of children(program, "body"))
|
|
1371
|
+
visit(s, program, "body");
|
|
1372
|
+
return sites;
|
|
1373
|
+
}
|
|
1374
|
+
function innermost(sites, start, end) {
|
|
1375
|
+
let best = null;
|
|
1376
|
+
for (const s of sites) {
|
|
1377
|
+
if (s.start > start || s.end < end)
|
|
1378
|
+
continue;
|
|
1379
|
+
if (!best || s.end - s.start < best.end - best.start)
|
|
1380
|
+
best = s;
|
|
1381
|
+
else if (s.end - s.start === best.end - best.start && s.kind === "block")
|
|
1382
|
+
best = s;
|
|
1383
|
+
}
|
|
1384
|
+
return best;
|
|
1385
|
+
}
|
|
1386
|
+
function edits(site, probe) {
|
|
1387
|
+
const hit = `${HIT}(${probe})`;
|
|
1388
|
+
const open = (text) => ({ at: site.at, text, order: [1, -site.end] });
|
|
1389
|
+
const close = (text) => ({ at: site.end, text, order: [0, -site.start] });
|
|
1390
|
+
switch (site.kind) {
|
|
1391
|
+
case "statement":
|
|
1392
|
+
case "block":
|
|
1393
|
+
return [open(`;${hit};`)];
|
|
1394
|
+
case "lone":
|
|
1395
|
+
return [open(`{;${hit};`), close("}")];
|
|
1396
|
+
case "expression":
|
|
1397
|
+
return [open(`(${hit},`), close(")")];
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
function applyEdits(src, all) {
|
|
1401
|
+
all.sort((a, b) => a.at - b.at || a.order[0] - b.order[0] || a.order[1] - b.order[1]);
|
|
1402
|
+
let out = "";
|
|
1403
|
+
let from = 0;
|
|
1404
|
+
for (const e of all) {
|
|
1405
|
+
out += src.slice(from, e.at) + e.text;
|
|
1406
|
+
from = e.at;
|
|
1407
|
+
}
|
|
1408
|
+
return out + src.slice(from);
|
|
1409
|
+
}
|
|
1410
|
+
function instrument(file, src, mutants, firstProbe) {
|
|
1411
|
+
const parsed = parseSync(file, src, PARSE_OPTIONS);
|
|
1412
|
+
if (parsed.errors.length > 0 || !isAstNode(parsed.program))
|
|
1413
|
+
return null;
|
|
1414
|
+
const sites = collectSites(parsed.program);
|
|
1415
|
+
const bySite = new Map;
|
|
1416
|
+
for (const m of mutants) {
|
|
1417
|
+
const site = innermost(sites, m.start, m.end);
|
|
1418
|
+
if (!site)
|
|
1419
|
+
return null;
|
|
1420
|
+
bySite.set(site, [...bySite.get(site) ?? [], m.id]);
|
|
1421
|
+
}
|
|
1422
|
+
const probes = [];
|
|
1423
|
+
const all = [];
|
|
1424
|
+
for (const [site, ids] of bySite) {
|
|
1425
|
+
all.push(...edits(site, firstProbe + probes.length));
|
|
1426
|
+
probes.push(ids);
|
|
1427
|
+
}
|
|
1428
|
+
const code = applyEdits(src, all);
|
|
1429
|
+
if (parseSync(file, code, PARSE_OPTIONS).errors.length > 0)
|
|
1430
|
+
return null;
|
|
1431
|
+
return { code, probes };
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// tools/mutate/src/engine/coverage.ts
|
|
1435
|
+
var COVERAGE_VERSION = 1;
|
|
1436
|
+
var COVERAGE_TIMEOUT_MS = 600000;
|
|
1437
|
+
var MAX_PATTERN = 8000;
|
|
1438
|
+
var numbers = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "number") : [];
|
|
1439
|
+
function parseHits(raw) {
|
|
1440
|
+
if (!isRecord(raw) || !Array.isArray(raw["files"]))
|
|
1441
|
+
return null;
|
|
1442
|
+
return {
|
|
1443
|
+
served: numbers(raw["served"]),
|
|
1444
|
+
outside: numbers(raw["outside"]),
|
|
1445
|
+
files: raw["files"].filter(isRecord).map((f) => ({
|
|
1446
|
+
load: numbers(f["load"]),
|
|
1447
|
+
between: numbers(f["between"]),
|
|
1448
|
+
tests: Array.isArray(f["tests"]) ? f["tests"].map(numbers) : [],
|
|
1449
|
+
overlapped: f["overlapped"] === true
|
|
1450
|
+
}))
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
function real(path) {
|
|
1454
|
+
try {
|
|
1455
|
+
return realpathSync2(path);
|
|
1456
|
+
} catch {
|
|
1457
|
+
return path;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
function chunk(items, n) {
|
|
1461
|
+
const count = Math.max(1, Math.min(n, items.length));
|
|
1462
|
+
const size = Math.ceil(items.length / count);
|
|
1463
|
+
const out = [];
|
|
1464
|
+
for (let i = 0;i < items.length; i += size)
|
|
1465
|
+
out.push(items.slice(i, i + size));
|
|
1466
|
+
return out;
|
|
1467
|
+
}
|
|
1468
|
+
function newProbeHits() {
|
|
1469
|
+
return { tests: new Set, files: new Set, loads: new Set, static: false };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
class Attribution {
|
|
1473
|
+
cwd;
|
|
1474
|
+
probes = new Map;
|
|
1475
|
+
served = new Set;
|
|
1476
|
+
tests = {};
|
|
1477
|
+
byFile = {};
|
|
1478
|
+
constructor(cwd) {
|
|
1479
|
+
this.cwd = cwd;
|
|
1480
|
+
}
|
|
1481
|
+
at(probe) {
|
|
1482
|
+
let hits = this.probes.get(probe);
|
|
1483
|
+
if (!hits) {
|
|
1484
|
+
hits = newProbeHits();
|
|
1485
|
+
this.probes.set(probe, hits);
|
|
1486
|
+
}
|
|
1487
|
+
return hits;
|
|
1488
|
+
}
|
|
1489
|
+
record(c) {
|
|
1490
|
+
const id = testId(this.cwd, c.file, c.name, c.line);
|
|
1491
|
+
if (!this.tests[id]) {
|
|
1492
|
+
this.tests[id] = { id, file: c.file, name: c.name, line: c.line, filterName: c.filterName };
|
|
1493
|
+
const file = real(c.file);
|
|
1494
|
+
this.byFile[file] = [...this.byFile[file] ?? [], id];
|
|
1495
|
+
}
|
|
1496
|
+
return id;
|
|
1497
|
+
}
|
|
1498
|
+
add(testFiles, hits, cases) {
|
|
1499
|
+
for (const t of hits.served)
|
|
1500
|
+
this.served.add(t);
|
|
1501
|
+
for (const p of hits.outside)
|
|
1502
|
+
this.at(p).static = true;
|
|
1503
|
+
const ran = new Map;
|
|
1504
|
+
for (const c of cases) {
|
|
1505
|
+
if (c.status === "skipped")
|
|
1506
|
+
continue;
|
|
1507
|
+
const file = real(c.file);
|
|
1508
|
+
ran.set(file, [...ran.get(file) ?? [], c]);
|
|
1509
|
+
}
|
|
1510
|
+
hits.files.forEach((f, i) => {
|
|
1511
|
+
const file = testFiles[i];
|
|
1512
|
+
if (!file)
|
|
1513
|
+
return;
|
|
1514
|
+
const fileCases = ran.get(file) ?? [];
|
|
1515
|
+
const ids = fileCases.map((c) => this.record(c));
|
|
1516
|
+
for (const p of f.load) {
|
|
1517
|
+
const h = this.at(p);
|
|
1518
|
+
h.static = true;
|
|
1519
|
+
h.loads.add(file);
|
|
1520
|
+
}
|
|
1521
|
+
for (const p of f.between)
|
|
1522
|
+
this.at(p).files.add(file);
|
|
1523
|
+
const ordered = !f.overlapped && f.tests.length === ids.length;
|
|
1524
|
+
f.tests.forEach((probes, k) => {
|
|
1525
|
+
const id = ids[k];
|
|
1526
|
+
for (const p of probes) {
|
|
1527
|
+
if (ordered && id)
|
|
1528
|
+
this.at(p).tests.add(id);
|
|
1529
|
+
else
|
|
1530
|
+
this.at(p).files.add(file);
|
|
1531
|
+
}
|
|
1532
|
+
});
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
async function planTargets(run) {
|
|
1537
|
+
const plan = [];
|
|
1538
|
+
const probes = [];
|
|
1539
|
+
const failed = new Map;
|
|
1540
|
+
for (const [target, t] of run.targets.entries()) {
|
|
1541
|
+
const probed = instrument(t.file, await Bun.file(t.file).text(), t.mutants, probes.length);
|
|
1542
|
+
if (!probed) {
|
|
1543
|
+
failed.set(target, "its probed source does not parse");
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
plan.push({ file: t.file, code: probed.code });
|
|
1547
|
+
for (const ids of probed.probes)
|
|
1548
|
+
probes.push({ target, ids });
|
|
1549
|
+
}
|
|
1550
|
+
return { plan, probes, failed };
|
|
1551
|
+
}
|
|
1552
|
+
function sorted(set) {
|
|
1553
|
+
return [...new Set(set)].sort();
|
|
1554
|
+
}
|
|
1555
|
+
function usersOf(t, attribution, mine) {
|
|
1556
|
+
const users = new Set(t.covering);
|
|
1557
|
+
for (const { probe } of mine) {
|
|
1558
|
+
const h = attribution.probes.get(probe);
|
|
1559
|
+
if (!h)
|
|
1560
|
+
continue;
|
|
1561
|
+
const testFiles = [...h.tests].flatMap((id) => {
|
|
1562
|
+
const file = attribution.tests[id]?.file;
|
|
1563
|
+
return file ? [real(file)] : [];
|
|
1564
|
+
});
|
|
1565
|
+
for (const f of [...testFiles, ...h.files, ...h.loads])
|
|
1566
|
+
users.add(f);
|
|
1567
|
+
}
|
|
1568
|
+
return users;
|
|
1569
|
+
}
|
|
1570
|
+
function targetCoverage(run, target, attribution, probes) {
|
|
1571
|
+
const t = run.targets[target];
|
|
1572
|
+
if (!t)
|
|
1573
|
+
return { kind: "static", reason: "missing target" };
|
|
1574
|
+
const mine = probes.flatMap((p, probe) => p.target === target ? [{ probe, ids: p.ids }] : []);
|
|
1575
|
+
if (!attribution.served.has(target) && t.covering.length > 0) {
|
|
1576
|
+
return {
|
|
1577
|
+
kind: "static",
|
|
1578
|
+
reason: "a test imports it but the coverage pass never loaded it (a project preload may)"
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
const users = usersOf(t, attribution, mine);
|
|
1582
|
+
const mutants = {};
|
|
1583
|
+
for (const { probe, ids } of mine) {
|
|
1584
|
+
const h = attribution.probes.get(probe) ?? newProbeHits();
|
|
1585
|
+
const files = h.static ? [...users, ...h.files] : [...h.files];
|
|
1586
|
+
const cov = { tests: sorted(h.tests), files: sorted(files) };
|
|
1587
|
+
for (const id of ids)
|
|
1588
|
+
mutants[id] = cov;
|
|
1589
|
+
}
|
|
1590
|
+
return { kind: "dynamic", mutants };
|
|
1591
|
+
}
|
|
1592
|
+
async function collectCoverage(run) {
|
|
1593
|
+
const { plan, probes, failed } = await planTargets(run);
|
|
1594
|
+
const dir = mkdtempSync2(join7(tmpdir3(), "polly-mutate-coverage-"));
|
|
1595
|
+
try {
|
|
1596
|
+
const planPath = join7(dir, "plan.json");
|
|
1597
|
+
writeFileSync3(planPath, JSON.stringify({ targets: plan, tests: run.testFiles }));
|
|
1598
|
+
const attribution = new Attribution(run.cwd);
|
|
1599
|
+
const chunks = chunk(run.testFiles, run.concurrency);
|
|
1600
|
+
const outcomes = await pool(chunks, run.concurrency, async (files, i) => {
|
|
1601
|
+
const out = join7(dir, `hits-${i}.json`);
|
|
1602
|
+
const attempt = (workdirs) => inCwd(workdirs, run.cwd, async (cwd) => {
|
|
1603
|
+
rmSync3(out, { force: true });
|
|
1604
|
+
const outcome = await runBunTest({
|
|
1605
|
+
cwd,
|
|
1606
|
+
files,
|
|
1607
|
+
preloads: run.preloads,
|
|
1608
|
+
env: { POLLY_MUTATE_COVERAGE: planPath, POLLY_MUTATE_COVERAGE_OUT: out },
|
|
1609
|
+
contracts: run.contracts,
|
|
1610
|
+
bail: false,
|
|
1611
|
+
junit: true,
|
|
1612
|
+
timeoutMs: COVERAGE_TIMEOUT_MS,
|
|
1613
|
+
captureStderr: true
|
|
1614
|
+
});
|
|
1615
|
+
const file = Bun.file(out);
|
|
1616
|
+
const hits = await file.exists() ? parseHits(await file.json()) : null;
|
|
1617
|
+
return { outcome, hits, cwd };
|
|
1618
|
+
});
|
|
1619
|
+
const scratch = await attempt(run.workdirs);
|
|
1620
|
+
if (scratch.outcome.exitCode === 0 || !run.workdirs)
|
|
1621
|
+
return scratch;
|
|
1622
|
+
run.log(`coverage pass: process ${i + 1}/${chunks.length} is red in a scratch cwd; rerun in-tree`);
|
|
1623
|
+
return attempt(null);
|
|
1624
|
+
});
|
|
1625
|
+
for (const [i, { outcome, hits, cwd }] of outcomes.entries()) {
|
|
1626
|
+
if (!hits) {
|
|
1627
|
+
run.log(`coverage pass: process ${i + 1}/${chunks.length} wrote no data (${outcome.timedOut ? "timed out" : `exit ${outcome.exitCode}`}); every target keeps static selection`);
|
|
1628
|
+
return null;
|
|
1629
|
+
}
|
|
1630
|
+
if (outcome.exitCode !== 0) {
|
|
1631
|
+
const failLine = outcome.stderrTail?.match(/\(fail\)[^\n]*/)?.[0] ?? "";
|
|
1632
|
+
run.log(`coverage pass: tests failed in process ${i + 1}/${chunks.length} (exit ${outcome.exitCode}) ${failLine}`.trim());
|
|
1633
|
+
}
|
|
1634
|
+
attribution.add(run.testFiles, hits, parseJunit(outcome.junitXml ?? "", cwd));
|
|
1635
|
+
}
|
|
1636
|
+
const targets = run.targets.map((_, i) => {
|
|
1637
|
+
const reason = failed.get(i);
|
|
1638
|
+
if (reason)
|
|
1639
|
+
return { kind: "static", reason };
|
|
1640
|
+
return targetCoverage(run, i, attribution, probes);
|
|
1641
|
+
});
|
|
1642
|
+
return { targets, tests: attribution.tests, byFile: attribution.byFile };
|
|
1643
|
+
} finally {
|
|
1644
|
+
rmSync3(dir, { recursive: true, force: true });
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
var escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1648
|
+
function selectionFor(cov, coverage) {
|
|
1649
|
+
const named = cov.tests.flatMap((id) => {
|
|
1650
|
+
const t = coverage.tests[id];
|
|
1651
|
+
return t ? [t] : [];
|
|
1652
|
+
});
|
|
1653
|
+
const files = sorted([...cov.files, ...named.map((t) => real(t.file))]);
|
|
1654
|
+
const coveredBy = sorted([...cov.tests, ...cov.files.flatMap((f) => coverage.byFile[f] ?? [])]);
|
|
1655
|
+
if (cov.files.length > 0 || named.length === 0)
|
|
1656
|
+
return { files, coveredBy };
|
|
1657
|
+
const pattern = `^(?:${sorted(named.map((t) => escapeRegex(t.filterName))).join("|")})$`;
|
|
1658
|
+
return pattern.length > MAX_PATTERN ? { files, coveredBy } : { files, namePattern: pattern, coveredBy };
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// tools/mutate/src/engine/generate.ts
|
|
1662
|
+
import { parseSync as parseSync2 } from "oxc-parser";
|
|
1663
|
+
|
|
1664
|
+
// tools/mutate/src/engine/operators.ts
|
|
1665
|
+
import { mutate as mutateRegex } from "weapon-regex";
|
|
1666
|
+
var EQUALITY = {
|
|
1667
|
+
"<": ["<=", ">="],
|
|
1668
|
+
"<=": ["<", ">"],
|
|
1669
|
+
">": [">=", "<="],
|
|
1670
|
+
">=": [">", "<"],
|
|
1671
|
+
"==": ["!="],
|
|
1672
|
+
"!=": ["=="],
|
|
1673
|
+
"===": ["!=="],
|
|
1674
|
+
"!==": ["==="]
|
|
1675
|
+
};
|
|
1676
|
+
var ARITHMETIC = { "+": "-", "-": "+", "*": "/", "/": "*", "%": "*" };
|
|
1677
|
+
var LOGICAL = { "&&": "||", "||": "&&", "??": "&&" };
|
|
1678
|
+
var ASSIGNMENT = {
|
|
1679
|
+
"+=": "-=",
|
|
1680
|
+
"-=": "+=",
|
|
1681
|
+
"*=": "/=",
|
|
1682
|
+
"/=": "*=",
|
|
1683
|
+
"%=": "*=",
|
|
1684
|
+
"<<=": ">>=",
|
|
1685
|
+
">>=": "<<=",
|
|
1686
|
+
"&=": "|=",
|
|
1687
|
+
"|=": "&=",
|
|
1688
|
+
"&&=": "||=",
|
|
1689
|
+
"||=": "&&=",
|
|
1690
|
+
"??=": "&&="
|
|
1691
|
+
};
|
|
1692
|
+
var STRING_SAFE_ASSIGNMENTS = new Set(["&&=", "||=", "??="]);
|
|
1693
|
+
var BOOLEAN_OPERATORS = new Set(["!=", "!==", "&&", "<", "<=", "==", "===", ">", ">=", "||"]);
|
|
1694
|
+
var LOOPS = new Set(["ForStatement", "WhileStatement", "DoWhileStatement"]);
|
|
1695
|
+
var EMPTY_STRING_REPLACEMENT = '"Stryker was here!"';
|
|
1696
|
+
var METHODS = (() => {
|
|
1697
|
+
const forward = [
|
|
1698
|
+
["charAt", null],
|
|
1699
|
+
["endsWith", "startsWith"],
|
|
1700
|
+
["every", "some"],
|
|
1701
|
+
["filter", null],
|
|
1702
|
+
["reverse", null],
|
|
1703
|
+
["slice", null],
|
|
1704
|
+
["sort", null],
|
|
1705
|
+
["substr", null],
|
|
1706
|
+
["substring", null],
|
|
1707
|
+
["toLocaleLowerCase", "toLocaleUpperCase"],
|
|
1708
|
+
["toLowerCase", "toUpperCase"],
|
|
1709
|
+
["trim", null],
|
|
1710
|
+
["trimEnd", "trimStart"],
|
|
1711
|
+
["min", "max"],
|
|
1712
|
+
["setDate", "setTime"],
|
|
1713
|
+
["setFullYear", "setMonth"],
|
|
1714
|
+
["setHours", "setMinutes"],
|
|
1715
|
+
["setSeconds", "setMilliseconds"],
|
|
1716
|
+
["setUTCDate", "setTime"],
|
|
1717
|
+
["setUTCFullYear", "setUTCMonth"],
|
|
1718
|
+
["setUTCHours", "setUTCMinutes"],
|
|
1719
|
+
["setUTCSeconds", "setUTCMilliseconds"]
|
|
1720
|
+
];
|
|
1721
|
+
const map = new Map(forward);
|
|
1722
|
+
for (const [name, swap] of forward) {
|
|
1723
|
+
if (swap && name !== "setUTCDate" && name !== "getUTCDate")
|
|
1724
|
+
map.set(swap, name);
|
|
1725
|
+
}
|
|
1726
|
+
return map;
|
|
1727
|
+
})();
|
|
1728
|
+
function operatorOffset(src, op, left, right) {
|
|
1729
|
+
const gap = src.slice(left.end, right.start);
|
|
1730
|
+
const at = gap.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, (c) => " ".repeat(c.length)).indexOf(op);
|
|
1731
|
+
return at === -1 ? -1 : left.end + at;
|
|
1732
|
+
}
|
|
1733
|
+
function isStringNode(node) {
|
|
1734
|
+
if (!node)
|
|
1735
|
+
return false;
|
|
1736
|
+
return node.type === "Literal" && typeof node["value"] === "string" || node.type === "TemplateLiteral";
|
|
1737
|
+
}
|
|
1738
|
+
var text = (src, node) => src.slice(node.start, node.end);
|
|
1739
|
+
function binaryParts(node, src) {
|
|
1740
|
+
const op = stringField(node, "operator");
|
|
1741
|
+
const left = child(node, "left");
|
|
1742
|
+
const right = child(node, "right");
|
|
1743
|
+
if (!op || !left || !right)
|
|
1744
|
+
return;
|
|
1745
|
+
const at = operatorOffset(src, op, left, right);
|
|
1746
|
+
return at === -1 ? undefined : { op, left, right, at };
|
|
1747
|
+
}
|
|
1748
|
+
var binary = (node, ctx) => {
|
|
1749
|
+
if (node.type !== "BinaryExpression")
|
|
1750
|
+
return;
|
|
1751
|
+
const parts = binaryParts(node, ctx.src);
|
|
1752
|
+
if (!parts)
|
|
1753
|
+
return;
|
|
1754
|
+
const { op, left, right, at } = parts;
|
|
1755
|
+
const span = (rep, mutator) => ctx.emit(at, at + op.length, rep, mutator);
|
|
1756
|
+
for (const rep of EQUALITY[op] ?? [])
|
|
1757
|
+
span(rep, "EqualityOperator");
|
|
1758
|
+
const arith = ARITHMETIC[op];
|
|
1759
|
+
const leftProbe = left.type === "BinaryExpression" ? child(left, "right") : left;
|
|
1760
|
+
if (arith && !isStringNode(right) && !isStringNode(leftProbe))
|
|
1761
|
+
span(arith, "ArithmeticOperator");
|
|
1762
|
+
};
|
|
1763
|
+
var logical = (node, ctx) => {
|
|
1764
|
+
if (node.type !== "LogicalExpression")
|
|
1765
|
+
return;
|
|
1766
|
+
const parts = binaryParts(node, ctx.src);
|
|
1767
|
+
const rep = parts && LOGICAL[parts.op];
|
|
1768
|
+
if (!parts || !rep)
|
|
1769
|
+
return;
|
|
1770
|
+
const { op, left, right, at } = parts;
|
|
1771
|
+
if (op !== "??") {
|
|
1772
|
+
ctx.emit(at, at + op.length, rep, "LogicalOperator");
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
const operand = (n) => n.type === "LogicalExpression" ? `(${text(ctx.src, n)})` : text(ctx.src, n);
|
|
1776
|
+
const slice = (from, to) => ctx.src.slice(from, to);
|
|
1777
|
+
ctx.emit(node.start, node.end, `(${slice(node.start, left.start)}${operand(left)}${slice(left.end, at)}${rep}${slice(at + op.length, right.start)}${operand(right)}${slice(right.end, node.end)})`, "LogicalOperator");
|
|
1778
|
+
};
|
|
1779
|
+
var assignment = (node, ctx) => {
|
|
1780
|
+
if (node.type !== "AssignmentExpression")
|
|
1781
|
+
return;
|
|
1782
|
+
const parts = binaryParts(node, ctx.src);
|
|
1783
|
+
const rep = parts && ASSIGNMENT[parts.op];
|
|
1784
|
+
if (!parts || !rep)
|
|
1785
|
+
return;
|
|
1786
|
+
if (isStringNode(parts.right) && !STRING_SAFE_ASSIGNMENTS.has(parts.op))
|
|
1787
|
+
return;
|
|
1788
|
+
ctx.emit(parts.at, parts.at + parts.op.length, rep, "AssignmentOperator");
|
|
1789
|
+
};
|
|
1790
|
+
var booleanLiteral = (node, ctx) => {
|
|
1791
|
+
if (node.type === "Literal" && typeof node["value"] === "boolean") {
|
|
1792
|
+
ctx.emit(node.start, node.end, String(!node["value"]), "BooleanLiteral");
|
|
1793
|
+
}
|
|
1794
|
+
if (node.type === "UnaryExpression" && node["operator"] === "!") {
|
|
1795
|
+
ctx.emit(node.start, node.start + 1, "", "BooleanLiteral");
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
var UNARY = { "+": "-", "-": "+", "~": "" };
|
|
1799
|
+
var unary = (node, ctx) => {
|
|
1800
|
+
if (node.type !== "UnaryExpression")
|
|
1801
|
+
return;
|
|
1802
|
+
const op = stringField(node, "operator");
|
|
1803
|
+
const rep = op === undefined ? undefined : UNARY[op];
|
|
1804
|
+
if (rep !== undefined)
|
|
1805
|
+
ctx.emit(node.start, node.start + 1, rep, "UnaryOperator");
|
|
1806
|
+
};
|
|
1807
|
+
var update = (node, ctx) => {
|
|
1808
|
+
if (node.type !== "UpdateExpression")
|
|
1809
|
+
return;
|
|
1810
|
+
const op = stringField(node, "operator");
|
|
1811
|
+
const arg = child(node, "argument");
|
|
1812
|
+
if (!op || !arg)
|
|
1813
|
+
return;
|
|
1814
|
+
const at = node["prefix"] === true ? node.start : arg.end;
|
|
1815
|
+
const opAt = ctx.src.indexOf(op, at);
|
|
1816
|
+
if (opAt === -1 || opAt >= node.end)
|
|
1817
|
+
return;
|
|
1818
|
+
ctx.emit(opAt, opAt + 2, op === "++" ? "--" : "++", "UpdateOperator");
|
|
1819
|
+
};
|
|
1820
|
+
function isStructuralString(node, ctx) {
|
|
1821
|
+
const p = ctx.parent;
|
|
1822
|
+
if (!p)
|
|
1823
|
+
return false;
|
|
1824
|
+
if (p.type.startsWith("Export") || p.type === "ImportExpression")
|
|
1825
|
+
return true;
|
|
1826
|
+
if (p.type === "JSXAttribute" || p.type === "ExpressionStatement")
|
|
1827
|
+
return true;
|
|
1828
|
+
if ((p.type === "Property" || p.type === "PropertyDefinition") && child(p, "key") === node) {
|
|
1829
|
+
return true;
|
|
1830
|
+
}
|
|
1831
|
+
if (p.type !== "CallExpression")
|
|
1832
|
+
return false;
|
|
1833
|
+
const callee = child(p, "callee");
|
|
1834
|
+
return callee?.type === "Identifier" && (callee["name"] === "require" || callee["name"] === "Symbol");
|
|
1835
|
+
}
|
|
1836
|
+
var stringLiteral = (node, ctx) => {
|
|
1837
|
+
if (node.type === "TemplateLiteral") {
|
|
1838
|
+
const quasis = children(node, "quasis");
|
|
1839
|
+
const value = quasis[0]?.["value"];
|
|
1840
|
+
const empty = quasis.length === 1 && isRecord(value) && value["raw"] === "";
|
|
1841
|
+
ctx.emit(node.start, node.end, empty ? "`Stryker was here!`" : "``", "StringLiteral");
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
if (node.type !== "Literal" || typeof node["value"] !== "string")
|
|
1845
|
+
return;
|
|
1846
|
+
if (isStructuralString(node, ctx))
|
|
1847
|
+
return;
|
|
1848
|
+
ctx.emit(node.start, node.end, node["value"] === "" ? EMPTY_STRING_REPLACEMENT : '""', "StringLiteral");
|
|
1849
|
+
};
|
|
1850
|
+
function isBooleanExpression(node) {
|
|
1851
|
+
if (node.type !== "BinaryExpression" && node.type !== "LogicalExpression")
|
|
1852
|
+
return false;
|
|
1853
|
+
const op = stringField(node, "operator");
|
|
1854
|
+
return op !== undefined && BOOLEAN_OPERATORS.has(op);
|
|
1855
|
+
}
|
|
1856
|
+
function conditionalTest(node, ctx) {
|
|
1857
|
+
const p = ctx.parent;
|
|
1858
|
+
const emit = (rep) => ctx.emit(node.start, node.end, rep, "ConditionalExpression");
|
|
1859
|
+
if (p && ctx.key === "test" && LOOPS.has(p.type)) {
|
|
1860
|
+
emit("false");
|
|
1861
|
+
return true;
|
|
1862
|
+
}
|
|
1863
|
+
if (p?.type === "IfStatement" && ctx.key === "test") {
|
|
1864
|
+
emit("true");
|
|
1865
|
+
emit("false");
|
|
1866
|
+
return true;
|
|
1867
|
+
}
|
|
1868
|
+
if (!isBooleanExpression(node))
|
|
1869
|
+
return false;
|
|
1870
|
+
const parentOp = p?.type === "LogicalExpression" ? stringField(p, "operator") : undefined;
|
|
1871
|
+
if (parentOp !== "||")
|
|
1872
|
+
emit("true");
|
|
1873
|
+
if (parentOp !== "&&")
|
|
1874
|
+
emit("false");
|
|
1875
|
+
return true;
|
|
1876
|
+
}
|
|
1877
|
+
function forWithoutTest(node, ctx) {
|
|
1878
|
+
const init = child(node, "init");
|
|
1879
|
+
const open = ctx.src.indexOf("(", node.start);
|
|
1880
|
+
const semi = open === -1 ? -1 : ctx.src.indexOf(";", init ? init.end : open);
|
|
1881
|
+
if (semi !== -1)
|
|
1882
|
+
ctx.emit(semi + 1, semi + 1, "false", "ConditionalExpression");
|
|
1883
|
+
}
|
|
1884
|
+
function emptySwitchCase(node, ctx) {
|
|
1885
|
+
const body = children(node, "consequent");
|
|
1886
|
+
const first = body[0];
|
|
1887
|
+
const last = body[body.length - 1];
|
|
1888
|
+
if (first && last)
|
|
1889
|
+
ctx.emit(first.start, last.end, "", "ConditionalExpression");
|
|
1890
|
+
}
|
|
1891
|
+
var conditional = (node, ctx) => {
|
|
1892
|
+
if (conditionalTest(node, ctx))
|
|
1893
|
+
return;
|
|
1894
|
+
if (node.type === "ForStatement" && !child(node, "test"))
|
|
1895
|
+
forWithoutTest(node, ctx);
|
|
1896
|
+
if (node.type === "SwitchCase")
|
|
1897
|
+
emptySwitchCase(node, ctx);
|
|
1898
|
+
};
|
|
1899
|
+
function containsSuperCall(node) {
|
|
1900
|
+
if (node.type === "CallExpression" && child(node, "callee")?.type === "Super")
|
|
1901
|
+
return true;
|
|
1902
|
+
for (const value of Object.values(node)) {
|
|
1903
|
+
const list = Array.isArray(value) ? value : [value];
|
|
1904
|
+
for (const c of list)
|
|
1905
|
+
if (isAstNode(c) && containsSuperCall(c))
|
|
1906
|
+
return true;
|
|
1907
|
+
}
|
|
1908
|
+
return false;
|
|
1909
|
+
}
|
|
1910
|
+
function isInvalidConstructorBody(ctx, block) {
|
|
1911
|
+
const n = ctx.ancestors.length;
|
|
1912
|
+
const fn = ctx.ancestors[n - 1];
|
|
1913
|
+
const method = ctx.ancestors[n - 2];
|
|
1914
|
+
const classBody = ctx.ancestors[n - 3];
|
|
1915
|
+
if (!fn || method?.type !== "MethodDefinition" || method["kind"] !== "constructor")
|
|
1916
|
+
return false;
|
|
1917
|
+
const paramProps = children(fn, "params").some((param) => param.type === "TSParameterProperty");
|
|
1918
|
+
const initialisedFields = classBody?.type === "ClassBody" && children(classBody, "body").some((m) => m.type === "PropertyDefinition" && child(m, "value") !== undefined && child(m, "key")?.type !== "PrivateIdentifier");
|
|
1919
|
+
return (paramProps || initialisedFields) && containsSuperCall(block);
|
|
1920
|
+
}
|
|
1921
|
+
var block = (node, ctx) => {
|
|
1922
|
+
if (node.type !== "BlockStatement" || children(node, "body").length === 0)
|
|
1923
|
+
return;
|
|
1924
|
+
if (isInvalidConstructorBody(ctx, node))
|
|
1925
|
+
return;
|
|
1926
|
+
ctx.emit(node.start, node.end, "{}", "BlockStatement");
|
|
1927
|
+
};
|
|
1928
|
+
var arrayDeclaration = (node, ctx) => {
|
|
1929
|
+
if (node.type === "ArrayExpression") {
|
|
1930
|
+
const empty = children(node, "elements").length === 0;
|
|
1931
|
+
ctx.emit(node.start, node.end, empty ? '["Stryker was here"]' : "[]", "ArrayDeclaration");
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
if (node.type !== "CallExpression" && node.type !== "NewExpression")
|
|
1935
|
+
return;
|
|
1936
|
+
const callee = child(node, "callee");
|
|
1937
|
+
if (callee?.type !== "Identifier" || callee["name"] !== "Array")
|
|
1938
|
+
return;
|
|
1939
|
+
const args = children(node, "arguments").length === 0 ? "[]" : "";
|
|
1940
|
+
const prefix = node.type === "NewExpression" ? "new " : "";
|
|
1941
|
+
ctx.emit(node.start, node.end, `${prefix}Array(${args})`, "ArrayDeclaration");
|
|
1942
|
+
};
|
|
1943
|
+
var objectLiteral = (node, ctx) => {
|
|
1944
|
+
if (node.type === "ObjectExpression" && children(node, "properties").length > 0) {
|
|
1945
|
+
ctx.emit(node.start, node.end, "{}", "ObjectLiteral");
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1948
|
+
var arrowFunction = (node, ctx) => {
|
|
1949
|
+
if (node.type !== "ArrowFunctionExpression")
|
|
1950
|
+
return;
|
|
1951
|
+
const body = child(node, "body");
|
|
1952
|
+
if (!body || body.type === "BlockStatement" || node["expression"] === false)
|
|
1953
|
+
return;
|
|
1954
|
+
if (body.type === "Identifier" && body["name"] === "undefined")
|
|
1955
|
+
return;
|
|
1956
|
+
ctx.emit(node.start, node.end, "() => undefined", "ArrowFunction");
|
|
1957
|
+
};
|
|
1958
|
+
var optionalChaining = (node, ctx) => {
|
|
1959
|
+
if (node["optional"] !== true)
|
|
1960
|
+
return;
|
|
1961
|
+
if (node.type === "MemberExpression") {
|
|
1962
|
+
const object = child(node, "object");
|
|
1963
|
+
if (!object)
|
|
1964
|
+
return;
|
|
1965
|
+
const q = ctx.src.indexOf("?.", object.end);
|
|
1966
|
+
if (q === -1 || q >= node.end)
|
|
1967
|
+
return;
|
|
1968
|
+
ctx.emit(q, q + 2, node["computed"] === true ? "" : ".", "OptionalChaining");
|
|
1969
|
+
}
|
|
1970
|
+
if (node.type === "CallExpression") {
|
|
1971
|
+
const callee = child(node, "callee");
|
|
1972
|
+
if (!callee)
|
|
1973
|
+
return;
|
|
1974
|
+
const q = ctx.src.indexOf("?.", callee.end);
|
|
1975
|
+
if (q === -1 || q >= node.end)
|
|
1976
|
+
return;
|
|
1977
|
+
ctx.emit(q, q + 2, "", "OptionalChaining");
|
|
1978
|
+
}
|
|
1979
|
+
};
|
|
1980
|
+
var methodExpression = (node, ctx) => {
|
|
1981
|
+
if (node.type !== "CallExpression")
|
|
1982
|
+
return;
|
|
1983
|
+
const callee = child(node, "callee");
|
|
1984
|
+
if (callee?.type !== "MemberExpression" || callee["computed"] === true)
|
|
1985
|
+
return;
|
|
1986
|
+
const property = child(callee, "property");
|
|
1987
|
+
const object = child(callee, "object");
|
|
1988
|
+
if (property?.type !== "Identifier" || !object)
|
|
1989
|
+
return;
|
|
1990
|
+
const name = stringField(property, "name");
|
|
1991
|
+
const swap = name === undefined ? undefined : METHODS.get(name);
|
|
1992
|
+
if (swap === undefined)
|
|
1993
|
+
return;
|
|
1994
|
+
if (swap === null)
|
|
1995
|
+
ctx.emit(node.start, node.end, text(ctx.src, object), "MethodExpression");
|
|
1996
|
+
else
|
|
1997
|
+
ctx.emit(property.start, property.end, swap, "MethodExpression");
|
|
1998
|
+
};
|
|
1999
|
+
function regexPatterns(pattern, flags) {
|
|
2000
|
+
if (pattern.length === 0)
|
|
2001
|
+
return [];
|
|
2002
|
+
try {
|
|
2003
|
+
return mutateRegex(pattern, flags, { mutationLevels: [1] }).map((m) => m.pattern);
|
|
2004
|
+
} catch {
|
|
2005
|
+
return [];
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
var regex = (node, ctx) => {
|
|
2009
|
+
if (node.type !== "Literal")
|
|
2010
|
+
return;
|
|
2011
|
+
const literal = node["regex"];
|
|
2012
|
+
if (isRecord(literal)) {
|
|
2013
|
+
const pattern = literal["pattern"];
|
|
2014
|
+
const flags = typeof literal["flags"] === "string" ? literal["flags"] : "";
|
|
2015
|
+
if (typeof pattern !== "string")
|
|
2016
|
+
return;
|
|
2017
|
+
for (const p of regexPatterns(pattern, flags)) {
|
|
2018
|
+
ctx.emit(node.start, node.end, `/${p}/${flags}`, "Regex");
|
|
2019
|
+
}
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
const p = ctx.parent;
|
|
2023
|
+
if (typeof node["value"] !== "string" || p?.type !== "NewExpression")
|
|
2024
|
+
return;
|
|
2025
|
+
if (calleeName(p) !== "RegExp" || child(p, "callee")?.type !== "Identifier")
|
|
2026
|
+
return;
|
|
2027
|
+
const [first, second] = children(p, "arguments");
|
|
2028
|
+
if (first !== node)
|
|
2029
|
+
return;
|
|
2030
|
+
const flags = second?.type === "Literal" ? stringField(second, "value") : undefined;
|
|
2031
|
+
for (const pattern of regexPatterns(node["value"], flags)) {
|
|
2032
|
+
ctx.emit(node.start, node.end, JSON.stringify(pattern), "Regex");
|
|
2033
|
+
}
|
|
2034
|
+
};
|
|
2035
|
+
var OPERATORS = [
|
|
2036
|
+
binary,
|
|
2037
|
+
logical,
|
|
2038
|
+
assignment,
|
|
2039
|
+
booleanLiteral,
|
|
2040
|
+
unary,
|
|
2041
|
+
update,
|
|
2042
|
+
stringLiteral,
|
|
2043
|
+
regex,
|
|
2044
|
+
conditional,
|
|
2045
|
+
block,
|
|
2046
|
+
arrayDeclaration,
|
|
2047
|
+
objectLiteral,
|
|
2048
|
+
arrowFunction,
|
|
2049
|
+
optionalChaining,
|
|
2050
|
+
methodExpression
|
|
2051
|
+
];
|
|
2052
|
+
|
|
2053
|
+
// tools/mutate/src/engine/verify-callsites.ts
|
|
2054
|
+
var VERIFY_PRIMITIVES = new Set([
|
|
2055
|
+
"requires",
|
|
2056
|
+
"ensures",
|
|
2057
|
+
"invariant",
|
|
2058
|
+
"stateConstraint",
|
|
2059
|
+
"forAllPeers",
|
|
2060
|
+
"somePeer"
|
|
2061
|
+
]);
|
|
2062
|
+
function verifyCallsiteReason(name) {
|
|
2063
|
+
return `Inside polly's ${name}(...) — a runtime no-op (compiled away in production, ` + `translated to a TLA+ assertion in verification). No test can observe or kill ` + `mutations here, so they are excluded from the score (polly#143).`;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// tools/mutate/src/engine/generate.ts
|
|
2067
|
+
var TS_RUNTIME_WRAPPERS = new Set([
|
|
2068
|
+
"TSAsExpression",
|
|
2069
|
+
"TSSatisfiesExpression",
|
|
2070
|
+
"TSNonNullExpression",
|
|
2071
|
+
"TSInstantiationExpression",
|
|
2072
|
+
"TSTypeAssertion",
|
|
2073
|
+
"TSParameterProperty",
|
|
2074
|
+
"TSModuleBlock"
|
|
2075
|
+
]);
|
|
2076
|
+
var PARSE_OPTIONS2 = { preserveParens: false };
|
|
2077
|
+
var TYPE_KEYS = new Set(["typeAnnotation", "returnType", "typeParameters", "typeArguments"]);
|
|
2078
|
+
var SKIPPED_STATEMENTS = new Set(["ImportDeclaration", "ExportAllDeclaration", "Decorator"]);
|
|
2079
|
+
function isSkipped(node) {
|
|
2080
|
+
if (SKIPPED_STATEMENTS.has(node.type))
|
|
2081
|
+
return true;
|
|
2082
|
+
if (node.type === "VariableDeclaration")
|
|
2083
|
+
return node["declare"] === true;
|
|
2084
|
+
if (node.type === "TSModuleDeclaration")
|
|
2085
|
+
return node["declare"] === true;
|
|
2086
|
+
return node.type.startsWith("TS") && !TS_RUNTIME_WRAPPERS.has(node.type);
|
|
2087
|
+
}
|
|
2088
|
+
function lineIndex(src) {
|
|
2089
|
+
const starts = [0];
|
|
2090
|
+
for (let i = 0;i < src.length; i++)
|
|
2091
|
+
if (src.charCodeAt(i) === 10)
|
|
2092
|
+
starts.push(i + 1);
|
|
2093
|
+
return (offset) => {
|
|
2094
|
+
let lo = 0;
|
|
2095
|
+
let hi = starts.length - 1;
|
|
2096
|
+
while (lo < hi) {
|
|
2097
|
+
const mid = lo + hi + 1 >> 1;
|
|
2098
|
+
if ((starts[mid] ?? 0) <= offset)
|
|
2099
|
+
lo = mid;
|
|
2100
|
+
else
|
|
2101
|
+
hi = mid - 1;
|
|
2102
|
+
}
|
|
2103
|
+
return { line: lo + 1, column: offset - (starts[lo] ?? 0) + 1 };
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
function applyMutant(src, m) {
|
|
2107
|
+
return src.slice(0, m.start) + m.replacement + src.slice(m.end);
|
|
2108
|
+
}
|
|
2109
|
+
function verifyReason(node) {
|
|
2110
|
+
if (node.type !== "CallExpression")
|
|
2111
|
+
return;
|
|
2112
|
+
const name = calleeName(node);
|
|
2113
|
+
return name && VERIFY_PRIMITIVES.has(name) ? verifyCallsiteReason(name) : undefined;
|
|
2114
|
+
}
|
|
2115
|
+
var FUNCTION_NODES = new Set([
|
|
2116
|
+
"FunctionDeclaration",
|
|
2117
|
+
"FunctionExpression",
|
|
2118
|
+
"ArrowFunctionExpression"
|
|
2119
|
+
]);
|
|
2120
|
+
function contractSite(file, node, ancestors, position) {
|
|
2121
|
+
if (node.type !== "CallExpression")
|
|
2122
|
+
return;
|
|
2123
|
+
const name = calleeName(node);
|
|
2124
|
+
if (name !== "requires" && name !== "ensures")
|
|
2125
|
+
return;
|
|
2126
|
+
const second = children(node, "arguments")[1];
|
|
2127
|
+
const message = second?.type === "Literal" && typeof second["value"] === "string" ? second["value"] : undefined;
|
|
2128
|
+
const handlerNode = [...ancestors].reverse().find((a) => a.type === "CallExpression" && calleeName(a) === "on") ?? [...ancestors].reverse().find((a) => FUNCTION_NODES.has(a.type));
|
|
2129
|
+
const handler = handlerNode ? { from: position(handlerNode.start).line, to: position(handlerNode.end).line } : undefined;
|
|
2130
|
+
return {
|
|
2131
|
+
file,
|
|
2132
|
+
line: position(node.start).line,
|
|
2133
|
+
primitive: name,
|
|
2134
|
+
...message === undefined ? {} : { message },
|
|
2135
|
+
...handler ? { handler } : {}
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
function runtimeChildren(node) {
|
|
2139
|
+
const out = [];
|
|
2140
|
+
for (const [k, value] of Object.entries(node)) {
|
|
2141
|
+
if (TYPE_KEYS.has(k))
|
|
2142
|
+
continue;
|
|
2143
|
+
const list = Array.isArray(value) ? value : [value];
|
|
2144
|
+
for (const c of list)
|
|
2145
|
+
if (isAstNode(c))
|
|
2146
|
+
out.push([k, c]);
|
|
2147
|
+
}
|
|
2148
|
+
return out;
|
|
2149
|
+
}
|
|
2150
|
+
function generateMutants(file, src, opts = {}) {
|
|
2151
|
+
const parsed = parseSync2(file, src, PARSE_OPTIONS2);
|
|
2152
|
+
const first = parsed.errors[0];
|
|
2153
|
+
if (first)
|
|
2154
|
+
throw new Error(`${file}: cannot parse target: ${first.message}`);
|
|
2155
|
+
const position = lineIndex(src);
|
|
2156
|
+
const ignoreVerify = opts.ignoreVerifyCallsites ?? true;
|
|
2157
|
+
const prefix = opts.idPrefix ?? "";
|
|
2158
|
+
const seen = new Set;
|
|
2159
|
+
const excluded = new Set(opts.exclude ?? []);
|
|
2160
|
+
const all = [];
|
|
2161
|
+
const contracts = [];
|
|
2162
|
+
const ancestors = [];
|
|
2163
|
+
const visit = (node, key, ignored) => {
|
|
2164
|
+
if (isSkipped(node))
|
|
2165
|
+
return;
|
|
2166
|
+
const parent = ancestors[ancestors.length - 1];
|
|
2167
|
+
const site = contractSite(file, node, ancestors, position);
|
|
2168
|
+
if (site)
|
|
2169
|
+
contracts.push(site);
|
|
2170
|
+
const ignoredHere = ignored ?? (ignoreVerify ? verifyReason(node) : undefined);
|
|
2171
|
+
const ctx = {
|
|
2172
|
+
src,
|
|
2173
|
+
parent,
|
|
2174
|
+
key,
|
|
2175
|
+
ancestors,
|
|
2176
|
+
emit(start, end, replacement, mutatorName) {
|
|
2177
|
+
if (excluded.has(mutatorName))
|
|
2178
|
+
return;
|
|
2179
|
+
const dedupe = `${start}:${end}:${replacement}`;
|
|
2180
|
+
if (seen.has(dedupe))
|
|
2181
|
+
return;
|
|
2182
|
+
seen.add(dedupe);
|
|
2183
|
+
all.push({
|
|
2184
|
+
id: "",
|
|
2185
|
+
file,
|
|
2186
|
+
mutatorName,
|
|
2187
|
+
start,
|
|
2188
|
+
end,
|
|
2189
|
+
replacement,
|
|
2190
|
+
location: { start: position(start), end: position(end) },
|
|
2191
|
+
...ignoredHere ? { ignoredReason: ignoredHere } : {}
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
};
|
|
2195
|
+
for (const op of OPERATORS)
|
|
2196
|
+
op(node, ctx);
|
|
2197
|
+
ancestors.push(node);
|
|
2198
|
+
for (const [k, c] of runtimeChildren(node))
|
|
2199
|
+
visit(c, k, ignoredHere);
|
|
2200
|
+
ancestors.pop();
|
|
2201
|
+
};
|
|
2202
|
+
for (const statement of parsed.program.body) {
|
|
2203
|
+
if (isAstNode(statement))
|
|
2204
|
+
visit(statement, "body", undefined);
|
|
2205
|
+
}
|
|
2206
|
+
const inRange = opts.lines ? all.filter((m) => m.location.start.line >= (opts.lines?.from ?? 0) && m.location.start.line <= (opts.lines?.to ?? 0)) : all;
|
|
2207
|
+
inRange.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
2208
|
+
inRange.forEach((m, i) => {
|
|
2209
|
+
m.id = `${prefix}${i}`;
|
|
2210
|
+
});
|
|
2211
|
+
const mutants = [];
|
|
2212
|
+
const compileErrors = [];
|
|
2213
|
+
for (const m of inRange) {
|
|
2214
|
+
if (parseSync2(file, applyMutant(src, m), PARSE_OPTIONS2).errors.length > 0)
|
|
2215
|
+
compileErrors.push(m);
|
|
2216
|
+
else
|
|
2217
|
+
mutants.push(m);
|
|
2218
|
+
}
|
|
2219
|
+
return { mutants, compileErrors, contracts };
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// tools/mutate/src/engine/graph.ts
|
|
2223
|
+
import { realpathSync as realpathSync3 } from "node:fs";
|
|
2224
|
+
import { dirname as dirname4, extname } from "node:path";
|
|
2225
|
+
import { Glob as Glob2 } from "bun";
|
|
2226
|
+
var SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"]);
|
|
2227
|
+
function loaderFor(file) {
|
|
2228
|
+
const ext = extname(file);
|
|
2229
|
+
if (ext === ".tsx")
|
|
2230
|
+
return "tsx";
|
|
2231
|
+
if (ext === ".jsx")
|
|
2232
|
+
return "jsx";
|
|
2233
|
+
if (ext === ".ts" || ext === ".mts" || ext === ".cts")
|
|
2234
|
+
return "ts";
|
|
2235
|
+
return "js";
|
|
2236
|
+
}
|
|
2237
|
+
function real2(path) {
|
|
2238
|
+
try {
|
|
2239
|
+
return realpathSync3(path);
|
|
2240
|
+
} catch {
|
|
2241
|
+
return path;
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
async function findTestFiles(cwd, include) {
|
|
2245
|
+
const found = new Set;
|
|
2246
|
+
for (const pattern of include) {
|
|
2247
|
+
for await (const f of new Glob2(pattern).scan({ cwd, absolute: true, onlyFiles: true })) {
|
|
2248
|
+
if (!f.includes("/node_modules/"))
|
|
2249
|
+
found.add(real2(f));
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
return [...found].sort();
|
|
2253
|
+
}
|
|
2254
|
+
var transpilers = new Map;
|
|
2255
|
+
function scan(file, text) {
|
|
2256
|
+
const loader = loaderFor(file);
|
|
2257
|
+
let t = transpilers.get(loader);
|
|
2258
|
+
if (!t) {
|
|
2259
|
+
t = new Bun.Transpiler({ loader });
|
|
2260
|
+
transpilers.set(loader, t);
|
|
2261
|
+
}
|
|
2262
|
+
try {
|
|
2263
|
+
return t.scanImports(text).map((i) => i.path);
|
|
2264
|
+
} catch {
|
|
2265
|
+
return [];
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
function resolveImports(file, text) {
|
|
2269
|
+
const out = [];
|
|
2270
|
+
for (const spec of scan(file, text)) {
|
|
2271
|
+
if (spec.startsWith("bun:") || spec.startsWith("node:"))
|
|
2272
|
+
continue;
|
|
2273
|
+
let resolved;
|
|
2274
|
+
try {
|
|
2275
|
+
resolved = real2(Bun.resolveSync(spec, dirname4(file)));
|
|
2276
|
+
} catch {
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2279
|
+
if (!resolved.includes("/node_modules/") && SOURCE_EXTENSIONS.has(extname(resolved))) {
|
|
2280
|
+
out.push(resolved);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
return out;
|
|
2284
|
+
}
|
|
2285
|
+
async function buildImportGraph(testFiles) {
|
|
2286
|
+
const imports = new Map;
|
|
2287
|
+
const queue = [...testFiles];
|
|
2288
|
+
for (let file = queue.pop();file !== undefined; file = queue.pop()) {
|
|
2289
|
+
if (imports.has(file))
|
|
2290
|
+
continue;
|
|
2291
|
+
const out = resolveImports(file, await Bun.file(file).text());
|
|
2292
|
+
imports.set(file, out);
|
|
2293
|
+
queue.push(...out.filter((f) => !imports.has(f)));
|
|
2294
|
+
}
|
|
2295
|
+
return { testFiles, imports };
|
|
2296
|
+
}
|
|
2297
|
+
function importersOf(graph) {
|
|
2298
|
+
const importers = new Map;
|
|
2299
|
+
for (const [from, tos] of graph.imports) {
|
|
2300
|
+
for (const to of tos)
|
|
2301
|
+
importers.set(to, [...importers.get(to) ?? [], from]);
|
|
2302
|
+
}
|
|
2303
|
+
return importers;
|
|
2304
|
+
}
|
|
2305
|
+
function coveringTests(graph, target) {
|
|
2306
|
+
const importers = importersOf(graph);
|
|
2307
|
+
const wanted = real2(target);
|
|
2308
|
+
const seen = new Set([wanted]);
|
|
2309
|
+
const stack = [wanted];
|
|
2310
|
+
for (let f = stack.pop();f !== undefined; f = stack.pop()) {
|
|
2311
|
+
const fresh = (importers.get(f) ?? []).filter((p) => !seen.has(p));
|
|
2312
|
+
for (const p of fresh)
|
|
2313
|
+
seen.add(p);
|
|
2314
|
+
stack.push(...fresh);
|
|
2315
|
+
}
|
|
2316
|
+
const tests = new Set(graph.testFiles);
|
|
2317
|
+
return [...seen].filter((f) => tests.has(f)).sort();
|
|
2318
|
+
}
|
|
2319
|
+
function closureOf(graph, files) {
|
|
2320
|
+
const seen = new Set(files);
|
|
2321
|
+
const stack = [...files];
|
|
2322
|
+
for (let f = stack.pop();f !== undefined; f = stack.pop()) {
|
|
2323
|
+
for (const next of graph.imports.get(f) ?? []) {
|
|
2324
|
+
if (!seen.has(next)) {
|
|
2325
|
+
seen.add(next);
|
|
2326
|
+
stack.push(next);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
return [...seen].sort();
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// tools/mutate/src/engine/report-writer.ts
|
|
2334
|
+
import { mkdirSync as mkdirSync3 } from "node:fs";
|
|
2335
|
+
import { dirname as dirname5, relative as relative4 } from "node:path";
|
|
2336
|
+
var ENGINE_NAME = "polly mutate (bun engine)";
|
|
2337
|
+
async function toMutationReport(result, cwd, meta) {
|
|
2338
|
+
const files = {};
|
|
2339
|
+
const testFiles = {};
|
|
2340
|
+
for (const target of result.targets) {
|
|
2341
|
+
const rel = relative4(cwd, target.file);
|
|
2342
|
+
files[rel] = {
|
|
2343
|
+
language: target.file.endsWith(".js") || target.file.endsWith(".jsx") ? "javascript" : "typescript",
|
|
2344
|
+
source: await Bun.file(target.file).text(),
|
|
2345
|
+
mutants: target.results.map((r) => ({
|
|
2346
|
+
id: r.mutant.id,
|
|
2347
|
+
mutatorName: r.mutant.mutatorName,
|
|
2348
|
+
replacement: r.mutant.replacement,
|
|
2349
|
+
status: r.status,
|
|
2350
|
+
...r.statusReason ? { statusReason: r.statusReason } : {},
|
|
2351
|
+
location: r.mutant.location,
|
|
2352
|
+
killedBy: r.killedBy ?? [],
|
|
2353
|
+
coveredBy: r.coveredBy ?? [],
|
|
2354
|
+
killedByContract: r.killedByContract ?? [],
|
|
2355
|
+
...r.contractOnly ? { contractOnly: true } : {}
|
|
2356
|
+
}))
|
|
2357
|
+
};
|
|
2358
|
+
for (const t of target.coveringTests)
|
|
2359
|
+
testFiles[relative4(cwd, t)] ??= { tests: [] };
|
|
2360
|
+
for (const t of target.tests) {
|
|
2361
|
+
const rel = relative4(cwd, t.file);
|
|
2362
|
+
testFiles[rel] ??= { tests: [] };
|
|
2363
|
+
const entry = testFiles[rel];
|
|
2364
|
+
if (!entry.tests.some((x) => x.id === t.id)) {
|
|
2365
|
+
entry.tests.push({
|
|
2366
|
+
id: t.id,
|
|
2367
|
+
name: t.name,
|
|
2368
|
+
location: { start: { line: t.line, column: 1 } }
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
const sites = result.targets.flatMap((t) => t.contracts.map((c) => ({
|
|
2374
|
+
id: `${relative4(cwd, c.file)}:${c.line}`,
|
|
2375
|
+
file: relative4(cwd, c.file),
|
|
2376
|
+
line: c.line,
|
|
2377
|
+
primitive: c.primitive,
|
|
2378
|
+
...c.message === undefined ? {} : { message: c.message },
|
|
2379
|
+
...c.handler ? { handler: c.handler } : {}
|
|
2380
|
+
})));
|
|
2381
|
+
return {
|
|
2382
|
+
schemaVersion: "1",
|
|
2383
|
+
thresholds: { high: 80, low: 60 },
|
|
2384
|
+
framework: { name: ENGINE_NAME },
|
|
2385
|
+
files,
|
|
2386
|
+
testFiles,
|
|
2387
|
+
contracts: {
|
|
2388
|
+
mode: meta.contracts,
|
|
2389
|
+
runner: meta.runner,
|
|
2390
|
+
sites,
|
|
2391
|
+
violations: result.contractViolations.map((v) => ({
|
|
2392
|
+
target: relative4(cwd, v.target),
|
|
2393
|
+
test: v.test,
|
|
2394
|
+
contracts: v.contracts
|
|
2395
|
+
}))
|
|
2396
|
+
}
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
async function writeReport(report, path) {
|
|
2400
|
+
mkdirSync3(dirname5(path), { recursive: true });
|
|
2401
|
+
await Bun.write(path, `${JSON.stringify(report, null, 2)}
|
|
2402
|
+
`);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// tools/mutate/src/engine/stray-writes.ts
|
|
2406
|
+
function git(cwd, args) {
|
|
2407
|
+
const proc = Bun.spawnSync(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
2408
|
+
return proc.exitCode === 0 ? proc.stdout.toString() : null;
|
|
2409
|
+
}
|
|
2410
|
+
function snapshotWorktree(cwd) {
|
|
2411
|
+
const root = git(cwd, ["rev-parse", "--show-toplevel"])?.trim();
|
|
2412
|
+
if (!root)
|
|
2413
|
+
return null;
|
|
2414
|
+
const out = git(root, [
|
|
2415
|
+
"status",
|
|
2416
|
+
"--porcelain=v1",
|
|
2417
|
+
"-z",
|
|
2418
|
+
"--untracked-files=all",
|
|
2419
|
+
"--ignored=matching"
|
|
2420
|
+
]);
|
|
2421
|
+
if (out === null)
|
|
2422
|
+
return null;
|
|
2423
|
+
const entries = new Set;
|
|
2424
|
+
const tokens = out.split("\x00");
|
|
2425
|
+
for (let i = 0;i < tokens.length; i++) {
|
|
2426
|
+
const token = tokens[i];
|
|
2427
|
+
if (!token)
|
|
2428
|
+
continue;
|
|
2429
|
+
entries.add(token);
|
|
2430
|
+
if (token[0] === "R" || token[0] === "C")
|
|
2431
|
+
i++;
|
|
2432
|
+
}
|
|
2433
|
+
return { root, entries };
|
|
2434
|
+
}
|
|
2435
|
+
function newPaths(before, after) {
|
|
2436
|
+
if (!before || !after || before.root !== after.root)
|
|
2437
|
+
return [];
|
|
2438
|
+
const paths = new Set;
|
|
2439
|
+
for (const entry of after.entries)
|
|
2440
|
+
if (!before.entries.has(entry))
|
|
2441
|
+
paths.add(entry.slice(3));
|
|
2442
|
+
return [...paths].sort();
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// tools/mutate/src/engine/typecheck.ts
|
|
2446
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
2447
|
+
import { dirname as dirname6, join as join8, relative as relative5 } from "node:path";
|
|
2448
|
+
var TYPECHECK_OVERRIDES = {
|
|
2449
|
+
allowUnreachableCode: true,
|
|
2450
|
+
noUnusedLocals: false,
|
|
2451
|
+
noUnusedParameters: false
|
|
2452
|
+
};
|
|
2453
|
+
var TYPECHECK_CONFIG_NAME = "tsconfig.polly-mutate.json";
|
|
2454
|
+
var REASON_LIMIT = 300;
|
|
2455
|
+
async function loadApi() {
|
|
2456
|
+
try {
|
|
2457
|
+
return await import("typescript-7/unstable/async");
|
|
2458
|
+
} catch {
|
|
2459
|
+
return null;
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
function tsconfigCandidates(file) {
|
|
2463
|
+
const out = [];
|
|
2464
|
+
let dir = dirname6(file);
|
|
2465
|
+
for (;; ) {
|
|
2466
|
+
const candidate = join8(dir, "tsconfig.json");
|
|
2467
|
+
if (existsSync8(candidate))
|
|
2468
|
+
out.push(candidate);
|
|
2469
|
+
const parent = dirname6(dir);
|
|
2470
|
+
if (parent === dir)
|
|
2471
|
+
return out;
|
|
2472
|
+
dir = parent;
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
function virtualConfigFor(tsconfig) {
|
|
2476
|
+
return join8(dirname6(tsconfig), TYPECHECK_CONFIG_NAME);
|
|
2477
|
+
}
|
|
2478
|
+
var VIRTUAL_CONFIG_TEXT = JSON.stringify({
|
|
2479
|
+
extends: "./tsconfig.json",
|
|
2480
|
+
compilerOptions: TYPECHECK_OVERRIDES
|
|
2481
|
+
});
|
|
2482
|
+
function errorKeys(api, diagnostics) {
|
|
2483
|
+
return diagnostics.filter((d) => d.category === api.DiagnosticCategory.Error).map((d) => `${d.code}|${d.text}`);
|
|
2484
|
+
}
|
|
2485
|
+
function reasonOf(key) {
|
|
2486
|
+
const sep = key.indexOf("|");
|
|
2487
|
+
const text = key.slice(sep + 1).replace(/\s+/g, " ");
|
|
2488
|
+
const reason = `TS${key.slice(0, sep)}: ${text}`;
|
|
2489
|
+
return reason.length > REASON_LIMIT ? `${reason.slice(0, REASON_LIMIT - 1)}…` : reason;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
class Session {
|
|
2493
|
+
mod;
|
|
2494
|
+
snapshot = null;
|
|
2495
|
+
overlay = new Map;
|
|
2496
|
+
opened = new Map;
|
|
2497
|
+
api;
|
|
2498
|
+
constructor(mod, cwd) {
|
|
2499
|
+
this.mod = mod;
|
|
2500
|
+
this.api = new mod.API({
|
|
2501
|
+
cwd,
|
|
2502
|
+
fs: {
|
|
2503
|
+
readFile: (f) => this.overlay.get(f),
|
|
2504
|
+
fileExists: (f) => this.overlay.has(f) ? true : undefined
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
async update(params) {
|
|
2509
|
+
const next = await this.api.updateSnapshot(params);
|
|
2510
|
+
await this.snapshot?.dispose();
|
|
2511
|
+
this.snapshot = next;
|
|
2512
|
+
return next;
|
|
2513
|
+
}
|
|
2514
|
+
program(config) {
|
|
2515
|
+
const project = this.snapshot?.getProject(config);
|
|
2516
|
+
if (!project)
|
|
2517
|
+
throw new Error(`polly mutate: tsgo lost the project ${config}`);
|
|
2518
|
+
return project.program;
|
|
2519
|
+
}
|
|
2520
|
+
async projectFor(file) {
|
|
2521
|
+
for (const tsconfig of tsconfigCandidates(file)) {
|
|
2522
|
+
let config = this.opened.get(tsconfig);
|
|
2523
|
+
if (config === undefined) {
|
|
2524
|
+
config = virtualConfigFor(tsconfig);
|
|
2525
|
+
this.overlay.set(config, VIRTUAL_CONFIG_TEXT);
|
|
2526
|
+
const snap = await this.update({ openProjects: [config] });
|
|
2527
|
+
if (!snap.getProject(config))
|
|
2528
|
+
config = null;
|
|
2529
|
+
this.opened.set(tsconfig, config);
|
|
2530
|
+
}
|
|
2531
|
+
if (config === null)
|
|
2532
|
+
continue;
|
|
2533
|
+
const project = this.snapshot?.getProject(config);
|
|
2534
|
+
if (project?.rootFiles.some((f) => samePath(f, file)))
|
|
2535
|
+
return config;
|
|
2536
|
+
}
|
|
2537
|
+
return null;
|
|
2538
|
+
}
|
|
2539
|
+
async errorsIn(config, file) {
|
|
2540
|
+
const program = this.program(config);
|
|
2541
|
+
const [syntactic, semantic] = await Promise.all([
|
|
2542
|
+
program.getSyntacticDiagnostics(file),
|
|
2543
|
+
program.getSemanticDiagnostics(file)
|
|
2544
|
+
]);
|
|
2545
|
+
return [...errorKeys(this.mod, syntactic), ...errorKeys(this.mod, semantic)];
|
|
2546
|
+
}
|
|
2547
|
+
async check(config, target, src) {
|
|
2548
|
+
const invalid = new Map;
|
|
2549
|
+
const baseline = new Set(await this.errorsIn(config, target.file));
|
|
2550
|
+
try {
|
|
2551
|
+
for (const m of target.mutants) {
|
|
2552
|
+
this.overlay.set(target.file, applyMutant(src, m));
|
|
2553
|
+
await this.update({ fileChanges: { changed: [target.file] } });
|
|
2554
|
+
const fresh = (await this.errorsIn(config, target.file)).find((k) => !baseline.has(k));
|
|
2555
|
+
if (fresh !== undefined)
|
|
2556
|
+
invalid.set(m.id, reasonOf(fresh));
|
|
2557
|
+
}
|
|
2558
|
+
} finally {
|
|
2559
|
+
this.overlay.delete(target.file);
|
|
2560
|
+
await this.update({ fileChanges: { changed: [target.file] } });
|
|
2561
|
+
}
|
|
2562
|
+
return invalid;
|
|
2563
|
+
}
|
|
2564
|
+
async close() {
|
|
2565
|
+
await this.api.close();
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
function samePath(a, b) {
|
|
2569
|
+
return a === b || a.toLowerCase() === b.toLowerCase();
|
|
2570
|
+
}
|
|
2571
|
+
async function typecheckMutants(targets, opts) {
|
|
2572
|
+
const log = opts.log ?? (() => {
|
|
2573
|
+
return;
|
|
2574
|
+
});
|
|
2575
|
+
const started = performance.now();
|
|
2576
|
+
const outcome = { invalid: new Map, skipped: [], checked: 0, wallMs: 0 };
|
|
2577
|
+
const withConfig = targets.filter((t) => {
|
|
2578
|
+
if (tsconfigCandidates(t.file).length > 0)
|
|
2579
|
+
return true;
|
|
2580
|
+
outcome.skipped.push({ file: t.file, reason: "no tsconfig.json above it" });
|
|
2581
|
+
return false;
|
|
2582
|
+
});
|
|
2583
|
+
if (withConfig.length === 0) {
|
|
2584
|
+
for (const s of outcome.skipped)
|
|
2585
|
+
log(`type filter: ${relative5(opts.cwd, s.file)}: ${s.reason}`);
|
|
2586
|
+
outcome.wallMs = Math.round(performance.now() - started);
|
|
2587
|
+
return outcome;
|
|
2588
|
+
}
|
|
2589
|
+
const mod = await loadApi();
|
|
2590
|
+
if (!mod) {
|
|
2591
|
+
log("type filter off: typescript-7 is not installed (bun add typescript-7@npm:typescript@7.0.2)");
|
|
2592
|
+
return null;
|
|
2593
|
+
}
|
|
2594
|
+
const session = new Session(mod, opts.cwd);
|
|
2595
|
+
try {
|
|
2596
|
+
for (const target of withConfig) {
|
|
2597
|
+
const config = await session.projectFor(target.file);
|
|
2598
|
+
if (!config) {
|
|
2599
|
+
outcome.skipped.push({ file: target.file, reason: "no tsconfig.json includes it" });
|
|
2600
|
+
continue;
|
|
2601
|
+
}
|
|
2602
|
+
const src = await Bun.file(target.file).text();
|
|
2603
|
+
const verdicts = await session.check(config, target, src);
|
|
2604
|
+
for (const [id, reason] of verdicts)
|
|
2605
|
+
outcome.invalid.set(id, reason);
|
|
2606
|
+
outcome.checked += target.mutants.length;
|
|
2607
|
+
}
|
|
2608
|
+
} finally {
|
|
2609
|
+
await session.close();
|
|
2610
|
+
}
|
|
2611
|
+
outcome.wallMs = Math.round(performance.now() - started);
|
|
2612
|
+
for (const s of outcome.skipped)
|
|
2613
|
+
log(`type filter: ${relative5(opts.cwd, s.file)}: ${s.reason}`);
|
|
2614
|
+
log(`type filter: ${outcome.invalid.size} of ${outcome.checked} mutants fail to type-check (${(outcome.wallMs / 1000).toFixed(1)} s)`);
|
|
2615
|
+
return outcome;
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
// tools/mutate/src/engine/index.ts
|
|
2619
|
+
function projectFiles(cfg) {
|
|
2620
|
+
return [
|
|
2621
|
+
...cfg.testPreload,
|
|
2622
|
+
...["tsconfig.json", "bunfig.toml", "bun.lock"].map((f) => join9(cfg.cwd, f))
|
|
2623
|
+
];
|
|
2624
|
+
}
|
|
2625
|
+
async function runEngine(cfg, hooks = {}) {
|
|
2626
|
+
if (cfg.runner.kind === "command" && cfg.mode === "matrix") {
|
|
2627
|
+
throw new Error("matrix mode needs per-test results; the command runner has none");
|
|
2628
|
+
}
|
|
2629
|
+
const workdirs = cfg.scratchCwd ? createWorkdirs(cfg.cwd) : null;
|
|
2630
|
+
try {
|
|
2631
|
+
return await run(cfg, hooks, workdirs);
|
|
2632
|
+
} finally {
|
|
2633
|
+
workdirs?.remove();
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
async function generateAll(cfg, graph) {
|
|
2637
|
+
const out = [];
|
|
2638
|
+
for (const [index, target] of cfg.targets.entries()) {
|
|
2639
|
+
const file = realpathSync4(target.file);
|
|
2640
|
+
const { mutants, compileErrors, contracts } = generateMutants(file, await Bun.file(file).text(), {
|
|
2641
|
+
ignoreVerifyCallsites: cfg.ignoreVerifyCallsites,
|
|
2642
|
+
lines: target.lines,
|
|
2643
|
+
idPrefix: `${index}.`,
|
|
2644
|
+
exclude: cfg.excludeMutators
|
|
2645
|
+
});
|
|
2646
|
+
out.push({
|
|
2647
|
+
file,
|
|
2648
|
+
mutants,
|
|
2649
|
+
compileErrors,
|
|
2650
|
+
covering: graph ? coveringTests(graph, file) : [],
|
|
2651
|
+
contracts
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
return out;
|
|
2655
|
+
}
|
|
2656
|
+
async function dynamicCoverage(cfg, graph, generated, preloads, workdirs, log) {
|
|
2657
|
+
const targets = generated.map((g) => ({
|
|
2658
|
+
file: g.file,
|
|
2659
|
+
mutants: g.mutants.filter((m) => !m.ignoredReason),
|
|
2660
|
+
covering: g.covering
|
|
2661
|
+
}));
|
|
2662
|
+
const key = cfg.cacheDir ? await cacheKey({
|
|
2663
|
+
mutants: targets.flatMap((t) => t.mutants),
|
|
2664
|
+
closure: [...graph.imports.keys()].sort(),
|
|
2665
|
+
projectFiles: projectFiles(cfg),
|
|
2666
|
+
settings: {
|
|
2667
|
+
coverage: COVERAGE_VERSION,
|
|
2668
|
+
targets: targets.map((t) => [t.file, t.mutants.length]),
|
|
2669
|
+
tests: graph.testFiles,
|
|
2670
|
+
contracts: cfg.contracts
|
|
2671
|
+
}
|
|
2672
|
+
}) : null;
|
|
2673
|
+
const cached = cfg.cacheDir && key ? await readCoverageCache(cfg.cacheDir, key) : null;
|
|
2674
|
+
if (cached) {
|
|
2675
|
+
log("coverage pass: from cache");
|
|
2676
|
+
return cached;
|
|
2677
|
+
}
|
|
2678
|
+
const started = performance.now();
|
|
2679
|
+
const coverage = await collectCoverage({
|
|
2680
|
+
cwd: cfg.cwd,
|
|
2681
|
+
testFiles: graph.testFiles,
|
|
2682
|
+
targets,
|
|
2683
|
+
preloads,
|
|
2684
|
+
concurrency: cfg.concurrency,
|
|
2685
|
+
workdirs,
|
|
2686
|
+
contracts: cfg.contracts,
|
|
2687
|
+
log
|
|
175
2688
|
});
|
|
176
|
-
|
|
177
|
-
|
|
2689
|
+
if (!coverage)
|
|
2690
|
+
return null;
|
|
2691
|
+
log(`coverage pass: ${Object.keys(coverage.tests).length} tests in ` + `${((performance.now() - started) / 1000).toFixed(1)} s`);
|
|
2692
|
+
if (cfg.cacheDir && key)
|
|
2693
|
+
writeCoverageCache(cfg.cacheDir, key, coverage);
|
|
2694
|
+
return coverage;
|
|
178
2695
|
}
|
|
179
|
-
function
|
|
180
|
-
return
|
|
2696
|
+
function sortedUnique(items) {
|
|
2697
|
+
return [...new Set(items)].sort();
|
|
181
2698
|
}
|
|
182
|
-
function
|
|
183
|
-
|
|
2699
|
+
function dynamicSelection(g, target, coverage) {
|
|
2700
|
+
const selections = new Map;
|
|
2701
|
+
for (const m of g.mutants) {
|
|
2702
|
+
const cov = target.mutants[m.id];
|
|
2703
|
+
if (cov)
|
|
2704
|
+
selections.set(m.id, selectionFor(cov, coverage));
|
|
2705
|
+
}
|
|
2706
|
+
const covering = sortedUnique([...selections.values()].flatMap((sel) => sel.files));
|
|
2707
|
+
const coverageTests = covering.flatMap((f) => (coverage.byFile[f] ?? []).flatMap((id) => {
|
|
2708
|
+
const t = coverage.tests[id];
|
|
2709
|
+
return t ? [{ id: t.id, file: t.file, name: t.name, line: t.line }] : [];
|
|
2710
|
+
}));
|
|
2711
|
+
return { covering, selections, coverageTests };
|
|
184
2712
|
}
|
|
185
|
-
|
|
186
|
-
|
|
2713
|
+
function selectionKey(mutants, selections) {
|
|
2714
|
+
if (!selections)
|
|
2715
|
+
return null;
|
|
2716
|
+
return mutants.map((m) => {
|
|
2717
|
+
const sel = selections.get(m.id);
|
|
2718
|
+
return [mutantSig(m), sel?.files ?? [], sel?.namePattern ?? ""];
|
|
2719
|
+
});
|
|
187
2720
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
2721
|
+
async function withCache(cfg, graph, g, picked) {
|
|
2722
|
+
const { covering, selections } = picked;
|
|
2723
|
+
const settings = {
|
|
2724
|
+
mode: cfg.mode,
|
|
2725
|
+
timeout: cfg.timeout,
|
|
2726
|
+
scratchCwd: cfg.scratchCwd,
|
|
2727
|
+
testPreload: cfg.testPreload,
|
|
2728
|
+
coverage: cfg.coverage,
|
|
2729
|
+
typecheck: cfg.typecheck,
|
|
2730
|
+
contracts: cfg.contracts,
|
|
2731
|
+
selections: selectionKey(g.mutants, selections)
|
|
2732
|
+
};
|
|
2733
|
+
const key = cfg.cacheDir && graph && covering.length > 0 ? await cacheKey({
|
|
2734
|
+
mutants: g.mutants,
|
|
2735
|
+
closure: closureOf(graph, covering),
|
|
2736
|
+
projectFiles: projectFiles(cfg),
|
|
2737
|
+
settings
|
|
2738
|
+
}) : null;
|
|
2739
|
+
return {
|
|
2740
|
+
file: g.file,
|
|
2741
|
+
mutants: g.mutants,
|
|
2742
|
+
compileErrors: g.compileErrors,
|
|
2743
|
+
...picked,
|
|
2744
|
+
covered: cfg.runner.kind === "command" || covering.length > 0,
|
|
2745
|
+
cacheKey: key,
|
|
2746
|
+
cached: cfg.cacheDir && key ? await readCache(cfg.cacheDir, key) : null,
|
|
2747
|
+
contracts: g.contracts
|
|
2748
|
+
};
|
|
195
2749
|
}
|
|
196
|
-
async function
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
2750
|
+
async function prepare(cfg, preloads, workdirs, log) {
|
|
2751
|
+
const command = cfg.runner.kind === "command";
|
|
2752
|
+
const testFiles = command ? [] : await findTestFiles(cfg.cwd, cfg.testInclude);
|
|
2753
|
+
if (!command && testFiles.length === 0) {
|
|
2754
|
+
throw new Error(`no test files match ${cfg.testInclude.join(", ")} under ${cfg.cwd}`);
|
|
2755
|
+
}
|
|
2756
|
+
const graph = command ? null : await buildImportGraph(testFiles);
|
|
2757
|
+
const generated = await generateAll(cfg, graph);
|
|
2758
|
+
const coverage = graph && cfg.coverage === "dynamic" ? await dynamicCoverage(cfg, graph, generated, preloads, workdirs, log) : null;
|
|
2759
|
+
const prepared = [];
|
|
2760
|
+
for (const [index, g] of generated.entries()) {
|
|
2761
|
+
const target = coverage?.targets[index];
|
|
2762
|
+
if (target?.kind === "static") {
|
|
2763
|
+
log(`${relative6(cfg.cwd, g.file)}: static test selection (${target.reason})`);
|
|
2764
|
+
}
|
|
2765
|
+
const picked = coverage && target?.kind === "dynamic" ? dynamicSelection(g, target, coverage) : { covering: g.covering, selections: null, coverageTests: [] };
|
|
2766
|
+
prepared.push(await withCache(cfg, graph, g, picked));
|
|
2767
|
+
}
|
|
2768
|
+
return { prepared, testFiles: testFiles.length };
|
|
201
2769
|
}
|
|
202
|
-
function
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
kills: r.kills,
|
|
213
|
-
uniqueKills: r.unique_kills,
|
|
214
|
-
subsumed: r.kills > 0 && r.unique_kills === 0
|
|
215
|
-
});
|
|
216
|
-
return m;
|
|
2770
|
+
function collectContractViolations(cfg, baselines, log) {
|
|
2771
|
+
const out = [];
|
|
2772
|
+
for (const [p, baseline] of baselines) {
|
|
2773
|
+
for (const v of baseline?.contractViolations ?? []) {
|
|
2774
|
+
out.push({ target: p.file, test: v.test, contracts: v.contracts });
|
|
2775
|
+
const where = v.contracts.length > 0 ? ` → ${v.contracts.join(", ")}` : "";
|
|
2776
|
+
log(`${relative6(cfg.cwd, p.file)}: test violates contract: ${v.test}${where}`);
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
return out;
|
|
217
2780
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
2781
|
+
function selected(p, mutant) {
|
|
2782
|
+
return !p.selections || (p.selections.get(mutant.id)?.files.length ?? 0) > 0;
|
|
2783
|
+
}
|
|
2784
|
+
async function runMutant(cfg, workdirs, preloads, p, baseline, job) {
|
|
2785
|
+
const matrix = cfg.mode === "matrix";
|
|
2786
|
+
const selection = p.selections?.get(job.mutant.id);
|
|
2787
|
+
const files = selection?.files ?? p.covering;
|
|
2788
|
+
const coveredBy = selection ? { coveredBy: selection.coveredBy } : {};
|
|
2789
|
+
let runCwd = cfg.cwd;
|
|
2790
|
+
const attempt = (namePattern) => inCwd(job.inTree ? null : workdirs, cfg.cwd, (cwd) => {
|
|
2791
|
+
runCwd = cwd;
|
|
2792
|
+
return runBunTest({
|
|
2793
|
+
cwd,
|
|
2794
|
+
files,
|
|
2795
|
+
preloads,
|
|
2796
|
+
contracts: cfg.contracts,
|
|
2797
|
+
env: {
|
|
2798
|
+
POLLY_MUTATE_FILE: job.mutant.file,
|
|
2799
|
+
POLLY_MUTATE_SPAN: JSON.stringify({
|
|
2800
|
+
start: job.mutant.start,
|
|
2801
|
+
end: job.mutant.end,
|
|
2802
|
+
replacement: job.mutant.replacement
|
|
2803
|
+
})
|
|
2804
|
+
},
|
|
2805
|
+
bail: !matrix,
|
|
2806
|
+
junit: matrix || namePattern !== undefined,
|
|
2807
|
+
timeoutMs: job.timeoutMs,
|
|
2808
|
+
...namePattern ? { namePattern } : {}
|
|
2809
|
+
}, cfg.runner);
|
|
2810
|
+
});
|
|
2811
|
+
let outcome = await attempt(selection?.namePattern);
|
|
2812
|
+
let cases = outcome.junitXml ? parseJunit(outcome.junitXml, runCwd) : [];
|
|
2813
|
+
if (selection?.namePattern && !outcome.timedOut && outcome.exitCode !== 0 && matchedNothing(cases)) {
|
|
2814
|
+
outcome = await attempt(undefined);
|
|
2815
|
+
cases = outcome.junitXml ? parseJunit(outcome.junitXml, runCwd) : [];
|
|
230
2816
|
}
|
|
231
|
-
|
|
2817
|
+
const durationMs = Math.round(outcome.ms);
|
|
2818
|
+
const result = { mutant: job.mutant, durationMs, ...coveredBy };
|
|
2819
|
+
if (outcome.timedOut)
|
|
2820
|
+
return { ...result, status: "Timeout" };
|
|
2821
|
+
if (outcome.exitCode === 0)
|
|
2822
|
+
return { ...result, status: "Survived" };
|
|
2823
|
+
if (!matrix)
|
|
2824
|
+
return { ...result, status: "Killed" };
|
|
2825
|
+
const ran = new Set(files);
|
|
2826
|
+
const reported = new Set([...baseline.reportedFiles].filter((f) => ran.has(f)));
|
|
2827
|
+
const killedBy = killers(cases, reported, cfg.cwd);
|
|
2828
|
+
return { ...result, status: "Killed", killedBy, ...contractAttribution(cfg, cases, killedBy) };
|
|
232
2829
|
}
|
|
233
|
-
function
|
|
234
|
-
if (
|
|
235
|
-
return
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return
|
|
2830
|
+
function contractAttribution(cfg, cases, killedBy) {
|
|
2831
|
+
if (cfg.contracts === "off")
|
|
2832
|
+
return {};
|
|
2833
|
+
const kills = contractKills(cases, cfg.cwd);
|
|
2834
|
+
if (kills.length === 0)
|
|
2835
|
+
return {};
|
|
2836
|
+
const killedByContract = sortedUnique(kills.flatMap((k) => k.contracts));
|
|
2837
|
+
const failed = cases.filter((c) => c.status === "failed").length;
|
|
2838
|
+
const contractOnly = kills.length === failed && killedBy.length === failed;
|
|
2839
|
+
return { killedByContract, ...contractOnly ? { contractOnly: true } : {} };
|
|
243
2840
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
2841
|
+
function matchedNothing(cases) {
|
|
2842
|
+
return cases.length > 0 && cases.every((c) => c.status === "skipped");
|
|
2843
|
+
}
|
|
2844
|
+
function fromCache(p, entry) {
|
|
2845
|
+
const bySig = new Map(entry.results.map((r) => [r.sig, r]));
|
|
2846
|
+
const results = [...p.mutants, ...p.compileErrors].map((mutant) => {
|
|
2847
|
+
const hit = bySig.get(mutantSig(mutant));
|
|
2848
|
+
if (!hit)
|
|
2849
|
+
return { mutant, status: "RuntimeError", statusReason: "missing from the cache entry" };
|
|
2850
|
+
const { sig: _sig, ...rest } = hit;
|
|
2851
|
+
return { mutant, ...rest };
|
|
2852
|
+
});
|
|
2853
|
+
results.sort((a, b) => a.mutant.start - b.mutant.start);
|
|
2854
|
+
return {
|
|
2855
|
+
file: p.file,
|
|
2856
|
+
coveringTests: p.covering,
|
|
2857
|
+
baselineMs: entry.baselineMs,
|
|
2858
|
+
inTree: entry.inTree,
|
|
2859
|
+
cached: true,
|
|
2860
|
+
tests: entry.tests,
|
|
2861
|
+
results,
|
|
2862
|
+
contracts: p.contracts
|
|
2863
|
+
};
|
|
2864
|
+
}
|
|
2865
|
+
function testsFor(cfg, p, baseline, results) {
|
|
2866
|
+
const tests = [...baseline.tests];
|
|
2867
|
+
const known = new Set(tests.map((t) => t.id));
|
|
2868
|
+
for (const t of p.coverageTests) {
|
|
2869
|
+
if (!known.has(t.id)) {
|
|
2870
|
+
known.add(t.id);
|
|
2871
|
+
tests.push(t);
|
|
256
2872
|
}
|
|
257
|
-
const reason = staleReason(d, await fileHash(file), sig);
|
|
258
|
-
if (reason)
|
|
259
|
-
stale.push(` ${file} [${d.verdict}] — ${reason}`);
|
|
260
|
-
else
|
|
261
|
-
settled.push(` ${file} [${d.verdict}] ${d.rationale ? `— ${d.rationale}` : ""}`);
|
|
262
2873
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
2874
|
+
const used = new Set(results.flatMap((r) => r.killedBy ?? []));
|
|
2875
|
+
for (const file of p.covering) {
|
|
2876
|
+
const id = loadFailureId(cfg.cwd, file);
|
|
2877
|
+
if (used.has(id) && !known.has(id))
|
|
2878
|
+
tests.push({ id, file, name: LOAD_FAILURE_NAME, line: 0 });
|
|
266
2879
|
}
|
|
267
|
-
|
|
268
|
-
out.push(`NEEDS REVIEW (subsumed files with no decision — ${needsReview.length})`);
|
|
269
|
-
out.push(needsReview.length ? needsReview.join(`
|
|
270
|
-
`) : " none");
|
|
271
|
-
out.push(`
|
|
272
|
-
STALE (decision's basis drifted — re-review — ${stale.length})`);
|
|
273
|
-
out.push(stale.length ? stale.join(`
|
|
274
|
-
`) : " none");
|
|
275
|
-
out.push(`
|
|
276
|
-
SETTLED (fresh decisions, suppressed from action list — ${settled.length})`);
|
|
277
|
-
out.push(settled.length ? settled.join(`
|
|
278
|
-
`) : " none");
|
|
279
|
-
return out.join(`
|
|
280
|
-
`);
|
|
2880
|
+
return tests;
|
|
281
2881
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
2882
|
+
function unrun(mutant, statusReason) {
|
|
2883
|
+
return { mutant, status: "RuntimeError", statusReason };
|
|
2884
|
+
}
|
|
2885
|
+
function notRun(p, baseline, mutant) {
|
|
2886
|
+
if (!p.covered) {
|
|
2887
|
+
const reason = p.selections ? "no test ran this target" : "no test file imports this target";
|
|
2888
|
+
return { mutant, status: "NoCoverage", statusReason: reason };
|
|
286
2889
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
2890
|
+
if (baseline?.contractViolations)
|
|
2891
|
+
return unrun(mutant, baseline.reason);
|
|
2892
|
+
if (!selected(p, mutant)) {
|
|
2893
|
+
return { mutant, status: "NoCoverage", statusReason: "no test ran this code", coveredBy: [] };
|
|
291
2894
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
2895
|
+
if (!baseline?.ok)
|
|
2896
|
+
return unrun(mutant, baseline?.reason);
|
|
2897
|
+
return null;
|
|
2898
|
+
}
|
|
2899
|
+
function summarise(cfg, p, baseline, byId, invalid, workdirs) {
|
|
2900
|
+
const results = p.mutants.map((mutant) => {
|
|
2901
|
+
if (mutant.ignoredReason)
|
|
2902
|
+
return { mutant, status: "Ignored", statusReason: mutant.ignoredReason };
|
|
2903
|
+
const typeError = invalid.get(mutant.id);
|
|
2904
|
+
if (typeError)
|
|
2905
|
+
return { mutant, status: "CompileError", statusReason: typeError };
|
|
2906
|
+
return notRun(p, baseline, mutant) ?? byId.get(mutant.id) ?? unrun(mutant, "mutant was not run");
|
|
2907
|
+
});
|
|
2908
|
+
for (const mutant of p.compileErrors) {
|
|
2909
|
+
results.push({ mutant, status: "CompileError", statusReason: "mutated source does not parse" });
|
|
2910
|
+
}
|
|
2911
|
+
results.sort((a, b) => a.mutant.start - b.mutant.start);
|
|
2912
|
+
return {
|
|
2913
|
+
file: p.file,
|
|
2914
|
+
coveringTests: p.covering,
|
|
2915
|
+
baselineMs: baseline?.ok ? Math.round(baseline.ms) : null,
|
|
2916
|
+
inTree: baseline?.inTree ?? !workdirs,
|
|
2917
|
+
cached: false,
|
|
2918
|
+
tests: baseline?.ok ? testsFor(cfg, p, baseline, results) : p.coverageTests,
|
|
2919
|
+
results,
|
|
2920
|
+
contracts: p.contracts
|
|
300
2921
|
};
|
|
301
|
-
await Bun.write(logPath, (await Bun.file(logPath).exists() ? await Bun.file(logPath).text() : "") + JSON.stringify(record) + `
|
|
302
|
-
`);
|
|
303
|
-
console.log(`✓ recorded: ${file} [${verdict}]${rationale ? ` — ${rationale}` : ""}`);
|
|
304
2922
|
}
|
|
305
|
-
|
|
2923
|
+
async function run(cfg, hooks, workdirs) {
|
|
2924
|
+
const log = hooks.log ?? (() => {
|
|
2925
|
+
return;
|
|
2926
|
+
});
|
|
2927
|
+
const started = performance.now();
|
|
2928
|
+
const preloads = [...cfg.testPreload, preloadPath()];
|
|
2929
|
+
const beforeRun = snapshotWorktree(cfg.cwd);
|
|
2930
|
+
const { prepared, testFiles } = await prepare(cfg, preloads, workdirs, log);
|
|
2931
|
+
const totalMutants = prepared.reduce((n, p) => n + p.mutants.length, 0);
|
|
2932
|
+
const hits = prepared.filter((p) => p.cached).length;
|
|
2933
|
+
log(`${prepared.length} target(s), ${totalMutants} mutants, ` + (cfg.runner.kind === "command" ? `command \`${cfg.runner.argv.join(" ")}\`` : `${testFiles} test files in scope`) + `, ${cfg.mode} mode` + (cfg.runner.kind === "bun-test" ? `, ${cfg.coverage} coverage` : "") + (cfg.cacheDir ? `, ${hits} target(s) from cache` : ""));
|
|
2934
|
+
const fresh = prepared.filter((p) => !p.cached && p.covered);
|
|
2935
|
+
const [measured, typed] = await Promise.all([
|
|
2936
|
+
pool(fresh, cfg.concurrency, (p) => measureBaseline(cfg, workdirs, preloads, p, log)),
|
|
2937
|
+
cfg.typecheck ? typecheckMutants(prepared.filter((p) => !p.cached).map((p) => ({ file: p.file, mutants: p.mutants.filter((m) => !m.ignoredReason) })), { cwd: cfg.cwd, log }) : null
|
|
2938
|
+
]);
|
|
2939
|
+
const invalid = typed?.invalid ?? new Map;
|
|
2940
|
+
const baselines = new Map(fresh.map((p, i) => [p, measured[i] ?? null]));
|
|
2941
|
+
const afterBaselines = snapshotWorktree(cfg.cwd);
|
|
2942
|
+
const contractViolations = collectContractViolations(cfg, baselines, log);
|
|
2943
|
+
const jobs = [];
|
|
2944
|
+
prepared.forEach((p, target) => {
|
|
2945
|
+
const baseline = baselines.get(p);
|
|
2946
|
+
if (!baseline?.ok)
|
|
2947
|
+
return;
|
|
2948
|
+
const timeoutMs = Math.round(baseline.ms * cfg.timeout.factor + cfg.timeout.extraMs);
|
|
2949
|
+
for (const mutant of p.mutants) {
|
|
2950
|
+
if (!mutant.ignoredReason && !invalid.has(mutant.id) && selected(p, mutant))
|
|
2951
|
+
jobs.push({ target, mutant, timeoutMs, inTree: baseline.inTree });
|
|
2952
|
+
}
|
|
2953
|
+
});
|
|
2954
|
+
jobs.sort((a, b) => b.timeoutMs - a.timeoutMs);
|
|
2955
|
+
let done = 0;
|
|
2956
|
+
const runResults = await pool(jobs, cfg.concurrency, async (job) => {
|
|
2957
|
+
const p = prepared[job.target];
|
|
2958
|
+
const baseline = p && baselines.get(p);
|
|
2959
|
+
if (!p || !baseline)
|
|
2960
|
+
throw new Error(`job for target ${job.target} has no baseline`);
|
|
2961
|
+
const result = await runMutant(cfg, workdirs, preloads, p, baseline, job);
|
|
2962
|
+
done++;
|
|
2963
|
+
hooks.progress?.(done, jobs.length);
|
|
2964
|
+
return result;
|
|
2965
|
+
});
|
|
2966
|
+
const byId = new Map(runResults.map((r) => [r.mutant.id, r]));
|
|
2967
|
+
const targets = prepared.map((p) => p.cached ? fromCache(p, p.cached) : summarise(cfg, p, baselines.get(p) ?? null, byId, invalid, workdirs));
|
|
2968
|
+
const afterMutants = snapshotWorktree(cfg.cwd);
|
|
2969
|
+
const strayWrites = beforeRun ? {
|
|
2970
|
+
root: beforeRun.root,
|
|
2971
|
+
baseline: newPaths(beforeRun, afterBaselines),
|
|
2972
|
+
mutants: newPaths(afterBaselines, afterMutants)
|
|
2973
|
+
} : null;
|
|
2974
|
+
for (const path of strayWrites?.mutants ?? [])
|
|
2975
|
+
log(`warning: a mutant run wrote ${path}`);
|
|
2976
|
+
if (cfg.cacheDir) {
|
|
2977
|
+
targets.forEach((t, i) => {
|
|
2978
|
+
const p = prepared[i];
|
|
2979
|
+
if (!cfg.cacheDir || !p?.cacheKey || t.cached || t.baselineMs === null)
|
|
2980
|
+
return;
|
|
2981
|
+
writeCache(cfg.cacheDir, p.cacheKey, {
|
|
2982
|
+
baselineMs: t.baselineMs,
|
|
2983
|
+
inTree: t.inTree,
|
|
2984
|
+
tests: t.tests,
|
|
2985
|
+
results: t.results.map(cachedResult)
|
|
2986
|
+
});
|
|
2987
|
+
});
|
|
2988
|
+
}
|
|
2989
|
+
const result = {
|
|
2990
|
+
targets,
|
|
2991
|
+
wallMs: Math.round(performance.now() - started),
|
|
2992
|
+
strayWrites,
|
|
2993
|
+
contractViolations
|
|
2994
|
+
};
|
|
2995
|
+
await writeReport(await toMutationReport(result, cfg.cwd, { contracts: cfg.contracts, runner: cfg.runner.kind }), cfg.reportPath);
|
|
2996
|
+
return result;
|
|
2997
|
+
}
|
|
2998
|
+
function mutationScore(results) {
|
|
2999
|
+
let detected = 0;
|
|
3000
|
+
let valid = 0;
|
|
3001
|
+
for (const r of results) {
|
|
3002
|
+
if (r.status === "Killed" || r.status === "Timeout")
|
|
3003
|
+
detected++;
|
|
3004
|
+
if (r.status === "Killed" || r.status === "Timeout" || r.status === "Survived" || r.status === "NoCoverage")
|
|
3005
|
+
valid++;
|
|
3006
|
+
}
|
|
3007
|
+
return valid === 0 ? null : 100 * detected / valid;
|
|
3008
|
+
}
|
|
306
3009
|
|
|
307
|
-
// tools/mutate/src/
|
|
308
|
-
import {
|
|
309
|
-
import { join as
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
3010
|
+
// tools/mutate/src/engine-config.ts
|
|
3011
|
+
import { availableParallelism } from "node:os";
|
|
3012
|
+
import { dirname as dirname7, join as join10, resolve as resolve4 } from "node:path";
|
|
3013
|
+
import { Glob as Glob3 } from "bun";
|
|
3014
|
+
var RANGE = /^(.*?):(\d+)(?::\d+)?-(\d+)(?::\d+)?$/;
|
|
3015
|
+
async function scanGlob(cwd, pattern) {
|
|
3016
|
+
const out = [];
|
|
3017
|
+
for await (const f of new Glob3(pattern).scan({ cwd, absolute: true, onlyFiles: true })) {
|
|
3018
|
+
if (!f.includes("/node_modules/"))
|
|
3019
|
+
out.push(f);
|
|
3020
|
+
}
|
|
3021
|
+
return out;
|
|
314
3022
|
}
|
|
315
|
-
function
|
|
316
|
-
|
|
317
|
-
|
|
3023
|
+
function parseEntry(entry) {
|
|
3024
|
+
const range = RANGE.exec(entry);
|
|
3025
|
+
if (!range?.[1])
|
|
3026
|
+
return { pattern: entry };
|
|
3027
|
+
return { pattern: range[1], lines: { from: Number(range[2]), to: Number(range[3]) } };
|
|
3028
|
+
}
|
|
3029
|
+
async function expandMutateEntries(cwd, entries) {
|
|
3030
|
+
const negated = entries.filter((x) => x.startsWith("!"));
|
|
3031
|
+
const excluded = new Set((await Promise.all(negated.map((e) => scanGlob(cwd, e.slice(1))))).flat());
|
|
3032
|
+
const targets = new Map;
|
|
3033
|
+
for (const entry of entries.filter((x) => !x.startsWith("!"))) {
|
|
3034
|
+
const { pattern, lines } = parseEntry(entry);
|
|
3035
|
+
const files = (await scanGlob(cwd, pattern)).filter((f) => !excluded.has(f));
|
|
3036
|
+
for (const f of files)
|
|
3037
|
+
targets.set(f, lines ? { file: f, lines } : { file: f });
|
|
318
3038
|
}
|
|
319
|
-
return [
|
|
3039
|
+
return [...targets.values()].sort((a, b) => a.file.localeCompare(b.file));
|
|
320
3040
|
}
|
|
321
|
-
function
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
3041
|
+
async function changedSince(cwd, ref) {
|
|
3042
|
+
const proc = Bun.spawn(["git", "diff", "--name-only", "--relative", ref], {
|
|
3043
|
+
cwd,
|
|
3044
|
+
stdout: "pipe",
|
|
3045
|
+
stderr: "pipe"
|
|
3046
|
+
});
|
|
3047
|
+
const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
3048
|
+
if (code !== 0)
|
|
3049
|
+
throw new Error(`git diff --name-only ${ref} failed: ${await new Response(proc.stderr).text()}`);
|
|
3050
|
+
return new Set(out.split(`
|
|
3051
|
+
`).filter(Boolean).map((p) => resolve4(cwd, p)));
|
|
3052
|
+
}
|
|
3053
|
+
async function selectTargets(dir, config, args) {
|
|
3054
|
+
const entries = args.files ? args.files.split(",").map((s) => s.trim()).filter(Boolean) : config.targets;
|
|
3055
|
+
const targets = await expandMutateEntries(dir, entries);
|
|
3056
|
+
if (!args.since)
|
|
3057
|
+
return targets;
|
|
3058
|
+
const changed = await changedSince(dir, args.since);
|
|
3059
|
+
return targets.filter((t) => changed.has(t.file));
|
|
3060
|
+
}
|
|
3061
|
+
function resolveConcurrency(flag, fromConfig) {
|
|
3062
|
+
if (flag === undefined)
|
|
3063
|
+
return fromConfig ?? Math.max(1, availableParallelism() - 1);
|
|
3064
|
+
const n = Number(flag);
|
|
3065
|
+
if (!(Number.isInteger(n) && n > 0)) {
|
|
3066
|
+
throw new Error(`--concurrency must be a positive integer, got "${flag}"`);
|
|
325
3067
|
}
|
|
326
|
-
return
|
|
3068
|
+
return n;
|
|
327
3069
|
}
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
try {
|
|
333
|
-
pluginRef = resolvePluginRef(projectDir);
|
|
334
|
-
} catch {
|
|
335
|
-
throw new Error("Cannot resolve @fairfox/polly from here. Run `polly mutate init` in a project that depends on @fairfox/polly.");
|
|
3070
|
+
function resolveMode(flag, config) {
|
|
3071
|
+
const mode = flag ?? config.mode ?? "fast";
|
|
3072
|
+
if (mode !== "fast" && mode !== "matrix") {
|
|
3073
|
+
throw new Error(`--mode ${mode}: expected fast or matrix`);
|
|
336
3074
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
3075
|
+
if (mode === "matrix" && config.runner?.kind === "command") {
|
|
3076
|
+
throw new Error("matrix mode needs per-test results; the command runner has none (use fast)");
|
|
3077
|
+
}
|
|
3078
|
+
return mode;
|
|
3079
|
+
}
|
|
3080
|
+
function resolveCoverage(flag, config) {
|
|
3081
|
+
const coverage = flag ?? config.coverage ?? "dynamic";
|
|
3082
|
+
if (coverage !== "dynamic" && coverage !== "static") {
|
|
3083
|
+
throw new Error(`--coverage ${coverage}: expected dynamic or static`);
|
|
3084
|
+
}
|
|
3085
|
+
return coverage;
|
|
3086
|
+
}
|
|
3087
|
+
function resolveContracts(flag, config) {
|
|
3088
|
+
const contracts = flag ?? config.contracts ?? "all";
|
|
3089
|
+
if (!isContractsMode(contracts)) {
|
|
3090
|
+
throw new Error(`--contracts ${contracts}: expected off, ensures or all`);
|
|
3091
|
+
}
|
|
3092
|
+
return contracts;
|
|
3093
|
+
}
|
|
3094
|
+
async function engineConfigFor(cfg, args) {
|
|
3095
|
+
if (!cfg.mutation) {
|
|
3096
|
+
throw new Error(cfg.mutationError ?? `no ${MUTATION_CONFIG_FILE} found — run 'polly mutate init', or pass --config <path>`);
|
|
3097
|
+
}
|
|
3098
|
+
const { config, path } = cfg.mutation;
|
|
3099
|
+
const dir = dirname7(path);
|
|
3100
|
+
const mode = resolveMode(args.mode, config);
|
|
3101
|
+
const command = config.runner?.kind === "command" ? config.runner : null;
|
|
3102
|
+
return {
|
|
3103
|
+
cwd: dir,
|
|
3104
|
+
targets: await selectTargets(dir, config, args),
|
|
3105
|
+
runner: command ?? { kind: "bun-test" },
|
|
3106
|
+
testInclude: config.tests?.include ?? [],
|
|
3107
|
+
testPreload: (config.tests?.preload ?? []).map((p) => resolve4(dir, p)),
|
|
3108
|
+
mode,
|
|
3109
|
+
coverage: command ? "static" : resolveCoverage(args.coverage, config),
|
|
3110
|
+
concurrency: resolveConcurrency(args.concurrency, config.concurrency),
|
|
3111
|
+
timeout: config.timeout ?? { factor: 2, extraMs: 3000 },
|
|
3112
|
+
typecheck: !args.noTypecheck && (config.typecheck ?? true),
|
|
3113
|
+
excludeMutators: config.mutators?.exclude ?? [],
|
|
3114
|
+
ignoreVerifyCallsites: config.ignoreVerifyCallsites ?? true,
|
|
3115
|
+
contracts: resolveContracts(args.contracts, config),
|
|
3116
|
+
scratchCwd: !(args.inTree || command),
|
|
3117
|
+
cacheDir: args.noCache ? null : join10(dir, ".polly", "mutate", "cache"),
|
|
3118
|
+
reportPath: cfg.reportPath
|
|
367
3119
|
};
|
|
368
|
-
await Bun.write(configPath, `${JSON.stringify(config, null, 2)}
|
|
369
|
-
`);
|
|
370
|
-
return { configPath, created: true, pluginRef, mutate, testGlob, warnings };
|
|
371
3120
|
}
|
|
372
3121
|
|
|
373
|
-
// tools/mutate/src/run.ts
|
|
374
|
-
import { resolve as resolve3 } from "node:path";
|
|
375
|
-
|
|
376
3122
|
// tools/mutate/src/report.ts
|
|
3123
|
+
function contractsSection(db, p) {
|
|
3124
|
+
const meta = new Map(queryAll(db, "SELECT key, value FROM report_meta").map((r) => [
|
|
3125
|
+
r.key,
|
|
3126
|
+
r.value
|
|
3127
|
+
]));
|
|
3128
|
+
const mode = meta.get("contracts_mode");
|
|
3129
|
+
if (mode === undefined)
|
|
3130
|
+
return;
|
|
3131
|
+
const nSites = queryGet(db, "SELECT count(*) n FROM contract_sites")?.n ?? 0;
|
|
3132
|
+
p(`
|
|
3133
|
+
CONTRACTS (requires/ensures as kill oracles — mode ${mode}, ${nSites} site(s))`);
|
|
3134
|
+
if (mode === "off") {
|
|
3135
|
+
p(` off — the sites are inventoried only; 'polly mutate run --contracts all' makes each one fail the test that falsifies it`);
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
const violations = queryAll(db, "SELECT target, test, contract_id FROM contract_violations ORDER BY target, test, contract_id");
|
|
3139
|
+
if (violations.length > 0) {
|
|
3140
|
+
p(` TEST VIOLATES CONTRACT (the unmutated code fails a contract under a test: ${violations.length})`);
|
|
3141
|
+
for (const v of violations) {
|
|
3142
|
+
p(` ${v.target}: ${v.test}${v.contract_id ? ` → ${v.contract_id}` : ""}`);
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
if (meta.get("runner") === "command") {
|
|
3146
|
+
p(` command runner: no per-test results, so kills are not attributed to contracts; compare the killed count against a --contracts off run`);
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
if (mode === "ensures")
|
|
3150
|
+
p(` requires sites are inventoried, not enforced (mode ensures)`);
|
|
3151
|
+
const only = queryAll(db, "SELECT file, line, mutator, count(*) n FROM mutants WHERE contract_only=1 GROUP BY file, line, mutator ORDER BY file, line");
|
|
3152
|
+
const onlyTotal = only.reduce((a, r) => a + r.n, 0);
|
|
3153
|
+
p(` CONTRACT-ONLY KILLS (no test assertion caught these; a contract did: ${onlyTotal})`);
|
|
3154
|
+
for (const r of only)
|
|
3155
|
+
p(` ${r.file}:${r.line} ${r.mutator}${r.n > 1 ? ` ×${r.n}` : ""}`);
|
|
3156
|
+
if (nSites === 0)
|
|
3157
|
+
return;
|
|
3158
|
+
const sites = queryAll(db, `SELECT s.id, s.primitive, s.message,
|
|
3159
|
+
(SELECT count(*) FROM contract_kills k WHERE k.contract_id = s.id) n,
|
|
3160
|
+
(SELECT count(*) FROM mutants m
|
|
3161
|
+
WHERE m.file = s.file AND s.handler_from IS NOT NULL
|
|
3162
|
+
AND m.line BETWEEN s.handler_from AND s.handler_to
|
|
3163
|
+
AND m.status IN ('Killed', 'Survived', 'Timeout')) exercised
|
|
3164
|
+
FROM contract_sites s ORDER BY n DESC, s.id`);
|
|
3165
|
+
p(` KILLS PER CONTRACT`);
|
|
3166
|
+
for (const s of sites) {
|
|
3167
|
+
p(` ${s.id} ${s.primitive} ${s.n} kill(s)${s.message ? ` “${s.message}”` : ""}`);
|
|
3168
|
+
}
|
|
3169
|
+
const toothless = sites.filter((s) => s.n === 0 && s.exercised > 0);
|
|
3170
|
+
const idle = sites.filter((s) => s.n === 0 && s.exercised === 0);
|
|
3171
|
+
p(` CONTRACTS WITHOUT TEETH (the handler's mutants ran, the contract never fired: ${toothless.length})`);
|
|
3172
|
+
if (toothless.length === 0)
|
|
3173
|
+
p(` none`);
|
|
3174
|
+
for (const s of toothless)
|
|
3175
|
+
p(` ${s.id} ${s.primitive}${s.message ? ` “${s.message}”` : ""}`);
|
|
3176
|
+
if (idle.length > 0) {
|
|
3177
|
+
p(` HANDLER NOT EXERCISED (no mutant in the contract's handler ran: ${idle.length})`);
|
|
3178
|
+
for (const s of idle)
|
|
3179
|
+
p(` ${s.id} ${s.primitive}`);
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
377
3182
|
function greedyCover(units, universe) {
|
|
378
3183
|
const remaining = new Set(universe);
|
|
379
3184
|
const chosen = [];
|
|
@@ -404,11 +3209,16 @@ function report(db, opts = {}) {
|
|
|
404
3209
|
const matrixComplete = opts.matrixComplete ?? true;
|
|
405
3210
|
const out = [];
|
|
406
3211
|
const p = (s = "") => out.push(s);
|
|
407
|
-
const tot = queryGet(db, "SELECT count(*) n, sum(status='Killed') killed, sum(status='Survived') survived, sum(status='Timeout') timeout, sum(status='NoCoverage') nocov FROM mutants") ?? { n: 0, killed: 0, survived: 0, timeout: 0, nocov: 0 };
|
|
3212
|
+
const tot = queryGet(db, "SELECT count(*) n, sum(status='Killed') killed, sum(status='Survived') survived, sum(status='Timeout') timeout, sum(status='NoCoverage') nocov, sum(status='CompileError') invalid, sum(status='Ignored') ignored FROM mutants") ?? { n: 0, killed: 0, survived: 0, timeout: 0, nocov: 0, invalid: 0, ignored: 0 };
|
|
408
3213
|
const nTests = queryGet(db, "SELECT count(*) n FROM tests")?.n ?? 0;
|
|
409
3214
|
const detected = tot.killed + tot.timeout;
|
|
410
|
-
const
|
|
411
|
-
|
|
3215
|
+
const valid = detected + tot.survived + tot.nocov;
|
|
3216
|
+
const score = valid === 0 ? "n/a" : `${(detected / valid * 100).toFixed(1)}%`;
|
|
3217
|
+
const excluded = [
|
|
3218
|
+
tot.invalid > 0 ? `${tot.invalid} invalid` : "",
|
|
3219
|
+
tot.ignored > 0 ? `${tot.ignored} ignored` : ""
|
|
3220
|
+
].filter(Boolean).map((s) => ` · ${s}`).join("");
|
|
3221
|
+
p(`MUTATION score ${score} ${tot.killed} killed · ${tot.timeout} timeout · ${tot.survived} survived · ${tot.nocov} no-coverage${excluded} (${tot.n} mutants, ${nTests} tests)`);
|
|
412
3222
|
if (matrixComplete) {
|
|
413
3223
|
const mult = queryAll(db, `
|
|
414
3224
|
SELECT CASE WHEN c=1 THEN '1' WHEN c=2 THEN '2' WHEN c BETWEEN 3 AND 5 THEN '3-5' ELSE '6+' END bucket, count(*) n
|
|
@@ -473,26 +3283,28 @@ SUBSUMED CASES (${subsumedCases.length} individual tests whose every kill is ca
|
|
|
473
3283
|
p(`
|
|
474
3284
|
⚠ KILL MATRIX INCOMPLETE — redundancy/subsumption analysis suppressed.`);
|
|
475
3285
|
p(` Every mutant has at most one recorded killer, so "which tests are redundant"`);
|
|
476
|
-
p(` cannot be derived.
|
|
477
|
-
p(`
|
|
3286
|
+
p(` cannot be derived. Run 'polly mutate run --mode matrix' (every`);
|
|
3287
|
+
p(` killer recorded), or 'polly mutate verify' to diagnose. Score/gaps/theatre below still hold.`);
|
|
478
3288
|
}
|
|
479
|
-
const
|
|
480
|
-
const
|
|
3289
|
+
const byLine = (status) => queryAll(db, `SELECT file, line, mutator, count(*) n FROM mutants WHERE status='${status}' GROUP BY file, line, mutator ORDER BY file, line`);
|
|
3290
|
+
const lineOf = (r) => ` ${r.file}:${r.line} ${r.mutator}${r.n > 1 ? ` ×${r.n}` : ""}`;
|
|
3291
|
+
const gaps = byLine("NoCoverage");
|
|
481
3292
|
const gapTotal = gaps.reduce((a, r) => a + r.n, 0);
|
|
482
3293
|
p(`
|
|
483
3294
|
GAPS (NoCoverage — no test executes this code: ${gapTotal})`);
|
|
484
3295
|
if (gapTotal === 0)
|
|
485
3296
|
p(` none — every mutable location is reached by some test`);
|
|
486
3297
|
for (const r of gaps)
|
|
487
|
-
p(
|
|
488
|
-
const theatre =
|
|
3298
|
+
p(lineOf(r));
|
|
3299
|
+
const theatre = byLine("Survived");
|
|
489
3300
|
const theatreTotal = theatre.reduce((a, r) => a + r.n, 0);
|
|
490
3301
|
p(`
|
|
491
3302
|
THEATRE (Survived — code is covered but no test detects the mutation: ${theatreTotal})`);
|
|
492
3303
|
if (theatreTotal === 0)
|
|
493
3304
|
p(` none`);
|
|
494
3305
|
for (const r of theatre)
|
|
495
|
-
p(
|
|
3306
|
+
p(lineOf(r));
|
|
3307
|
+
contractsSection(db, p);
|
|
496
3308
|
return out.join(`
|
|
497
3309
|
`);
|
|
498
3310
|
}
|
|
@@ -512,20 +3324,6 @@ function isMatrixComplete(report) {
|
|
|
512
3324
|
}
|
|
513
3325
|
async function verifyMatrix(opts = {}) {
|
|
514
3326
|
const reportPath = opts.reportPath ?? DEFAULT_REPORT2;
|
|
515
|
-
if (opts.run) {
|
|
516
|
-
console.log("Running mutation pass (this can take many minutes for a large mutate set)...");
|
|
517
|
-
const cmd = [
|
|
518
|
-
"bunx",
|
|
519
|
-
"stryker",
|
|
520
|
-
"run",
|
|
521
|
-
...opts.strykerConfig ? ["--configFile", opts.strykerConfig] : []
|
|
522
|
-
];
|
|
523
|
-
const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" });
|
|
524
|
-
const code = await proc.exited;
|
|
525
|
-
if (code !== 0) {
|
|
526
|
-
return { ok: false, checks: [{ name: "stryker run", ok: false, detail: `exited ${code}` }] };
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
3327
|
if (!await Bun.file(reportPath).exists()) {
|
|
530
3328
|
return {
|
|
531
3329
|
ok: false,
|
|
@@ -533,7 +3331,7 @@ async function verifyMatrix(opts = {}) {
|
|
|
533
3331
|
{
|
|
534
3332
|
name: "report exists",
|
|
535
3333
|
ok: false,
|
|
536
|
-
detail: `no report at ${reportPath}. Run
|
|
3334
|
+
detail: `no report at ${reportPath}. Run 'polly mutate verify --run' first.`
|
|
537
3335
|
}
|
|
538
3336
|
]
|
|
539
3337
|
};
|
|
@@ -558,24 +3356,118 @@ async function verifyMatrix(opts = {}) {
|
|
|
558
3356
|
add("coverage collected (not all NoCoverage)", noCov < mutants.length, `${noCov}/${mutants.length} NoCoverage — all-NoCoverage means the coverage dump broke`);
|
|
559
3357
|
return { ok: checks.every((c) => c.ok), checks };
|
|
560
3358
|
}
|
|
561
|
-
if (false) {}
|
|
562
3359
|
|
|
563
3360
|
// tools/mutate/src/run.ts
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
3361
|
+
function resolveEngine(args) {
|
|
3362
|
+
const engine = args.engine ?? "bun";
|
|
3363
|
+
if (engine === "bun")
|
|
3364
|
+
return engine;
|
|
3365
|
+
if (engine === "stryker") {
|
|
3366
|
+
throw new Error("--engine stryker was removed; the bun engine (the default) replaces it");
|
|
3367
|
+
}
|
|
3368
|
+
throw new Error(`unknown --engine "${engine}" (expected bun)`);
|
|
3369
|
+
}
|
|
3370
|
+
function deprecationNotice(cfg) {
|
|
3371
|
+
const m = cfg.mutation;
|
|
3372
|
+
if (m?.format !== "stryker")
|
|
3373
|
+
return null;
|
|
3374
|
+
return [
|
|
3375
|
+
`deprecated: reading ${relative7(cfg.cwd, m.path) || m.path} — Stryker is no longer supported; its config is read for migration only.`,
|
|
3376
|
+
" 'polly mutate migrate --write' writes the equivalent polly.mutate.ts.",
|
|
3377
|
+
...m.notes.map((n) => ` ${n}`)
|
|
3378
|
+
].join(`
|
|
3379
|
+
`);
|
|
3380
|
+
}
|
|
3381
|
+
function thresholdFailure(score, cfg) {
|
|
3382
|
+
const floor = cfg.mutation?.config.thresholds?.break;
|
|
3383
|
+
if (floor === undefined || score === null || score >= floor)
|
|
3384
|
+
return null;
|
|
3385
|
+
return `mutation score ${score.toFixed(1)}% is below thresholds.break ${floor}%`;
|
|
3386
|
+
}
|
|
3387
|
+
async function runBunEngine(cfg, args) {
|
|
3388
|
+
const notice = deprecationNotice(cfg);
|
|
3389
|
+
if (notice)
|
|
3390
|
+
console.log(`${notice}
|
|
3391
|
+
`);
|
|
3392
|
+
const engineCfg = await engineConfigFor(cfg, args);
|
|
3393
|
+
if (engineCfg.targets.length === 0) {
|
|
3394
|
+
console.log(args.since ? `no mutate targets changed since ${args.since}` : "no mutate targets matched");
|
|
3395
|
+
return 0;
|
|
3396
|
+
}
|
|
3397
|
+
let lastPct = -1;
|
|
3398
|
+
const result = await runEngine(engineCfg, {
|
|
3399
|
+
log: (line) => console.log(line),
|
|
3400
|
+
progress: (done, total) => {
|
|
3401
|
+
const pct = Math.floor(100 * done / total);
|
|
3402
|
+
if (pct >= lastPct + 10 || done === total) {
|
|
3403
|
+
lastPct = pct;
|
|
3404
|
+
console.log(` ${done}/${total} mutants`);
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
573
3407
|
});
|
|
574
|
-
|
|
3408
|
+
console.log(`
|
|
3409
|
+
file | tests | killed | survived | timeout | no-cov | invalid | other | score`);
|
|
3410
|
+
const all = result.targets.flatMap((t) => t.results);
|
|
3411
|
+
for (const t of result.targets) {
|
|
3412
|
+
const count = (s) => t.results.filter((r) => r.status === s).length;
|
|
3413
|
+
const other = t.results.length - count("Killed") - count("Survived") - count("Timeout") - count("NoCoverage") - count("CompileError");
|
|
3414
|
+
const score = mutationScore(t.results);
|
|
3415
|
+
console.log([
|
|
3416
|
+
relative7(engineCfg.cwd, t.file),
|
|
3417
|
+
t.coveringTests.length,
|
|
3418
|
+
count("Killed"),
|
|
3419
|
+
count("Survived"),
|
|
3420
|
+
count("Timeout"),
|
|
3421
|
+
count("NoCoverage"),
|
|
3422
|
+
count("CompileError"),
|
|
3423
|
+
other,
|
|
3424
|
+
score === null ? "n/a" : `${score.toFixed(1)}%`
|
|
3425
|
+
].join(" | "));
|
|
3426
|
+
}
|
|
3427
|
+
const total = mutationScore(all);
|
|
3428
|
+
console.log(`
|
|
3429
|
+
${all.length} mutants in ${(result.wallMs / 1000).toFixed(1)} s · score ${total === null ? "n/a" : `${total.toFixed(1)}%`} · report ${cfg.reportPath}`);
|
|
3430
|
+
const stray = result.strayWrites;
|
|
3431
|
+
if (stray && stray.mutants.length > 0) {
|
|
3432
|
+
console.log(`
|
|
3433
|
+
warning: mutant runs wrote ${stray.mutants.length} path(s) into ${stray.root} (not deleted — check, then remove):`);
|
|
3434
|
+
for (const path of stray.mutants)
|
|
3435
|
+
console.log(` ${path}`);
|
|
3436
|
+
}
|
|
3437
|
+
if (result.contractViolations.length > 0) {
|
|
3438
|
+
console.log(contractViolationsBlock(result, engineCfg.cwd));
|
|
3439
|
+
return 1;
|
|
3440
|
+
}
|
|
3441
|
+
const failure = thresholdFailure(total, cfg);
|
|
3442
|
+
if (failure) {
|
|
3443
|
+
console.log(`
|
|
3444
|
+
✗ ${failure}`);
|
|
3445
|
+
return 1;
|
|
3446
|
+
}
|
|
3447
|
+
return 0;
|
|
3448
|
+
}
|
|
3449
|
+
function contractViolationsBlock(result, cwd) {
|
|
3450
|
+
const sites = new Map(result.targets.flatMap((t) => t.contracts.map((c) => [`${relative7(cwd, c.file)}:${c.line}`, c])));
|
|
3451
|
+
const lines = [
|
|
3452
|
+
`
|
|
3453
|
+
✗ TEST VIOLATES CONTRACT (${result.contractViolations.length}; the mutants of these targets did not run)`
|
|
3454
|
+
];
|
|
3455
|
+
for (const v of result.contractViolations) {
|
|
3456
|
+
const where = v.contracts.length === 0 ? "" : ` → ${v.contracts.join(", ")}`;
|
|
3457
|
+
lines.push(` ${relative7(cwd, v.target)}: ${v.test}${where}`);
|
|
3458
|
+
for (const id of v.contracts) {
|
|
3459
|
+
const site = sites.get(id);
|
|
3460
|
+
if (site)
|
|
3461
|
+
lines.push(` ${site.primitive}${site.message ? ` “${site.message}”` : ""}`);
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
lines.push(" A test drives a handler outside its declared precondition, or a handler breaks its own", " postcondition. Fix the test or the contract; '--contracts ensures' leaves requires inert,", " '--contracts off' restores the no-op primitives.");
|
|
3465
|
+
return lines.join(`
|
|
3466
|
+
`);
|
|
575
3467
|
}
|
|
576
3468
|
async function reportFromFile(cfg) {
|
|
577
3469
|
if (!await Bun.file(cfg.reportPath).exists()) {
|
|
578
|
-
throw new Error(`no mutation report at ${cfg.reportPath}. Run 'polly mutate run'
|
|
3470
|
+
throw new Error(`no mutation report at ${cfg.reportPath}. Run 'polly mutate run' first, or pass --report <path>.`);
|
|
579
3471
|
}
|
|
580
3472
|
const mreport = await loadMutationReport(cfg.reportPath);
|
|
581
3473
|
const db = buildDb(mreport, cfg.dbPath);
|
|
@@ -585,18 +3477,6 @@ async function reportFromFile(cfg) {
|
|
|
585
3477
|
db.close();
|
|
586
3478
|
}
|
|
587
3479
|
}
|
|
588
|
-
async function presetAdvisory(cfg) {
|
|
589
|
-
if (!cfg.strykerConfigPath?.endsWith(".json"))
|
|
590
|
-
return null;
|
|
591
|
-
try {
|
|
592
|
-
const conf = await Bun.file(cfg.strykerConfigPath).json();
|
|
593
|
-
if ((conf.plugins ?? []).includes("@fairfox/polly/stryker"))
|
|
594
|
-
return null;
|
|
595
|
-
return "Tip: spread pollyStrykerPreset (from @fairfox/polly/stryker) into your Stryker config — " + "verify primitives (requires/ensures/…) are runtime no-ops and their mutants always survive, " + "dragging the score down with noise. See polly#143.";
|
|
596
|
-
} catch {
|
|
597
|
-
return null;
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
3480
|
|
|
601
3481
|
// tools/mutate/src/cli.ts
|
|
602
3482
|
var HELP = `polly mutate — mutation testing + useless-test detection
|
|
@@ -609,8 +3489,10 @@ theatre (code a test ran but never actually checked). How the matrix is built:
|
|
|
609
3489
|
tools/mutate/README.md.
|
|
610
3490
|
|
|
611
3491
|
Usage:
|
|
612
|
-
polly mutate init [--force] Scaffold a
|
|
613
|
-
polly mutate [
|
|
3492
|
+
polly mutate init [--force] Scaffold a polly.mutate.ts for this project
|
|
3493
|
+
polly mutate migrate [--write] Convert stryker.conf.json to polly.mutate.ts (prints it
|
|
3494
|
+
unless --write; --force overwrites an existing one)
|
|
3495
|
+
polly mutate [run] Run mutation testing, then print the useless-test report
|
|
614
3496
|
polly mutate report Print the report from an existing mutation.json
|
|
615
3497
|
polly mutate decisions Review verdicts: needs-review / stale / settled
|
|
616
3498
|
polly mutate decisions decide <file> <keep|prune|rewrite|investigate> "<rationale>"
|
|
@@ -618,16 +3500,35 @@ Usage:
|
|
|
618
3500
|
polly mutate help Show this help
|
|
619
3501
|
|
|
620
3502
|
Flags:
|
|
621
|
-
--config <path>
|
|
3503
|
+
--config <path> polly.mutate.ts, or a stryker.conf.json (default: ./polly.mutate.ts,
|
|
3504
|
+
else the discovered Stryker config, deprecated)
|
|
622
3505
|
--report <path> mutation report JSON (default: reports/mutation/mutation.json)
|
|
623
3506
|
--decisions <path> verdict log (default: .polly/test-debt/decisions.jsonl)
|
|
624
3507
|
--db <path> intermediate SQLite (default: in-memory)
|
|
625
3508
|
--no-report 'run' only — skip the analysis
|
|
3509
|
+
--files <a,b> mutate only these files (globs and :from-to ranges allowed)
|
|
3510
|
+
--since <git-ref> mutate only targets changed since the ref
|
|
3511
|
+
--in-tree run tests in the project dir, not a scratch cwd
|
|
3512
|
+
--concurrency <n> parallel test processes (default: config, else cores-1)
|
|
3513
|
+
--mode <fast|matrix> fast stops each run at the first failing test (default
|
|
3514
|
+
unless the config sets mode);
|
|
3515
|
+
matrix runs every test and records each killer (the kill matrix)
|
|
3516
|
+
--coverage <dynamic|static> dynamic (default) runs one probed pass to find the
|
|
3517
|
+
tests that run each mutant, and marks unrun code NoCoverage;
|
|
3518
|
+
static runs every test file that imports the target
|
|
3519
|
+
--no-cache rerun targets whose inputs are unchanged
|
|
3520
|
+
--no-typecheck run mutants the project's tsconfig rejects (default: report
|
|
3521
|
+
them CompileError without a run; needs typescript-7)
|
|
3522
|
+
--contracts <off|ensures|all> POLLY_CONTRACTS for every test process: a false
|
|
3523
|
+
requires/ensures throws, so each contract kills the mutants
|
|
3524
|
+
that falsify it (default all; ensures leaves requires inert;
|
|
3525
|
+
a baseline that violates a contract stops the run)
|
|
626
3526
|
-h, --help
|
|
627
3527
|
|
|
628
|
-
The redundancy/subsumption sections need a COMPLETE kill matrix (
|
|
629
|
-
|
|
630
|
-
'polly mutate verify' diagnoses the matrix
|
|
3528
|
+
The redundancy/subsumption sections need a COMPLETE kill matrix ('--mode matrix').
|
|
3529
|
+
Without it the report degrades
|
|
3530
|
+
to score + gaps + theatre; 'polly mutate verify' diagnoses the matrix
|
|
3531
|
+
('verify --run' runs a matrix pass first).`;
|
|
631
3532
|
async function openDb(reportPath, dbPath) {
|
|
632
3533
|
if (!await Bun.file(reportPath).exists()) {
|
|
633
3534
|
console.log(`no mutation report at ${reportPath}. Run 'polly mutate run' first, or pass --report <path>.`);
|
|
@@ -635,6 +3536,46 @@ async function openDb(reportPath, dbPath) {
|
|
|
635
3536
|
}
|
|
636
3537
|
return buildDb(await loadMutationReport(reportPath), dbPath);
|
|
637
3538
|
}
|
|
3539
|
+
async function init(cwd, force) {
|
|
3540
|
+
const result = await initConfig(cwd, { force });
|
|
3541
|
+
if (!result.created) {
|
|
3542
|
+
console.log(`${result.configPath} already exists. Re-run with --force to overwrite.`);
|
|
3543
|
+
return 1;
|
|
3544
|
+
}
|
|
3545
|
+
console.log(`✓ wrote ${result.configPath}`);
|
|
3546
|
+
console.log(` import: ${result.specifier}`);
|
|
3547
|
+
console.log(` targets: ${result.mutate.join(", ")}`);
|
|
3548
|
+
console.log(` tests: ${result.testGlob}`);
|
|
3549
|
+
for (const w of result.warnings)
|
|
3550
|
+
console.log(` ⚠ ${w}`);
|
|
3551
|
+
console.log(`
|
|
3552
|
+
Next: polly mutate run`);
|
|
3553
|
+
return 0;
|
|
3554
|
+
}
|
|
3555
|
+
async function migrate(cwd, args) {
|
|
3556
|
+
const strykerPath = args.config ? resolve5(cwd, args.config) : (await findStrykerConfigs(cwd))[0] ?? null;
|
|
3557
|
+
if (!strykerPath) {
|
|
3558
|
+
console.log("no stryker.conf.json found — pass --config <path>");
|
|
3559
|
+
return 1;
|
|
3560
|
+
}
|
|
3561
|
+
const result = await migrateConfig(strykerPath, { write: args.write, force: args.force });
|
|
3562
|
+
for (const n of result.notes)
|
|
3563
|
+
console.log(`note: ${n}`);
|
|
3564
|
+
if (!args.write) {
|
|
3565
|
+
console.log(`
|
|
3566
|
+
// ${result.path}
|
|
3567
|
+
${result.source}`);
|
|
3568
|
+
console.log("Re-run with --write to write it.");
|
|
3569
|
+
return 0;
|
|
3570
|
+
}
|
|
3571
|
+
if (!result.written) {
|
|
3572
|
+
console.log(`${result.path} already exists. Re-run with --force to overwrite.`);
|
|
3573
|
+
return 1;
|
|
3574
|
+
}
|
|
3575
|
+
console.log(`✓ wrote ${result.path}`);
|
|
3576
|
+
console.log(` ${strykerPath} is no longer read once polly.mutate.ts exists; delete it.`);
|
|
3577
|
+
return 0;
|
|
3578
|
+
}
|
|
638
3579
|
async function main() {
|
|
639
3580
|
const args = parseMutateArgs(process.argv.slice(2));
|
|
640
3581
|
if (args.help) {
|
|
@@ -642,30 +3583,15 @@ async function main() {
|
|
|
642
3583
|
return 0;
|
|
643
3584
|
}
|
|
644
3585
|
const cwd = process.cwd();
|
|
3586
|
+
if (args.verb === "init")
|
|
3587
|
+
return await init(cwd, args.force);
|
|
3588
|
+
if (args.verb === "migrate")
|
|
3589
|
+
return await migrate(cwd, args);
|
|
645
3590
|
const cfg = await resolveMutateConfig(cwd, args);
|
|
646
3591
|
switch (args.verb) {
|
|
647
|
-
case "init": {
|
|
648
|
-
const result = await initConfig(cwd, { force: args.force });
|
|
649
|
-
if (!result.created) {
|
|
650
|
-
console.log(`stryker.conf.json already exists at ${result.configPath}. Re-run with --force to overwrite.`);
|
|
651
|
-
return 1;
|
|
652
|
-
}
|
|
653
|
-
console.log(`✓ wrote ${result.configPath}`);
|
|
654
|
-
console.log(` plugin: ${result.pluginRef}`);
|
|
655
|
-
console.log(` mutate: ${result.mutate.join(", ")}`);
|
|
656
|
-
console.log(` testFiles: ${result.testGlob}`);
|
|
657
|
-
for (const w of result.warnings)
|
|
658
|
-
console.log(` ⚠ ${w}`);
|
|
659
|
-
console.log(`
|
|
660
|
-
Next: polly mutate run`);
|
|
661
|
-
return 0;
|
|
662
|
-
}
|
|
663
3592
|
case "run": {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
console.log(`${tip}
|
|
667
|
-
`);
|
|
668
|
-
const code = await runStryker(cfg);
|
|
3593
|
+
resolveEngine(args);
|
|
3594
|
+
const code = await runBunEngine(cfg, args);
|
|
669
3595
|
if (code !== 0)
|
|
670
3596
|
return code;
|
|
671
3597
|
if (args.noReport)
|
|
@@ -699,18 +3625,18 @@ ${await reportFromFile(cfg)}`);
|
|
|
699
3625
|
return 0;
|
|
700
3626
|
}
|
|
701
3627
|
case "verify": {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
});
|
|
3628
|
+
if (args.run) {
|
|
3629
|
+
resolveEngine(args);
|
|
3630
|
+
await runBunEngine(cfg, { ...args, mode: "matrix" });
|
|
3631
|
+
}
|
|
3632
|
+
const result = await verifyMatrix({ reportPath: cfg.reportPath });
|
|
707
3633
|
console.log(`
|
|
708
3634
|
Kill-matrix contract:`);
|
|
709
3635
|
for (const c of result.checks)
|
|
710
3636
|
console.log(` ${c.ok ? "✓" : "✗"} ${c.name} (${c.detail})`);
|
|
711
3637
|
if (!result.ok) {
|
|
712
3638
|
console.log(`
|
|
713
|
-
✗ Contract broken.
|
|
3639
|
+
✗ Contract broken. Re-check the Bun version: matrix mode reads Bun's junit output (engine/junit.ts).`);
|
|
714
3640
|
return 1;
|
|
715
3641
|
}
|
|
716
3642
|
console.log(`
|
|
@@ -730,4 +3656,4 @@ main().then((code) => process.exit(code)).catch((err) => {
|
|
|
730
3656
|
process.exit(1);
|
|
731
3657
|
});
|
|
732
3658
|
|
|
733
|
-
//# debugId=
|
|
3659
|
+
//# debugId=42FD32590F43AB6F64756E2164756E21
|