@systemfsoftware/stryker-js-cli 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +103 -0
- package/dist/main.mjs +1495 -0
- package/package.json +72 -0
package/dist/main.mjs
ADDED
|
@@ -0,0 +1,1495 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
|
|
3
|
+
import * as NodeStdio from "@effect/platform-node/NodeStdio";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Layer from "effect/Layer";
|
|
6
|
+
import semver from "semver";
|
|
7
|
+
import { strykerEngines, strykerVersion } from "@systemfsoftware/stryker-js-mutation-run/stryker-package";
|
|
8
|
+
import * as Context from "effect/Context";
|
|
9
|
+
import * as Result from "effect/Result";
|
|
10
|
+
import * as CliError from "effect/unstable/cli/CliError";
|
|
11
|
+
import * as Cause from "effect/Cause";
|
|
12
|
+
import * as Clock from "effect/Clock";
|
|
13
|
+
import * as Deferred from "effect/Deferred";
|
|
14
|
+
import * as Fiber from "effect/Fiber";
|
|
15
|
+
import * as Queue from "effect/Queue";
|
|
16
|
+
import * as Stdio from "effect/Stdio";
|
|
17
|
+
import * as Stream from "effect/Stream";
|
|
18
|
+
import { buildVerdictEnvelope, generateRunId } from "@systemfsoftware/stryker-js-mutation-run/verdict-envelope";
|
|
19
|
+
import * as Console from "effect/Console";
|
|
20
|
+
import * as FileSystem from "effect/FileSystem";
|
|
21
|
+
import * as Option from "effect/Option";
|
|
22
|
+
import * as Path from "effect/Path";
|
|
23
|
+
import * as Ref from "effect/Ref";
|
|
24
|
+
import * as Terminal from "effect/Terminal";
|
|
25
|
+
import * as Argument from "effect/unstable/cli/Argument";
|
|
26
|
+
import * as CliConfig from "effect/unstable/cli/CliConfig";
|
|
27
|
+
import * as Command from "effect/unstable/cli/Command";
|
|
28
|
+
import * as Flag from "effect/unstable/cli/Flag";
|
|
29
|
+
import * as GlobalFlag from "effect/unstable/cli/GlobalFlag";
|
|
30
|
+
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
|
|
31
|
+
import { ConfigReader, OptionsValidator, defaultOptions } from "@systemfsoftware/stryker-js-mutation-run/config/config-resolution";
|
|
32
|
+
import { PluginKind } from "@systemfsoftware/stryker-js-plugin-api/plugin";
|
|
33
|
+
import { strykerPlugins } from "@systemfsoftware/stryker-js-mutation-report/stryker-plugins";
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
import { readFileSync } from "node:fs";
|
|
36
|
+
import { resolve } from "node:path";
|
|
37
|
+
import { noopLogger } from "@stryker-mutator/util";
|
|
38
|
+
import { Cell, Workflow } from "@systemfsoftware/effect-cell-types";
|
|
39
|
+
import "@systemfsoftware/stryker-js-plugin-api/core";
|
|
40
|
+
import * as Exit from "effect/Exit";
|
|
41
|
+
import { pipe } from "effect/Function";
|
|
42
|
+
import * as Match from "effect/Match";
|
|
43
|
+
import * as S from "effect/Schema";
|
|
44
|
+
import { Stryker } from "@systemfsoftware/stryker-js-mutation-run";
|
|
45
|
+
import { forkCoreSchema } from "@systemfsoftware/stryker-js-mutation-run/config/fork-schema";
|
|
46
|
+
import { ConfigError, retrieveCause } from "@systemfsoftware/stryker-js-mutation-run/errors";
|
|
47
|
+
import { ExitClass, getPendingExitClasses, resolveExitCode } from "@systemfsoftware/stryker-js-mutation-run/exit-classification";
|
|
48
|
+
import { performance } from "node:perf_hooks";
|
|
49
|
+
import { format, inspect } from "node:util";
|
|
50
|
+
import { toRelativeNormalizedFileName } from "@systemfsoftware/stryker-js-mutation-run/mutants/incremental-differ";
|
|
51
|
+
//#region src/OutputMode.ts
|
|
52
|
+
/**
|
|
53
|
+
* The known tool variables. Narrow per the plan — exactly
|
|
54
|
+
* `['CLAUDECODE', 'CODEX_SANDBOX']` — and load-bearing rather than a
|
|
55
|
+
* fallback: they cover the PTY-allocating harnesses a stdin condition would
|
|
56
|
+
* have rescued.
|
|
57
|
+
*/
|
|
58
|
+
const TOOL_VARIABLES = ["CLAUDECODE", "CODEX_SANDBOX"];
|
|
59
|
+
/**
|
|
60
|
+
* Resolves the output mode by R4 precedence. Pure — reads nothing, so it is
|
|
61
|
+
* fully testable; the caller supplies every input once at startup. The
|
|
62
|
+
* mutually-exclusive-flags case is a caller error, returned as a `failure` so
|
|
63
|
+
* the function stays total.
|
|
64
|
+
*/
|
|
65
|
+
function resolveMode(input) {
|
|
66
|
+
if (input.text === true && input.json === true) return Result.fail(CliError.InvalidValue.make({
|
|
67
|
+
option: "json",
|
|
68
|
+
value: "text",
|
|
69
|
+
expected: "the \"--format text\" and \"--json\" flags are mutually exclusive — use one or the other",
|
|
70
|
+
kind: "flag"
|
|
71
|
+
}));
|
|
72
|
+
if (input.text === true) return Result.succeed({
|
|
73
|
+
mode: "human",
|
|
74
|
+
signal: "flag",
|
|
75
|
+
stdoutIsTTY: input.stdoutIsTTY
|
|
76
|
+
});
|
|
77
|
+
if (input.json === true) return Result.succeed({
|
|
78
|
+
mode: "machine",
|
|
79
|
+
signal: "flag",
|
|
80
|
+
stdoutIsTTY: input.stdoutIsTTY
|
|
81
|
+
});
|
|
82
|
+
if (input.envMode !== void 0 && input.envMode.length > 0) return Result.succeed({
|
|
83
|
+
mode: input.envMode === "machine" ? "machine" : "human",
|
|
84
|
+
signal: "env",
|
|
85
|
+
stdoutIsTTY: input.stdoutIsTTY
|
|
86
|
+
});
|
|
87
|
+
if (!input.stdoutIsTTY) return Result.succeed({
|
|
88
|
+
mode: "machine",
|
|
89
|
+
signal: "tty",
|
|
90
|
+
stdoutIsTTY: false
|
|
91
|
+
});
|
|
92
|
+
if (input.agent !== void 0 && input.agent.length > 0) return Result.succeed({
|
|
93
|
+
mode: "machine",
|
|
94
|
+
signal: "agent",
|
|
95
|
+
stdoutIsTTY: true
|
|
96
|
+
});
|
|
97
|
+
for (const variable of TOOL_VARIABLES) {
|
|
98
|
+
const value = input.toolVars?.[variable];
|
|
99
|
+
if (value !== void 0 && value.length > 0) return Result.succeed({
|
|
100
|
+
mode: "machine",
|
|
101
|
+
signal: "tool",
|
|
102
|
+
stdoutIsTTY: true
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return Result.succeed({
|
|
106
|
+
mode: "human",
|
|
107
|
+
signal: "tty",
|
|
108
|
+
stdoutIsTTY: true
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The progress bar's gate. Human mode on a non-TTY stdout (AE1) must not leak
|
|
113
|
+
* its control sequences into a pipe, and machine mode keeps stdout clean for
|
|
114
|
+
* the verdict envelope (R5). Decided from the resolved mode's own detection
|
|
115
|
+
* data — never a second `isTTY` probe.
|
|
116
|
+
*/
|
|
117
|
+
function isProgressEnabled(resolved) {
|
|
118
|
+
return resolved.mode === "human" && resolved.stdoutIsTTY;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The log colouriser's gate (R8). Machine mode never emits colour, so a
|
|
122
|
+
* harness merging `2>&1` is not handed escape sequences it must strip, and
|
|
123
|
+
* `NO_COLOR` is honoured for the human path per the convention: any value
|
|
124
|
+
* other than an unset or empty variable disables colour.
|
|
125
|
+
*/
|
|
126
|
+
function isColorEnabled(resolved, noColor) {
|
|
127
|
+
return resolved.mode === "human" && (noColor === void 0 || noColor.length === 0);
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/OutputModeAdapter.ts
|
|
131
|
+
var OutputModeProbeTag = class extends Context.Service()("@systemfsoftware/stryker-js-cli/OutputModeAdapter/OutputModeProbeTag") {};
|
|
132
|
+
const OutputModeProbe = OutputModeProbeTag;
|
|
133
|
+
const OutputModeProbeLive = Layer.succeed(OutputModeProbe, OutputModeProbe.of({ detectMode: () => {
|
|
134
|
+
const envMode = process.env["STRYKER_MODE"];
|
|
135
|
+
const agent = process.env["AGENT"];
|
|
136
|
+
return Result.getOrThrow(resolveMode({
|
|
137
|
+
stdoutIsTTY: process.stdout.isTTY === true,
|
|
138
|
+
...envMode !== void 0 ? { envMode } : {},
|
|
139
|
+
...agent !== void 0 ? { agent } : {},
|
|
140
|
+
toolVars: Object.fromEntries(TOOL_VARIABLES.map((variable) => [variable, process.env[variable]]))
|
|
141
|
+
}));
|
|
142
|
+
} }));
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/StreamProtocol.ts
|
|
145
|
+
/**
|
|
146
|
+
* The heartbeat interval (R19), matching Terraform's `apply_progress`
|
|
147
|
+
* cadence: long enough that a slow phase is not noisy, short enough that a
|
|
148
|
+
* consumer can tell "slow" from "hung" without waiting for a mutant event.
|
|
149
|
+
*/
|
|
150
|
+
const TICK_INTERVAL_MS = 1e4;
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/RunEventStreamAdapter.ts
|
|
153
|
+
var RunEventStreamPortTag = class extends Context.Service()("@systemfsoftware/stryker-js-cli/RunEventStreamAdapter/RunEventStreamPortTag") {};
|
|
154
|
+
const RunEventStreamPort = RunEventStreamPortTag;
|
|
155
|
+
const isTerminalEvent = (event) => event.kind === "verdict" || event.kind === "error" || event.kind === "help" || event.kind === "manifest";
|
|
156
|
+
/**
|
|
157
|
+
* The push adapter from the run's synchronous sink to the callback mailbox.
|
|
158
|
+
* `Queue.offer`/`Queue.end` on an unbounded queue never block or fail, so the
|
|
159
|
+
* sync sink can drive them with `Effect.runSync`. Defined at module scope,
|
|
160
|
+
* outside any Effect expression.
|
|
161
|
+
*/
|
|
162
|
+
function queueEmit(queue) {
|
|
163
|
+
return {
|
|
164
|
+
single: (event) => {
|
|
165
|
+
Effect.runSync(Queue.offer(queue, event));
|
|
166
|
+
},
|
|
167
|
+
end: () => {
|
|
168
|
+
Effect.runSync(Queue.end(queue));
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The drain writes the framed lines through the platform's `Stdio` stdout
|
|
174
|
+
* sink — `NodeStdio.layer` at the composition root supplies the service, and
|
|
175
|
+
* the sink owns the writable's backpressure, the scoped `'error'` listener a
|
|
176
|
+
* closed consumer raises, and the final `'finish'` wait (`endOnDone`). A
|
|
177
|
+
* write failure surfaces as a `PlatformError` which this catch swallows — a
|
|
178
|
+
* consumer closing the pipe must not replace the run's classed exit code
|
|
179
|
+
* (R31) — and the drain still completes only after every byte was handed to
|
|
180
|
+
* the OS (R30).
|
|
181
|
+
*/
|
|
182
|
+
const drainOf = (stdio, framed) => Stream.run(framed, stdio.stdout({ endOnDone: true })).pipe(Effect.ignore);
|
|
183
|
+
/**
|
|
184
|
+
* Creates a run's stream. The drain is an Effect the composition root forks
|
|
185
|
+
* before the run; until then the sink is unbound and every push is dropped,
|
|
186
|
+
* which is what makes human mode — or an absent drain — inert without
|
|
187
|
+
* per-call probing (R2). The run's clock zero is read from the runtime so
|
|
188
|
+
* the adapter never touches the wall clock directly.
|
|
189
|
+
*/
|
|
190
|
+
const makeRunEventStream = (stdio, resolved) => Effect.gen(function* () {
|
|
191
|
+
const runId = generateRunId();
|
|
192
|
+
const startedAt = yield* Clock.currentTimeMillis;
|
|
193
|
+
const state = {
|
|
194
|
+
mode: resolved.mode,
|
|
195
|
+
signal: resolved.signal,
|
|
196
|
+
emit: null,
|
|
197
|
+
headerWritten: false,
|
|
198
|
+
terminalWritten: false,
|
|
199
|
+
progress: {
|
|
200
|
+
completed: 0,
|
|
201
|
+
total: null
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const registered = yield* Deferred.make();
|
|
205
|
+
const eventStream = Stream.callback((queue) => Effect.sync(() => {
|
|
206
|
+
state.emit = queueEmit(queue);
|
|
207
|
+
}).pipe(Effect.andThen(Deferred.succeed(registered, void 0))));
|
|
208
|
+
const tickStream = Stream.tick(TICK_INTERVAL_MS).pipe(Stream.filter(() => state.mode === "machine" && state.headerWritten && !state.terminalWritten), Stream.mapEffect(() => Effect.gen(function* () {
|
|
209
|
+
return {
|
|
210
|
+
kind: "tick",
|
|
211
|
+
elapsedMs: (yield* Clock.currentTimeMillis) - startedAt,
|
|
212
|
+
completed: state.progress.completed,
|
|
213
|
+
total: state.progress.total
|
|
214
|
+
};
|
|
215
|
+
})));
|
|
216
|
+
let terminalSeen = false;
|
|
217
|
+
const framed = Stream.merge(eventStream, tickStream, { haltStrategy: "either" }).pipe(Stream.filter((event) => {
|
|
218
|
+
if (terminalSeen) return false;
|
|
219
|
+
if (isTerminalEvent(event)) terminalSeen = true;
|
|
220
|
+
return true;
|
|
221
|
+
}), Stream.map((event) => `${JSON.stringify(event)}\n`));
|
|
222
|
+
const drain = drainOf(stdio, framed);
|
|
223
|
+
let drainFiber = null;
|
|
224
|
+
const sink = (event) => {
|
|
225
|
+
const emit = state.emit;
|
|
226
|
+
if (emit === null || state.terminalWritten || state.mode !== "machine") return;
|
|
227
|
+
if (!state.headerWritten) {
|
|
228
|
+
state.headerWritten = true;
|
|
229
|
+
emit.single({
|
|
230
|
+
kind: "stream",
|
|
231
|
+
schemaVersion: "1.0",
|
|
232
|
+
runId,
|
|
233
|
+
mode: state.mode,
|
|
234
|
+
signal: state.signal
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
switch (event.kind) {
|
|
238
|
+
case "stream":
|
|
239
|
+
case "tick": return;
|
|
240
|
+
case "phase": break;
|
|
241
|
+
case "plan":
|
|
242
|
+
state.progress = {
|
|
243
|
+
...state.progress,
|
|
244
|
+
total: event.total
|
|
245
|
+
};
|
|
246
|
+
break;
|
|
247
|
+
case "mutant":
|
|
248
|
+
state.progress = {
|
|
249
|
+
completed: event.completed,
|
|
250
|
+
total: event.total
|
|
251
|
+
};
|
|
252
|
+
break;
|
|
253
|
+
case "verdict":
|
|
254
|
+
case "error":
|
|
255
|
+
case "help":
|
|
256
|
+
case "manifest":
|
|
257
|
+
emit.single(event);
|
|
258
|
+
state.terminalWritten = true;
|
|
259
|
+
emit.end();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
emit.single(event);
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
sink,
|
|
266
|
+
runId,
|
|
267
|
+
startedAt,
|
|
268
|
+
isOpen: () => state.emit !== null && state.mode === "machine" && !state.terminalWritten,
|
|
269
|
+
ensureOpen: (openResolved) => {
|
|
270
|
+
if (state.headerWritten) return;
|
|
271
|
+
state.mode = openResolved.mode;
|
|
272
|
+
state.signal = openResolved.signal;
|
|
273
|
+
},
|
|
274
|
+
open: Effect.gen(function* () {
|
|
275
|
+
if (drainFiber === null) {
|
|
276
|
+
drainFiber = yield* Effect.forkDetach(drain);
|
|
277
|
+
yield* Effect.race(Deferred.await(registered), Fiber.await(drainFiber));
|
|
278
|
+
}
|
|
279
|
+
}),
|
|
280
|
+
closeAndDrain: Effect.gen(function* () {
|
|
281
|
+
state.terminalWritten = true;
|
|
282
|
+
state.emit?.end();
|
|
283
|
+
if (drainFiber !== null) yield* Fiber.join(drainFiber);
|
|
284
|
+
})
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
const RunEventStreamLive = Layer.effect(RunEventStreamPort, Effect.map(Stdio.Stdio, (stdio) => RunEventStreamPort.of({ createRunEventStream: (resolved) => makeRunEventStream(stdio, resolved) })));
|
|
288
|
+
function isObject(value) {
|
|
289
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
290
|
+
}
|
|
291
|
+
function stringField(node, key) {
|
|
292
|
+
const value = node[key];
|
|
293
|
+
return typeof value === "string" ? value : void 0;
|
|
294
|
+
}
|
|
295
|
+
function stringArrayField(node, key) {
|
|
296
|
+
const value = node[key];
|
|
297
|
+
if (!Array.isArray(value)) return [];
|
|
298
|
+
const strings = [];
|
|
299
|
+
for (const item of value) if (typeof item === "string") strings.push(item);
|
|
300
|
+
return strings;
|
|
301
|
+
}
|
|
302
|
+
/** The compiled shapes discriminate on `_tag`; read it once, off the record. */
|
|
303
|
+
function tagOf(node) {
|
|
304
|
+
return node["_tag"];
|
|
305
|
+
}
|
|
306
|
+
function walkParam(param, isOptional, out) {
|
|
307
|
+
if (!isObject(param)) return;
|
|
308
|
+
switch (tagOf(param)) {
|
|
309
|
+
case "Single":
|
|
310
|
+
describeSingle(param, isOptional, out);
|
|
311
|
+
return;
|
|
312
|
+
case "Map":
|
|
313
|
+
case "Transform":
|
|
314
|
+
walkParam(param["param"], isOptional, out);
|
|
315
|
+
return;
|
|
316
|
+
case "Optional":
|
|
317
|
+
walkParam(param["param"], true, out);
|
|
318
|
+
return;
|
|
319
|
+
case "Variadic":
|
|
320
|
+
walkParam(param["param"], isOptional, out);
|
|
321
|
+
return;
|
|
322
|
+
default: return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const PRIMITIVE_KIND = {
|
|
326
|
+
Boolean: "boolean",
|
|
327
|
+
Choice: "choice",
|
|
328
|
+
Date: "date",
|
|
329
|
+
FileParse: "file",
|
|
330
|
+
FileSchema: "file",
|
|
331
|
+
FileText: "file",
|
|
332
|
+
Float: "float",
|
|
333
|
+
Integer: "integer",
|
|
334
|
+
KeyValuePair: "key=value",
|
|
335
|
+
None: "none",
|
|
336
|
+
Path: "path",
|
|
337
|
+
Redacted: "redacted",
|
|
338
|
+
String: "text"
|
|
339
|
+
};
|
|
340
|
+
function kindOf(primitive) {
|
|
341
|
+
const tag = stringField(primitive, "_tag");
|
|
342
|
+
return tag === void 0 ? "unknown" : PRIMITIVE_KIND[tag] ?? tag;
|
|
343
|
+
}
|
|
344
|
+
function choiceValues(primitive) {
|
|
345
|
+
const keys = primitive["choiceKeys"];
|
|
346
|
+
if (!Array.isArray(keys)) return;
|
|
347
|
+
const values = [];
|
|
348
|
+
for (const key of keys) if (typeof key === "string") values.push(key);
|
|
349
|
+
return values;
|
|
350
|
+
}
|
|
351
|
+
/** The allowed reporter names, read from the U9 registry — the same list the plugin loader accepts. */
|
|
352
|
+
const REPORTER_NAMES = strykerPlugins.filter((plugin) => plugin.kind === PluginKind.Reporter).map((plugin) => plugin.name);
|
|
353
|
+
/**
|
|
354
|
+
* v4 option descriptions are stored as `Option.some(string)` on the compiled
|
|
355
|
+
* `Single`; the walker unwraps the option.
|
|
356
|
+
*/
|
|
357
|
+
function descriptionOf(single) {
|
|
358
|
+
const description = single["description"];
|
|
359
|
+
if (!isObject(description)) return "";
|
|
360
|
+
switch (tagOf(description)) {
|
|
361
|
+
case "Some": {
|
|
362
|
+
const value = description["value"];
|
|
363
|
+
return typeof value === "string" ? value : "";
|
|
364
|
+
}
|
|
365
|
+
default: return "";
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function describeSingle(single, isOptional, out) {
|
|
369
|
+
const name = stringField(single, "name") ?? "";
|
|
370
|
+
const primitive = isObject(single["primitiveType"]) ? single["primitiveType"] : {};
|
|
371
|
+
const kind = kindOf(primitive);
|
|
372
|
+
const choices = name === "reporters" ? REPORTER_NAMES : kind === "choice" ? choiceValues(primitive) : void 0;
|
|
373
|
+
const description = descriptionOf(single);
|
|
374
|
+
const required = kind !== "boolean" && !isOptional;
|
|
375
|
+
const described = {
|
|
376
|
+
name,
|
|
377
|
+
aliases: stringArrayField(single, "aliases"),
|
|
378
|
+
kind,
|
|
379
|
+
required,
|
|
380
|
+
...choices !== void 0 ? { choices } : {},
|
|
381
|
+
description
|
|
382
|
+
};
|
|
383
|
+
if (single["kind"] === "argument") {
|
|
384
|
+
out.args.push({
|
|
385
|
+
name,
|
|
386
|
+
kind,
|
|
387
|
+
required,
|
|
388
|
+
description
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
out.flags.push(described);
|
|
393
|
+
}
|
|
394
|
+
function walkConfigNode(node, orderedParams, out) {
|
|
395
|
+
if (!isObject(node)) return;
|
|
396
|
+
switch (tagOf(node)) {
|
|
397
|
+
case "Param": {
|
|
398
|
+
const index = node["index"];
|
|
399
|
+
const param = typeof index === "number" ? orderedParams[index] : void 0;
|
|
400
|
+
if (param !== void 0) walkParam(param, false, out);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
case "Array":
|
|
404
|
+
if (Array.isArray(node["children"])) for (const child of node["children"]) walkConfigNode(child, orderedParams, out);
|
|
405
|
+
return;
|
|
406
|
+
case "Nested":
|
|
407
|
+
if (isObject(node["tree"])) walkConfigTree(node["tree"], orderedParams, out);
|
|
408
|
+
return;
|
|
409
|
+
default: return;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function walkConfigTree(tree, orderedParams, out) {
|
|
413
|
+
for (const key of Object.keys(tree)) walkConfigNode(tree[key], orderedParams, out);
|
|
414
|
+
}
|
|
415
|
+
function describeCommandNode(node) {
|
|
416
|
+
if (!isObject(node)) return;
|
|
417
|
+
const out = {
|
|
418
|
+
flags: [],
|
|
419
|
+
args: []
|
|
420
|
+
};
|
|
421
|
+
const config = node["config"];
|
|
422
|
+
if (isObject(config) && isObject(config["tree"])) {
|
|
423
|
+
const orderedParams = Array.isArray(config["orderedParams"]) ? config["orderedParams"] : [];
|
|
424
|
+
walkConfigTree(config["tree"], orderedParams, out);
|
|
425
|
+
}
|
|
426
|
+
const subcommands = [];
|
|
427
|
+
const grouped = node["subcommands"];
|
|
428
|
+
if (Array.isArray(grouped)) for (const group of grouped) {
|
|
429
|
+
if (!isObject(group) || !Array.isArray(group["commands"])) continue;
|
|
430
|
+
for (const child of group["commands"]) {
|
|
431
|
+
const described = describeCommandNode(child);
|
|
432
|
+
if (described !== void 0) subcommands.push(described);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
name: stringField(node, "name") ?? "",
|
|
437
|
+
description: typeof node["description"] === "string" ? node["description"] : "",
|
|
438
|
+
options: out.flags,
|
|
439
|
+
args: out.args,
|
|
440
|
+
subcommands
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Builds the manifest document for a command, walking its compiled form (the
|
|
445
|
+
* same structure the parser matches against). `version` is the tool version,
|
|
446
|
+
* passed in so this module stays free of package state.
|
|
447
|
+
*/
|
|
448
|
+
function buildLLMSManifest(command, version) {
|
|
449
|
+
const root = describeCommandNode(command) ?? {
|
|
450
|
+
name: "",
|
|
451
|
+
description: "",
|
|
452
|
+
options: [],
|
|
453
|
+
args: [],
|
|
454
|
+
subcommands: []
|
|
455
|
+
};
|
|
456
|
+
return {
|
|
457
|
+
schemaVersion: "1.0",
|
|
458
|
+
tool: root.name,
|
|
459
|
+
version,
|
|
460
|
+
commands: [root]
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/** The manifest as one JSON document, ready for stdout — the U4 convention. */
|
|
464
|
+
function emitLLMSManifest(command, version) {
|
|
465
|
+
return JSON.stringify(buildLLMSManifest(command, version));
|
|
466
|
+
}
|
|
467
|
+
//#endregion
|
|
468
|
+
//#region src/OutputModeConsoleState.ts
|
|
469
|
+
/**
|
|
470
|
+
* U6 — the machine-mode `Console` layer (KTD3, R7).
|
|
471
|
+
*
|
|
472
|
+
* The v4 CLI renders help and errors through an ANSI renderer and prints them
|
|
473
|
+
* with the `Console` reference — and there is no seam to intercept: the
|
|
474
|
+
* document is written *before* the failure propagates. Machine mode therefore
|
|
475
|
+
* replaces the `Console` reference itself with a capturing implementation:
|
|
476
|
+
* every write lands in an in-memory buffer instead of the real stdout/stderr,
|
|
477
|
+
* and the terminating bootstrap (StrykerCliHandler.ts) emits the buffer as
|
|
478
|
+
* one JSON envelope at teardown. Human mode keeps the default console so the
|
|
479
|
+
* framework's prose rendering is untouched.
|
|
480
|
+
*
|
|
481
|
+
* The v4 `Console.Console` interface is the sync `globalThis.console` shape
|
|
482
|
+
* (the v3 service's effect-returning surface became the module-level
|
|
483
|
+
* wrapper functions), so the capture implementation needs no `unsafe`
|
|
484
|
+
* mirror — every method stores into the buffer directly.
|
|
485
|
+
*/
|
|
486
|
+
const capturedConsoleChunks = [];
|
|
487
|
+
const countByLabel = /* @__PURE__ */ new Map();
|
|
488
|
+
const timeByLabel = /* @__PURE__ */ new Map();
|
|
489
|
+
function formatArgs(args) {
|
|
490
|
+
return format(...args);
|
|
491
|
+
}
|
|
492
|
+
function captureSync(args) {
|
|
493
|
+
capturedConsoleChunks.push(formatArgs(args));
|
|
494
|
+
}
|
|
495
|
+
function captureAssert(condition, args) {
|
|
496
|
+
if (!condition) capturedConsoleChunks.push(`Assertion failed: ${formatArgs(args)}`);
|
|
497
|
+
}
|
|
498
|
+
function captureCount(label) {
|
|
499
|
+
const key = label ?? "default";
|
|
500
|
+
const next = (countByLabel.get(key) ?? 0) + 1;
|
|
501
|
+
countByLabel.set(key, next);
|
|
502
|
+
capturedConsoleChunks.push(`${key}: ${next}`);
|
|
503
|
+
}
|
|
504
|
+
function captureTimeEnd(label, now) {
|
|
505
|
+
const key = label ?? "default";
|
|
506
|
+
const started = timeByLabel.get(key);
|
|
507
|
+
if (started !== void 0) {
|
|
508
|
+
timeByLabel.delete(key);
|
|
509
|
+
capturedConsoleChunks.push(`${key}: ${now - started}ms`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function captureTrace(args) {
|
|
513
|
+
capturedConsoleChunks.push(`Trace: ${formatArgs(args)}\n${(/* @__PURE__ */ new Error()).stack ?? ""}`);
|
|
514
|
+
}
|
|
515
|
+
const capturingConsole = {
|
|
516
|
+
assert: (condition, ...args) => captureAssert(condition, args),
|
|
517
|
+
clear: () => {},
|
|
518
|
+
count: (label) => captureCount(label),
|
|
519
|
+
countReset: (label) => countByLabel.delete(label ?? "default"),
|
|
520
|
+
debug: (...args) => captureSync(args),
|
|
521
|
+
dir: (item, options) => capturedConsoleChunks.push(inspect(item, options)),
|
|
522
|
+
dirxml: (item) => capturedConsoleChunks.push(inspect(item)),
|
|
523
|
+
error: (...args) => captureSync(args),
|
|
524
|
+
group: () => {},
|
|
525
|
+
groupCollapsed: () => {},
|
|
526
|
+
groupEnd: () => {},
|
|
527
|
+
info: (...args) => captureSync(args),
|
|
528
|
+
log: (...args) => captureSync(args),
|
|
529
|
+
table: (tabularData) => capturedConsoleChunks.push(inspect(tabularData, {
|
|
530
|
+
colors: false,
|
|
531
|
+
depth: null
|
|
532
|
+
})),
|
|
533
|
+
time: (label) => timeByLabel.set(label ?? "default", performance.now()),
|
|
534
|
+
timeEnd: (label) => captureTimeEnd(label, performance.now()),
|
|
535
|
+
timeLog: (label, ...args) => {
|
|
536
|
+
const key = label ?? "default";
|
|
537
|
+
const started = timeByLabel.get(key);
|
|
538
|
+
if (started !== void 0) capturedConsoleChunks.push(`${key}: ${performance.now() - started}ms ${formatArgs(args)}`);
|
|
539
|
+
},
|
|
540
|
+
trace: (...args) => captureTrace(args),
|
|
541
|
+
warn: (...args) => captureSync(args)
|
|
542
|
+
};
|
|
543
|
+
/**
|
|
544
|
+
* The machine-mode `Console` layer. Building it clears the capture buffer so
|
|
545
|
+
* every run starts empty; the terminating bootstrap reads the buffer back
|
|
546
|
+
* through `readCapturedConsole` at teardown. A `Layer` is already lazy, so
|
|
547
|
+
* the layer is a value: the reset effect runs when the layer is built.
|
|
548
|
+
*
|
|
549
|
+
* The layer must replace the `Console` reference the module-level
|
|
550
|
+
* `Console.log`/`Console.error` wrappers read through their fiber context;
|
|
551
|
+
* v4 reads the override the same way it reads any provided service, so a
|
|
552
|
+
* plain provide is sufficient — no special `setConsole`-style primitive
|
|
553
|
+
* exists any more. The reference's identifier is `never` (it carries no
|
|
554
|
+
* requirement), so the layer's type is too.
|
|
555
|
+
*
|
|
556
|
+
* Human mode provides no Console binding at all: effect's own default
|
|
557
|
+
* console delegates every method to the global console, which is exactly the
|
|
558
|
+
* prose rendering a human-mode run uses. Mirroring it here would reimplement
|
|
559
|
+
* the library default (V.7).
|
|
560
|
+
*/
|
|
561
|
+
const machineConsoleLayer = Layer.effect(Console.Console, Effect.sync(() => {
|
|
562
|
+
resetCapturedConsole();
|
|
563
|
+
return capturingConsole;
|
|
564
|
+
}));
|
|
565
|
+
/**
|
|
566
|
+
* The text captured so far by the machine console layer, joined into one
|
|
567
|
+
* document the way a terminal would have rendered it (one console call per
|
|
568
|
+
* line). Empty in human mode.
|
|
569
|
+
*/
|
|
570
|
+
function readCapturedConsole() {
|
|
571
|
+
return capturedConsoleChunks.join("\n");
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Clears the capture buffer and the count/time tables. Called when the
|
|
575
|
+
* machine layer is constructed so every run starts empty.
|
|
576
|
+
*/
|
|
577
|
+
function resetCapturedConsole() {
|
|
578
|
+
capturedConsoleChunks.length = 0;
|
|
579
|
+
countByLabel.clear();
|
|
580
|
+
timeByLabel.clear();
|
|
581
|
+
}
|
|
582
|
+
//#endregion
|
|
583
|
+
//#region src/Survivors.workflow.ts
|
|
584
|
+
/**
|
|
585
|
+
* The decision's helper closure reaches three language built-ins the purity
|
|
586
|
+
* gate cannot resolve as globals, so each is bound at module scope and the
|
|
587
|
+
* helpers reference the bindings: `isArray` keeps `Array.isArray`'s narrow,
|
|
588
|
+
* `objectEntries`/`objectFromEntries`/`objectKeys` keep `Object`'s trio, and
|
|
589
|
+
* `stringify` keeps `JSON.stringify`'s exact text.
|
|
590
|
+
*/
|
|
591
|
+
const isArray = Array.isArray;
|
|
592
|
+
const { entries: objectEntries, fromEntries: objectFromEntries, keys: objectKeys } = Object;
|
|
593
|
+
const stringify = JSON.stringify;
|
|
594
|
+
/**
|
|
595
|
+
* U8 — survivor re-run admission (R10, R11, KTD6, KTD7).
|
|
596
|
+
*
|
|
597
|
+
* The `--survivors` run re-tests exactly the mutants that survived a previous
|
|
598
|
+
* run. Its input is the previous run's mutation report, and the run is
|
|
599
|
+
* admitted only when a single structural hash of the resolved options, the
|
|
600
|
+
* recorded framework version, and the per-file source content all match the
|
|
601
|
+
* current run (KTD6). Because thresholds live inside the resolved options, a
|
|
602
|
+
* threshold-only change is caught for free. Every rejection exits 2 with a
|
|
603
|
+
* remediation naming the full run to do first; zero survivors exits 0 with a
|
|
604
|
+
* null score and writes no new report (AE3); and a report written by a
|
|
605
|
+
* survivors run is never admitted as the input of another survivors run
|
|
606
|
+
* (KTD7), so chaining two survivors runs fails loudly instead of re-testing a
|
|
607
|
+
* shrunken or stale set.
|
|
608
|
+
*
|
|
609
|
+
* All functions here are pure over their inputs — no file I/O — so the
|
|
610
|
+
* admission logic is fixture-testable in seconds.
|
|
611
|
+
*/
|
|
612
|
+
/** The path a `--survivors` run reads when no `survivorsPriorReport` is configured. */
|
|
613
|
+
const DEFAULT_SURVIVORS_PRIOR_REPORT = "reports/mutation-report.json";
|
|
614
|
+
/**
|
|
615
|
+
* The remediation every rejection carries (R10): name the full run to do
|
|
616
|
+
* first, never the survivors run itself.
|
|
617
|
+
*/
|
|
618
|
+
const SURVIVORS_RUN_FIRST_REMEDIATION = "run a full `stryker run` first, then re-run with --survivors";
|
|
619
|
+
/**
|
|
620
|
+
* The survivors-run bookkeeping keys carried in the resolved options. They
|
|
621
|
+
* are run mechanics, not configuration: a survivors run adds them, so without
|
|
622
|
+
* stripping them the current run's hash would differ from the prior full
|
|
623
|
+
* run's hash for the very same configuration. Their presence in a report's
|
|
624
|
+
* embedded config is also the marker that the report was produced by a
|
|
625
|
+
* survivors run (KTD7).
|
|
626
|
+
*/
|
|
627
|
+
const SURVIVORS_BOOKKEEPING_KEYS = ["survivorsPriorReport"];
|
|
628
|
+
function isRecord(value) {
|
|
629
|
+
return typeof value === "object" && value !== null && !isArray(value);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* The resolved options without the survivors-run bookkeeping keys, so both
|
|
633
|
+
* sides of the admission hash describe the same configuration.
|
|
634
|
+
*/
|
|
635
|
+
function stripSurvivorsKeys(config) {
|
|
636
|
+
if (!isRecord(config)) return {};
|
|
637
|
+
const rest = { ...config };
|
|
638
|
+
for (const key of SURVIVORS_BOOKKEEPING_KEYS) delete rest[key];
|
|
639
|
+
return rest;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* A report written by a survivors run embeds the bookkeeping key in its
|
|
643
|
+
* `config` (the report helper writes the resolved options). Such a report is
|
|
644
|
+
* never a valid input for another survivors run (KTD7): without this check
|
|
645
|
+
* the second run would either re-read a shrunken set or re-test a stale one.
|
|
646
|
+
*/
|
|
647
|
+
function wasProducedBySurvivorsRun(priorReport) {
|
|
648
|
+
const config = priorReport.config;
|
|
649
|
+
return isRecord(config) && "survivorsPriorReport" in config;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* The sha256 content hash both sides of the admission comparison use: the
|
|
653
|
+
* current run's source files and the sources the prior report embeds. The
|
|
654
|
+
* digest capability is supplied by the caller.
|
|
655
|
+
*/
|
|
656
|
+
function sourceContentHash(content, hash) {
|
|
657
|
+
return hash(content);
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Serializes the hash input with keys sorted at every level, so the hash is a
|
|
661
|
+
* pure function of the data and not of object key insertion order. The shape
|
|
662
|
+
* is pinned by a golden snapshot test: a serialization change here fails
|
|
663
|
+
* loudly instead of silently invalidating every prior report in the wild.
|
|
664
|
+
*/
|
|
665
|
+
function serializeSurvivorsHashInput(input) {
|
|
666
|
+
return stringify(sortKeys(input));
|
|
667
|
+
}
|
|
668
|
+
function sortKeys(value) {
|
|
669
|
+
if (isArray(value)) return value.map(sortKeys);
|
|
670
|
+
if (isRecord(value)) return objectFromEntries(objectKeys(value).sort().map((key) => [key, sortKeys(value[key])]));
|
|
671
|
+
return value;
|
|
672
|
+
}
|
|
673
|
+
/** The single structural hash the admission compares (KTD6). */
|
|
674
|
+
function structuralHash(input, hash) {
|
|
675
|
+
return hash(serializeSurvivorsHashInput(input));
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Converts a report mutant (1-based schema location) into the internal mutant
|
|
679
|
+
* shape a run consumes (0-based positions, absolute file name) — the exact
|
|
680
|
+
* inverse of `objectUtils.toSchemaLocation` and the same shift the
|
|
681
|
+
* incremental report reader applies (`project-reader.ts`). Mutants without a
|
|
682
|
+
* replacement fall back to their mutator name, the same convention the
|
|
683
|
+
* incremental differ uses.
|
|
684
|
+
*/
|
|
685
|
+
function reportMutantToMutant(file, mutant, resolveAbsolutePath) {
|
|
686
|
+
return {
|
|
687
|
+
id: mutant.id,
|
|
688
|
+
fileName: resolveAbsolutePath(file),
|
|
689
|
+
mutatorName: mutant.mutatorName,
|
|
690
|
+
replacement: mutant.replacement ?? mutant.mutatorName,
|
|
691
|
+
location: {
|
|
692
|
+
start: {
|
|
693
|
+
line: mutant.location.start.line - 1,
|
|
694
|
+
column: mutant.location.start.column - 1
|
|
695
|
+
},
|
|
696
|
+
end: {
|
|
697
|
+
line: mutant.location.end.line - 1,
|
|
698
|
+
column: mutant.location.end.column - 1
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* The survivors of the prior report: exactly the mutants whose status is
|
|
705
|
+
* `Survived`, converted to the internal mutant shape so a run can re-test
|
|
706
|
+
* them.
|
|
707
|
+
*/
|
|
708
|
+
function extractSurvivors(priorReport, resolveAbsolutePath) {
|
|
709
|
+
const survivors = [];
|
|
710
|
+
for (const [file, fileResult] of objectEntries(priorReport.files)) for (const mutant of fileResult.mutants) if (mutant.status === "Survived") survivors.push(reportMutantToMutant(file, mutant, resolveAbsolutePath));
|
|
711
|
+
return survivors;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* The survivor spans as `file:startLine:startCol-endLine:endCol` mutate
|
|
715
|
+
* ranges: the report's 1-based lines with the internal 0-based columns,
|
|
716
|
+
* relative file names, deduplicated in first-seen order.
|
|
717
|
+
*/
|
|
718
|
+
function survivorMutateSpans(survivors) {
|
|
719
|
+
const spans = [];
|
|
720
|
+
const seen = /* @__PURE__ */ new Set();
|
|
721
|
+
for (const survivor of survivors) {
|
|
722
|
+
const file = toRelativeNormalizedFileName(survivor.fileName);
|
|
723
|
+
const { start, end } = survivor.location;
|
|
724
|
+
const span = `${file}:${start.line + 1}:${start.column}-${end.line + 1}:${end.column}`;
|
|
725
|
+
if (!seen.has(span)) {
|
|
726
|
+
seen.add(span);
|
|
727
|
+
spans.push(span);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return spans;
|
|
731
|
+
}
|
|
732
|
+
const NO_REPORT_DETAIL = "No prior mutation report found — a --survivors run needs the report of a previous run.";
|
|
733
|
+
const SURVIVORS_RUN_SOURCE_DETAIL = "The prior mutation report was itself produced by a --survivors run, so it is not a valid input for another one.";
|
|
734
|
+
const MISMATCH_DETAIL = "The prior mutation report does not match the current run (resolved options, framework version, or source content differ).";
|
|
735
|
+
/** The per-file source hashes of the sources the prior report embeds. */
|
|
736
|
+
function priorSourceHashes(priorReport, hashContent) {
|
|
737
|
+
return objectFromEntries(objectEntries(priorReport.files).map(([file, fileResult]) => [file, sourceContentHash(fileResult.source, hashContent)]));
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Whether the admission hashes agree: the prior report's embedded resolved
|
|
741
|
+
* options, framework version and source content against the current run's.
|
|
742
|
+
*/
|
|
743
|
+
function hashesMatch(priorReport, input) {
|
|
744
|
+
return structuralHash({
|
|
745
|
+
resolvedOptions: stripSurvivorsKeys(priorReport.config),
|
|
746
|
+
frameworkVersion: priorReport.framework?.version,
|
|
747
|
+
sourceContentHashes: priorSourceHashes(priorReport, input.hashContent)
|
|
748
|
+
}, input.hashContent) === structuralHash({
|
|
749
|
+
resolvedOptions: stripSurvivorsKeys(input.currentConfig),
|
|
750
|
+
frameworkVersion: input.frameworkVersion,
|
|
751
|
+
sourceContentHashes: input.sourceContentHashes
|
|
752
|
+
}, input.hashContent);
|
|
753
|
+
}
|
|
754
|
+
const rejection = (reason, detail) => ({
|
|
755
|
+
kind: "reject",
|
|
756
|
+
reason,
|
|
757
|
+
remediation: `${detail} ${SURVIVORS_RUN_FIRST_REMEDIATION}`
|
|
758
|
+
});
|
|
759
|
+
function admissionVerdict(input) {
|
|
760
|
+
const priorReport = input.priorReport;
|
|
761
|
+
if (priorReport === void 0) return rejection("no-report", NO_REPORT_DETAIL);
|
|
762
|
+
if (wasProducedBySurvivorsRun(priorReport)) return rejection("mismatch", SURVIVORS_RUN_SOURCE_DETAIL);
|
|
763
|
+
const survivors = extractSurvivors(priorReport, input.resolveAbsolutePath);
|
|
764
|
+
if (survivors.length === 0) return { kind: "no-survivors" };
|
|
765
|
+
if (!hashesMatch(priorReport, input)) return rejection("mismatch", MISMATCH_DETAIL);
|
|
766
|
+
return {
|
|
767
|
+
kind: "admit",
|
|
768
|
+
survivors
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
const SurvivorsAdmissionTypeId = Symbol.for("@systemfsoftware/stryker-js-cli/SurvivorsAdmission");
|
|
772
|
+
var Admitted = class extends S.TaggedClass()("Admitted", { survivors: S.Array(S.Struct({
|
|
773
|
+
id: S.String,
|
|
774
|
+
fileName: S.String,
|
|
775
|
+
mutatorName: S.String,
|
|
776
|
+
replacement: S.String,
|
|
777
|
+
location: S.Struct({
|
|
778
|
+
start: S.Struct({
|
|
779
|
+
line: S.Finite,
|
|
780
|
+
column: S.Finite
|
|
781
|
+
}),
|
|
782
|
+
end: S.Struct({
|
|
783
|
+
line: S.Finite,
|
|
784
|
+
column: S.Finite
|
|
785
|
+
})
|
|
786
|
+
})
|
|
787
|
+
})) }) {
|
|
788
|
+
[SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
|
|
789
|
+
};
|
|
790
|
+
var NoSurvivors = class extends S.TaggedClass()("NoSurvivors", {}) {
|
|
791
|
+
[SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
|
|
792
|
+
};
|
|
793
|
+
S.Union([Admitted, NoSurvivors]);
|
|
794
|
+
var SurvivorsRejection = class extends S.TaggedError()("SurvivorsRejection", {
|
|
795
|
+
reason: S.Literals(["no-report", "mismatch"]),
|
|
796
|
+
remediation: S.String
|
|
797
|
+
}) {
|
|
798
|
+
[SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
|
|
799
|
+
};
|
|
800
|
+
/**
|
|
801
|
+
* The survivors admission decision: the classification `admissionVerdict`
|
|
802
|
+
* produces, assigned to the workflow channels — one arm per kind, no guard
|
|
803
|
+
* chain. A missing report, a survivors-sourced report and a hash mismatch are
|
|
804
|
+
* the same reject outcome with different reasons; only the rejection's
|
|
805
|
+
* remediation names the full run to do first (R10).
|
|
806
|
+
*/
|
|
807
|
+
const admitSurvivorsRun = Workflow.make((command) => Match.value(admissionVerdict(command)).pipe(Match.discriminator("kind")("reject", (verdict) => Result.fail(SurvivorsRejection.make({
|
|
808
|
+
reason: verdict.reason,
|
|
809
|
+
remediation: verdict.remediation
|
|
810
|
+
}))), Match.discriminator("kind")("no-survivors", () => Result.succeed(NoSurvivors.make())), Match.discriminator("kind")("admit", (verdict) => Result.succeed(Admitted.make({ survivors: verdict.survivors }))), Match.exhaustive));
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region src/SurvivorsExit.ts
|
|
813
|
+
/** The exit class a rejected survivors run exits with (R6: exit 2). */
|
|
814
|
+
const SURVIVORS_REJECT_EXIT_CLASS = ExitClass.ConfigError;
|
|
815
|
+
S.Union([S.TaggedStruct("run", {
|
|
816
|
+
options: S.Any,
|
|
817
|
+
survivors: S.Boolean
|
|
818
|
+
}), S.TaggedStruct("llms", { document: S.Any })]);
|
|
819
|
+
//#endregion
|
|
820
|
+
//#region src/StrykerCliExecutor.ts
|
|
821
|
+
/**
|
|
822
|
+
* The default run: binds the host-resolved run options (the sink, the mode,
|
|
823
|
+
* the timing) to a fresh `Stryker` and runs mutation testing.
|
|
824
|
+
*/
|
|
825
|
+
const defaultRunMutationTest = (hostOptions) => (options) => new Stryker(options, hostOptions).runMutationTest();
|
|
826
|
+
/**
|
|
827
|
+
* The machine-mode `Console` layer, bundled so the transport (which resolves
|
|
828
|
+
* the mode) can provide it without importing the state cell. Human mode
|
|
829
|
+
* provides no layer — effect's own default console is the prose rendering
|
|
830
|
+
* (OutputModeConsoleState.ts).
|
|
831
|
+
*/
|
|
832
|
+
const strykerCliConsoleLayers = { machine: machineConsoleLayer };
|
|
833
|
+
const SIGNAL_NUMBERS = Object.freeze({
|
|
834
|
+
SIGINT: 2,
|
|
835
|
+
SIGTERM: 15
|
|
836
|
+
});
|
|
837
|
+
const hashContent = (content) => createHash("sha256").update(content, "utf-8").digest("hex");
|
|
838
|
+
const resolveAbsolutePath = (file) => resolve(file);
|
|
839
|
+
/**
|
|
840
|
+
* The host options a run is bound to: the sink, the mode, the timing and the
|
|
841
|
+
* log descriptor chosen by the mode — machine mode keeps stdout exclusively
|
|
842
|
+
* for the NDJSON stream, so the logging backend is pointed at stderr; human
|
|
843
|
+
* mode keeps the stdout sink. The fix is the descriptor, never the log level.
|
|
844
|
+
*/
|
|
845
|
+
function hostOptionsOf(mode, stream) {
|
|
846
|
+
return {
|
|
847
|
+
loggerConsoleOut: mode.mode === "machine" ? process.stderr : process.stdout,
|
|
848
|
+
showColors: isColorEnabled(mode, process.env["NO_COLOR"]),
|
|
849
|
+
runEventSink: stream.sink,
|
|
850
|
+
runId: stream.runId,
|
|
851
|
+
resolvedMode: mode,
|
|
852
|
+
progressEnabled: isProgressEnabled(mode),
|
|
853
|
+
clearTextEnabled: mode.mode === "human",
|
|
854
|
+
runStartedAt: stream.startedAt,
|
|
855
|
+
reporterPluginModules: [import.meta.resolve("@systemfsoftware/stryker-js-mutation-report/stryker-plugins")]
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* The survivors admission, as a description whose phases chain by type and
|
|
860
|
+
* read in the order they run. The read gathers the admission's whole input
|
|
861
|
+
* product — resolved options, prior report and the current source hashes —
|
|
862
|
+
* across its interior and stashes the shell context the write dispatches on
|
|
863
|
+
* into the executor-owned `runContext` ref; `decode` packages exactly the
|
|
864
|
+
* workflow input; `admitSurvivorsRun` is the decide phase; `encode` is the
|
|
865
|
+
* identity because write receives the outcome as-is; the write reads the
|
|
866
|
+
* stashed context back and dispatches the decision to the verdict/run,
|
|
867
|
+
* failing the run with a rejection.
|
|
868
|
+
*/
|
|
869
|
+
const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext) => pipe(Cell.read((cliOptions) => Effect.promise(() => resolveSurvivorsRunOptions(cliOptions)).pipe(Effect.flatMap((resolvedOptions) => {
|
|
870
|
+
const priorReportPath = priorReportPathOf(resolvedOptions);
|
|
871
|
+
const priorReport = readPriorReport(priorReportPath);
|
|
872
|
+
return Ref.set(runContext, {
|
|
873
|
+
resolvedOptions,
|
|
874
|
+
priorReportPath
|
|
875
|
+
}).pipe(Effect.as({
|
|
876
|
+
resolvedOptions,
|
|
877
|
+
priorReport,
|
|
878
|
+
priorReportPath,
|
|
879
|
+
sourceContentHashes: sourceContentHashesOf(priorReport)
|
|
880
|
+
}));
|
|
881
|
+
}))), Cell.decode(({ resolvedOptions, priorReport, sourceContentHashes }) => Result.succeed({
|
|
882
|
+
priorReport,
|
|
883
|
+
currentConfig: resolvedOptions,
|
|
884
|
+
frameworkVersion: strykerVersion,
|
|
885
|
+
sourceContentHashes,
|
|
886
|
+
hashContent,
|
|
887
|
+
resolveAbsolutePath
|
|
888
|
+
})), Cell.decide(admitSurvivorsRun), Cell.encode((outcome) => outcome), Cell.write((outcome) => Effect.flatMap(Ref.get(runContext), (context) => {
|
|
889
|
+
if (context === void 0) return Effect.die("the survivors admission read must run before its write");
|
|
890
|
+
const { resolvedOptions, priorReportPath } = context;
|
|
891
|
+
return Result.match(outcome, {
|
|
892
|
+
onSuccess: (decision) => Match.value(decision).pipe(Match.tag("NoSurvivors", () => Effect.sync(() => emitEmptySurvivorsVerdict(stream, mode, resolvedOptions))), Match.tag("Admitted", (admitted) => {
|
|
893
|
+
const restricted = {
|
|
894
|
+
...resolvedOptions,
|
|
895
|
+
survivors: admitted.survivors,
|
|
896
|
+
mutate: survivorMutateSpans(admitted.survivors),
|
|
897
|
+
survivorsPriorReport: priorReportPath,
|
|
898
|
+
incremental: false
|
|
899
|
+
};
|
|
900
|
+
return Effect.promise(() => runMutationTest(restricted));
|
|
901
|
+
}), Match.orElse(() => Effect.die("unreachable admission decision variant"))),
|
|
902
|
+
onFailure: (rejection) => Effect.fail(rejection)
|
|
903
|
+
});
|
|
904
|
+
})));
|
|
905
|
+
/**
|
|
906
|
+
* The `--survivors` request: re-test exactly the prior report's survivor set.
|
|
907
|
+
* The survivors flag was parsed as a boolean; the admission decides between
|
|
908
|
+
* running the survivors and the plain pipeline. The chain's order is carried by
|
|
909
|
+
* the description's phase types; the run's resolved context cell is created
|
|
910
|
+
* here, beside the description it feeds.
|
|
911
|
+
*/
|
|
912
|
+
function runSurvivorsAdmission(runMutationTest, stream, mode, cliOptions) {
|
|
913
|
+
return Effect.gen(function* () {
|
|
914
|
+
const admissionContext = yield* Ref.make(void 0);
|
|
915
|
+
return yield* Cell.apply(survivorsAdmissionDescription(runMutationTest, stream, mode, admissionContext), cliOptions);
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Resolves the current options the same way the pipeline does — defaults +
|
|
920
|
+
* config file + CLI, validated against the fork schema (which carries the
|
|
921
|
+
* survivors-run properties). The admission hash compares these resolved
|
|
922
|
+
* options against the prior report's embedded config.
|
|
923
|
+
*/
|
|
924
|
+
function resolveSurvivorsRunOptions(cliOptions) {
|
|
925
|
+
return new ConfigReader(noopLogger, new OptionsValidator(forkCoreSchema, noopLogger)).readConfig(cliOptions);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* The prior report a `--survivors` run reads: the `survivorsPriorReport`
|
|
929
|
+
* config option when set, else the default path. The report path is run
|
|
930
|
+
* bookkeeping, never a CLI flag.
|
|
931
|
+
*/
|
|
932
|
+
function priorReportPathOf(resolved) {
|
|
933
|
+
const configured = resolved["survivorsPriorReport"];
|
|
934
|
+
return typeof configured === "string" ? configured : DEFAULT_SURVIVORS_PRIOR_REPORT;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* A report is usable only when it parses and carries a `files` dictionary; a
|
|
938
|
+
* missing, unreadable or misshapen report is the `no-report` rejection.
|
|
939
|
+
*/
|
|
940
|
+
function isMutationTestResultShape(value) {
|
|
941
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
942
|
+
return "files" in value && typeof value.files === "object" && value.files !== null && !Array.isArray(value.files);
|
|
943
|
+
}
|
|
944
|
+
function readPriorReport(priorReportPath) {
|
|
945
|
+
try {
|
|
946
|
+
const raw = readFileSync(priorReportPath, "utf-8");
|
|
947
|
+
const parsed = JSON.parse(raw);
|
|
948
|
+
return isMutationTestResultShape(parsed) ? parsed : void 0;
|
|
949
|
+
} catch {
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
function readSourceFile(file) {
|
|
954
|
+
try {
|
|
955
|
+
return readFileSync(file, "utf-8");
|
|
956
|
+
} catch {
|
|
957
|
+
return "";
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* The per-file content hashes of the current sources, keyed by the relative
|
|
962
|
+
* file names the prior report uses — the current side of the admission
|
|
963
|
+
* comparison (`admitSurvivorsRun` hashes the prior side from the sources the
|
|
964
|
+
* report embeds).
|
|
965
|
+
*/
|
|
966
|
+
function sourceContentHashesOf(priorReport) {
|
|
967
|
+
const hashes = {};
|
|
968
|
+
if (priorReport === void 0) return hashes;
|
|
969
|
+
for (const file of Object.keys(priorReport.files)) hashes[file] = sourceContentHash(readSourceFile(file), hashContent);
|
|
970
|
+
return hashes;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Machine mode emits the U4 verdict envelope for a run that produced no
|
|
974
|
+
* mutants and no report file: a `--survivors` run with zero survivors (AE3)
|
|
975
|
+
* or a successful `--dryRunOnly` run that ended before the mutation
|
|
976
|
+
* pipeline. The envelope carries a null score and an empty mutant list and is
|
|
977
|
+
* written as the terminal `verdict` line of the stdout stream (U6), carrying
|
|
978
|
+
* the run id the stream header already opened with (KTD11 — never a fresh
|
|
979
|
+
* id). Human mode prints nothing (the sink drops in human mode).
|
|
980
|
+
*/
|
|
981
|
+
function emitNullScoreVerdict(stream, mode, thresholds, config) {
|
|
982
|
+
const report = {
|
|
983
|
+
schemaVersion: "1.0",
|
|
984
|
+
files: {},
|
|
985
|
+
thresholds,
|
|
986
|
+
projectRoot: process.cwd(),
|
|
987
|
+
config,
|
|
988
|
+
framework: {
|
|
989
|
+
name: "StrykerJS",
|
|
990
|
+
version: strykerVersion
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
const envelope = buildVerdictEnvelope(report, mode.mode, mode.signal, stream.runId);
|
|
994
|
+
stream.sink({
|
|
995
|
+
kind: "verdict",
|
|
996
|
+
...envelope
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* The `--survivors` zero-survivor path: the prior report held no survivors,
|
|
1001
|
+
* so the run emits the null-score verdict without starting the pipeline. The
|
|
1002
|
+
* full resolved options ride along as the report's embedded config (KTD7).
|
|
1003
|
+
*/
|
|
1004
|
+
function emitEmptySurvivorsVerdict(stream, mode, resolved) {
|
|
1005
|
+
emitNullScoreVerdict(stream, mode, resolved.thresholds, resolved);
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* The contextual remediation for a failure, picked from the cause's shape:
|
|
1009
|
+
* signal terminations (POSIX `128 + n`) are called out as interruptions,
|
|
1010
|
+
* usage/parse errors point at `--help`, config errors name the offending file
|
|
1011
|
+
* (ConfigError messages carry it), and rejected survivors runs name the full
|
|
1012
|
+
* run to do first. Everything else points at the report file and the verdict
|
|
1013
|
+
* envelope, which is where a runtime failure's detail already is.
|
|
1014
|
+
*/
|
|
1015
|
+
function remediationFor(exit, code) {
|
|
1016
|
+
if (code > 128) return "the run was interrupted by a signal; re-run it to continue";
|
|
1017
|
+
const value = failureValue(exit);
|
|
1018
|
+
if (value !== void 0) {
|
|
1019
|
+
if (CliError.isCliError(value)) return "re-run with --help to see the full usage";
|
|
1020
|
+
if (value instanceof ConfigError) return `check the config file: ${value.message}`;
|
|
1021
|
+
if (S.is(SurvivorsRejection)(value)) return value.remediation;
|
|
1022
|
+
}
|
|
1023
|
+
return "see --reportFile or the verdict envelope on stdout";
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* The failure's own text, used when the capture buffer is empty — a failure
|
|
1027
|
+
* stryker reported through its own logger rather than the framework's
|
|
1028
|
+
* `Console`. Falls back to a rendered cause.
|
|
1029
|
+
*/
|
|
1030
|
+
function describeFailure(exit) {
|
|
1031
|
+
if (Exit.isFailure(exit)) {
|
|
1032
|
+
const value = failureValue(exit);
|
|
1033
|
+
if (value !== void 0) {
|
|
1034
|
+
if (S.is(SurvivorsRejection)(value)) return value.remediation;
|
|
1035
|
+
if (value instanceof Error) return value.message;
|
|
1036
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") return String(value);
|
|
1037
|
+
return Object.prototype.toString.call(value);
|
|
1038
|
+
}
|
|
1039
|
+
return Cause.pretty(exit.cause);
|
|
1040
|
+
}
|
|
1041
|
+
return "";
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* The argument the framework reports it does not know, named the way the wire
|
|
1045
|
+
* contract spells it. The v4 parser fails wrapped in a ShowHelp whose errors
|
|
1046
|
+
* carry the offending flag or operand; when the unrecognized flag was given a
|
|
1047
|
+
* separate value (`--format text`), the value is the token the old parser
|
|
1048
|
+
* reported, so the token after the flag is named when one was given.
|
|
1049
|
+
*/
|
|
1050
|
+
function unrecognizedArgumentOf(exit, argv) {
|
|
1051
|
+
if (!Exit.isFailure(exit)) return;
|
|
1052
|
+
const value = failureValue(exit);
|
|
1053
|
+
if (value === void 0 || !CliError.isCliError(value)) return;
|
|
1054
|
+
const errors = S.is(CliError.ShowHelp)(value) ? value.errors : [value];
|
|
1055
|
+
for (const error of errors) {
|
|
1056
|
+
if (S.is(CliError.UnrecognizedOption)(error)) {
|
|
1057
|
+
const at = argv.indexOf(error.option);
|
|
1058
|
+
const next = at >= 0 ? argv[at + 1] : void 0;
|
|
1059
|
+
return next !== void 0 && !next.startsWith("-") ? next : error.option;
|
|
1060
|
+
}
|
|
1061
|
+
if (S.is(CliError.UnexpectedArgument)(error)) return error.arguments[0];
|
|
1062
|
+
if (S.is(CliError.UnknownSubcommand)(error)) return error.subcommand;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* The first typed error in the exit's cause. The framework fails with
|
|
1067
|
+
* `Cause.fail` (usage errors); the run handler is `Effect.promise`, whose
|
|
1068
|
+
* rejected promises surface as *defects* (`Die` reasons) rather than
|
|
1069
|
+
* failures — so stryker's own ConfigError/StrykerError values arrive there
|
|
1070
|
+
* and must be read from the cause's `Die` reasons.
|
|
1071
|
+
*/
|
|
1072
|
+
function failureValue(exit) {
|
|
1073
|
+
if (!Exit.isFailure(exit)) return;
|
|
1074
|
+
const failure = Cause.findErrorOption(exit.cause);
|
|
1075
|
+
if (Option.isSome(failure)) return failure.value;
|
|
1076
|
+
const dieReason = exit.cause.reasons.find(Cause.isDieReason);
|
|
1077
|
+
return dieReason === void 0 ? void 0 : dieReason.defect;
|
|
1078
|
+
}
|
|
1079
|
+
function buildErrorEnvelope(exit, code, captured, argv) {
|
|
1080
|
+
const unrecognized = unrecognizedArgumentOf(exit, argv);
|
|
1081
|
+
return {
|
|
1082
|
+
schemaVersion: "1.0",
|
|
1083
|
+
code,
|
|
1084
|
+
error: unrecognized !== void 0 ? `Received unknown argument: '${unrecognized}'` : captured.length > 0 ? captured : describeFailure(exit),
|
|
1085
|
+
remediation: remediationFor(exit, code)
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Emits the machine-mode output from the run's finalizer — it runs on
|
|
1090
|
+
* success, failure and interruption alike (R30): a failed run writes the
|
|
1091
|
+
* `error` terminal event as the last line of the stdout stream; a successful
|
|
1092
|
+
* run whose only console output was the framework's help/version rendering
|
|
1093
|
+
* emits that captured document as the `help` terminal event, so `--help` in
|
|
1094
|
+
* machine mode never leaks an ANSI document. A successful run with an empty
|
|
1095
|
+
* buffer (the normal verdict path) emits nothing extra — the run already
|
|
1096
|
+
* wrote its terminal `verdict` line through the same module — unless the
|
|
1097
|
+
* stream is still open, which means the run never reached a verdict (the
|
|
1098
|
+
* `--dryRunOnly` early return): then a null-score `verdict` closes the
|
|
1099
|
+
* stream so the last stdout line is always a terminal event (R5).
|
|
1100
|
+
*/
|
|
1101
|
+
function emitMachineModeOutput(stream, mode, exit, code, argv) {
|
|
1102
|
+
const captured = readCapturedConsole();
|
|
1103
|
+
const value = failureValue(exit);
|
|
1104
|
+
if (Exit.isFailure(exit) && S.is(CliError.ShowHelp)(value) && value.errors.length === 0) {
|
|
1105
|
+
const document = {
|
|
1106
|
+
kind: "help",
|
|
1107
|
+
schemaVersion: "1.0",
|
|
1108
|
+
code: 0,
|
|
1109
|
+
help: captured
|
|
1110
|
+
};
|
|
1111
|
+
stream.sink(document);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (Exit.isFailure(exit)) {
|
|
1115
|
+
stream.sink({
|
|
1116
|
+
kind: "error",
|
|
1117
|
+
...buildErrorEnvelope(exit, code, captured, argv)
|
|
1118
|
+
});
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (captured.length > 0) {
|
|
1122
|
+
const document = {
|
|
1123
|
+
kind: "help",
|
|
1124
|
+
schemaVersion: "1.0",
|
|
1125
|
+
code: 0,
|
|
1126
|
+
help: captured
|
|
1127
|
+
};
|
|
1128
|
+
stream.sink(document);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (stream.isOpen()) emitNullScoreVerdict(stream, mode, defaultOptions.thresholds, {});
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* A rejected config reaches the finalizer as a typed failure or as a defect
|
|
1135
|
+
* depending on where the validator threw, and typed-inject may have wrapped
|
|
1136
|
+
* it, so both channels are searched and each candidate is unwrapped.
|
|
1137
|
+
*/
|
|
1138
|
+
function carriesConfigError(cause) {
|
|
1139
|
+
for (const reason of cause.reasons) {
|
|
1140
|
+
const candidate = Cause.isFailReason(reason) ? reason.error : Cause.isDieReason(reason) ? reason.defect : void 0;
|
|
1141
|
+
if (candidate !== void 0 && (candidate instanceof ConfigError || retrieveCause(candidate) instanceof ConfigError)) return true;
|
|
1142
|
+
}
|
|
1143
|
+
return false;
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Classifies a failed run for the finalizer: usage/parse failures
|
|
1147
|
+
* (`CliError` — except a bare help request, which exits 0), rejected
|
|
1148
|
+
* survivors runs (`SurvivorsRejection`) and a rejected config (`ConfigError`)
|
|
1149
|
+
* all exit 2, all other failures exit 1 (the framework's default). A
|
|
1150
|
+
* successful run exits 0; the verdict gates (U5) then resolve the final
|
|
1151
|
+
* classed code.
|
|
1152
|
+
*/
|
|
1153
|
+
function resolveCliExitCode(exit) {
|
|
1154
|
+
if (Exit.isSuccess(exit)) return 0;
|
|
1155
|
+
if (Cause.hasInterruptsOnly(exit.cause)) return 1;
|
|
1156
|
+
const failure = Cause.findErrorOption(exit.cause);
|
|
1157
|
+
if (Option.isSome(failure)) {
|
|
1158
|
+
const value = failure.value;
|
|
1159
|
+
if (S.is(CliError.ShowHelp)(value)) return value.errors.length > 0 ? 2 : 0;
|
|
1160
|
+
if (CliError.isCliError(value)) return 2;
|
|
1161
|
+
if (S.is(SurvivorsRejection)(value)) return SURVIVORS_REJECT_EXIT_CLASS;
|
|
1162
|
+
}
|
|
1163
|
+
if (carriesConfigError(exit.cause)) return ExitClass.ConfigError;
|
|
1164
|
+
return 1;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* The single operation of the CLI's executor cell: the impure shell that
|
|
1168
|
+
* wraps the transport's command effect with the run bootstrap. It creates the
|
|
1169
|
+
* run's stream from the resolved mode, binds the host options a run is
|
|
1170
|
+
* executed with, opens the stream, runs the command effect, dispatches the
|
|
1171
|
+
* request the handlers left, and on every outcome — success, failure and
|
|
1172
|
+
* interruption alike — emits the machine-mode terminal event (error/help/
|
|
1173
|
+
* null verdict) and drains the stream, returning the classed exit code as its
|
|
1174
|
+
* value. SIGINT/SIGTERM interrupt the current fiber so the finalizer runs
|
|
1175
|
+
* before the process exits; the code is resolved exactly once (R6), in the
|
|
1176
|
+
* finalizer, where the terminal event's `code` is chosen from the same inputs
|
|
1177
|
+
* the teardown used before.
|
|
1178
|
+
*/
|
|
1179
|
+
const runStrykerCli = (input, createRunEventStream) => Effect.gen(function* () {
|
|
1180
|
+
const stream = yield* createRunEventStream(input.mode);
|
|
1181
|
+
const runMutationTest = input.runMutationTest ?? defaultRunMutationTest(hostOptionsOf(input.mode, stream));
|
|
1182
|
+
let currentFiber = null;
|
|
1183
|
+
let lastSignal = null;
|
|
1184
|
+
const resolveClassedExitCode = (exit) => {
|
|
1185
|
+
const signal = lastSignal;
|
|
1186
|
+
if (signal !== null) return 128 + signal;
|
|
1187
|
+
if (Exit.isFailure(exit)) return resolveCliExitCode(exit);
|
|
1188
|
+
return resolveExitCode(getPendingExitClasses(), null);
|
|
1189
|
+
};
|
|
1190
|
+
const onSignal = (signal) => {
|
|
1191
|
+
lastSignal = SIGNAL_NUMBERS[signal] ?? null;
|
|
1192
|
+
process.removeListener("SIGINT", onSignal);
|
|
1193
|
+
process.removeListener("SIGTERM", onSignal);
|
|
1194
|
+
if (currentFiber !== null) currentFiber.interruptUnsafe(currentFiber.id);
|
|
1195
|
+
};
|
|
1196
|
+
const dispatch = (request) => Match.value(request).pipe(Match.tag("run", (runRequest) => runRequest.survivors ? runSurvivorsAdmission(runMutationTest, stream, input.mode, runRequest.options) : Effect.promise(() => runMutationTest(runRequest.options))), Match.tag("llms", (llmsRequest) => Effect.sync(() => {
|
|
1197
|
+
stream.ensureOpen({
|
|
1198
|
+
mode: "machine",
|
|
1199
|
+
signal: "flag",
|
|
1200
|
+
stdoutIsTTY: process.stdout.isTTY === true
|
|
1201
|
+
});
|
|
1202
|
+
stream.sink(llmsRequest.document);
|
|
1203
|
+
})), Match.orElse(() => Effect.die("unreachable cli request variant")));
|
|
1204
|
+
const program = Effect.acquireUseRelease(Effect.sync(() => {
|
|
1205
|
+
currentFiber = Fiber.getCurrent() ?? null;
|
|
1206
|
+
process.on("SIGINT", onSignal);
|
|
1207
|
+
process.on("SIGTERM", onSignal);
|
|
1208
|
+
}), () => Effect.gen(function* () {
|
|
1209
|
+
yield* stream.open;
|
|
1210
|
+
yield* input.program;
|
|
1211
|
+
const request = yield* Ref.get(input.requestRef);
|
|
1212
|
+
yield* Option.match(request, {
|
|
1213
|
+
onNone: () => Effect.void,
|
|
1214
|
+
onSome: (cliRequest) => dispatch(cliRequest)
|
|
1215
|
+
});
|
|
1216
|
+
}), () => Effect.sync(() => {
|
|
1217
|
+
process.removeListener("SIGINT", onSignal);
|
|
1218
|
+
process.removeListener("SIGTERM", onSignal);
|
|
1219
|
+
}));
|
|
1220
|
+
return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
1221
|
+
const exit = yield* Effect.exit(restore(program));
|
|
1222
|
+
const code = resolveClassedExitCode(exit);
|
|
1223
|
+
input.recordExitCode(code);
|
|
1224
|
+
if (input.mode.mode === "machine") emitMachineModeOutput(stream, input.mode, exit, code, input.argv);
|
|
1225
|
+
yield* stream.closeAndDrain;
|
|
1226
|
+
return code;
|
|
1227
|
+
}));
|
|
1228
|
+
});
|
|
1229
|
+
//#endregion
|
|
1230
|
+
//#region src/StrykerCliHandler.ts
|
|
1231
|
+
function createSplitter(separator) {
|
|
1232
|
+
return (value) => value.split(separator).filter(Boolean);
|
|
1233
|
+
}
|
|
1234
|
+
const splitOnComma = createSplitter(",");
|
|
1235
|
+
const splitOnSpace = createSplitter(" ");
|
|
1236
|
+
/**
|
|
1237
|
+
* Commander characterization: `always` stays a string, everything else is a
|
|
1238
|
+
* boolean where `false`/`0` (case-insensitively) mean `false` — a tri-state a
|
|
1239
|
+
* plain boolean or choice would flatten.
|
|
1240
|
+
*/
|
|
1241
|
+
function parseCleanDirOption(value) {
|
|
1242
|
+
const v = value.toLocaleLowerCase();
|
|
1243
|
+
return v === "always" ? v : v !== "false" && v !== "0";
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Commander characterization: a pure integer is parsed as a number, anything
|
|
1247
|
+
* else (e.g. `"50%"`) stays a string.
|
|
1248
|
+
*/
|
|
1249
|
+
function parseConcurrency(value) {
|
|
1250
|
+
if (/^\d+$/.test(value)) return parseInt(value, 10);
|
|
1251
|
+
return value;
|
|
1252
|
+
}
|
|
1253
|
+
const optional = (option) => Flag.optional(option);
|
|
1254
|
+
/**
|
|
1255
|
+
* Commander left an omitted flag out of the parsed options; `deepMerge` treats
|
|
1256
|
+
* `undefined` as absent but an explicit `false` would override a config-file
|
|
1257
|
+
* `true`. The framework's boolean defaults to `false` when absent, so map that
|
|
1258
|
+
* back to `undefined` (KTD4).
|
|
1259
|
+
*/
|
|
1260
|
+
const absentWhenFalse = (value) => value ? true : void 0;
|
|
1261
|
+
const LOG_LEVELS = [
|
|
1262
|
+
"fatal",
|
|
1263
|
+
"error",
|
|
1264
|
+
"warn",
|
|
1265
|
+
"info",
|
|
1266
|
+
"debug",
|
|
1267
|
+
"trace",
|
|
1268
|
+
"off"
|
|
1269
|
+
];
|
|
1270
|
+
const LOG_LEVEL_LOOKUP = {
|
|
1271
|
+
fatal: true,
|
|
1272
|
+
error: true,
|
|
1273
|
+
warn: true,
|
|
1274
|
+
info: true,
|
|
1275
|
+
debug: true,
|
|
1276
|
+
trace: true,
|
|
1277
|
+
off: true
|
|
1278
|
+
};
|
|
1279
|
+
function isLogLevel(value) {
|
|
1280
|
+
return LOG_LEVEL_LOOKUP[value] === true;
|
|
1281
|
+
}
|
|
1282
|
+
function setLogLevel(target, key, value) {
|
|
1283
|
+
const unwrapped = unwrap(value);
|
|
1284
|
+
if (unwrapped !== void 0 && isLogLevel(unwrapped)) target[key] = unwrapped;
|
|
1285
|
+
}
|
|
1286
|
+
const runOptions = {
|
|
1287
|
+
ignorePatterns: Flag.string("ignorePatterns").pipe(Flag.withDescription("A comma separated list of patterns used for specifying which files need to be ignored. This should only be used in cases where you experience a slow Stryker startup, because too many (or too large) files are copied to the sandbox that are not needed to run the tests. For example, image or movie directories. Note: This option will have NO effect when using the `--inPlace` option. The directories `node_modules`, `.git` and some others are always ignored. Example: `--ignorePatterns dist`. These patterns are ALWAYS ignored: [`node_modules`, `.git`, `/reports`, `*.tsbuildinfo`, `/stryker.log`, `.stryker-tmp`]. Because Stryker always ignores these, you should rarely have to adjust the `ignorePatterns` setting at all. This is useful to speed up Stryker by reducing the size of the sandbox directory which has a positive effect on performance."), Flag.map(splitOnComma), optional),
|
|
1288
|
+
ignoreStatic: Flag.map(Flag.boolean("ignoreStatic"), absentWhenFalse).pipe(Flag.withDescription("Ignore static mutants. Static mutants are mutants which are only executed during the loading of a file.")),
|
|
1289
|
+
incremental: Flag.map(Flag.boolean("incremental"), absentWhenFalse).pipe(Flag.withDescription("Enable 'incremental mode'. Stryker will store results in a file and use that file to speed up the next --incremental run")),
|
|
1290
|
+
allowEmpty: Flag.map(Flag.boolean("allowEmpty"), absentWhenFalse).pipe(Flag.withDescription("Allows stryker to exit without any errors in cases where no tests are found")),
|
|
1291
|
+
incrementalFile: Flag.string("incrementalFile").pipe(Flag.withDescription("Specify the file to use for incremental mode."), optional),
|
|
1292
|
+
force: Flag.map(Flag.boolean("force"), absentWhenFalse).pipe(Flag.withDescription("Run all mutants, even if --incremental is provided and an incremental file exists. Can be used to force a rebuild of the incremental file.")),
|
|
1293
|
+
mutate: Flag.string("mutate").pipe(Flag.withAlias("m"), Flag.withDescription("With `mutate` you configure the subset of files or just one specific file to be mutated. These should be your _production code files_, and definitely not your test files. (Whereas with `ignorePatterns` you prevent non-relevant files from being copied to the sandbox directory in the first place)\nThe default will try to guess your production code files based on sane defaults. It reads like this:\n- Include all js-like files inside the `src` or `lib` dir\n- Except files inside `__tests__` directories and file names ending with `test` or `spec`.\nIf the defaults are not sufficient for you, for example in a angular project you might want to **exclude** not only the `*.spec.ts` files but other files too, just like the default already does.\nIt is possible to override the defaults by: - supplying one or more [glob patterns](https://github.com/isaacs/minimatch) to include (e.g. `src/**/*.js`) - or one or more comma separated glob patterns preceded with `!` to exclude (e.g. `!src/**/*.spec.js`) - or both (e.g. `src/**/*.js,!src/**/*.spec.js`).\nNote: Stryker will use [minimatch](https://github.com/isaacs/minimatch) for parsing these patterns, see minimatch for the exact syntax."), Flag.map(splitOnComma), optional),
|
|
1294
|
+
testFiles: Flag.string("testFiles").pipe(Flag.withAlias("t"), Flag.withDescription("With `testFiles` you can limit which test files are executed during mutation testing. When specified, only tests from these files will be run. This allows you to verify that a module's dedicated unit tests can kill all its mutants independently."), Flag.map(splitOnComma), optional),
|
|
1295
|
+
buildCommand: Flag.string("buildCommand").pipe(Flag.withAlias("b"), Flag.withDescription("Configure a build command to run after mutating the code, but before mutants are tested. This is generally used to transpile your code before testing. Only configure this if your test runner doesn't take care of this already and you're not using just-in-time transpiler like `babel/register` or `ts-node`."), optional),
|
|
1296
|
+
dryRunOnly: Flag.map(Flag.boolean("dryRunOnly"), absentWhenFalse).pipe(Flag.withDescription("Execute the initial test run only, without doing actual mutation testing. Doing a dry run only can be used to test that StrykerJS can run your test setup, for example, in CI pipelines.")),
|
|
1297
|
+
checkers: Flag.string("checkers").pipe(Flag.withDescription("A comma separated list of checkers to use, for example --checkers typescript"), Flag.map(splitOnComma), optional),
|
|
1298
|
+
checkerNodeArgs: Flag.string("checkerNodeArgs").pipe(Flag.withDescription("A list of node args to be passed to checker child processes. Split on spaces (commander characterization): `--checkerNodeArgs \"--inspect-brk --trace-warnings\"`."), Flag.map(splitOnSpace), optional),
|
|
1299
|
+
coverageAnalysis: Flag.choice("coverageAnalysis", [
|
|
1300
|
+
"perTest",
|
|
1301
|
+
"all",
|
|
1302
|
+
"off"
|
|
1303
|
+
]).pipe(Flag.withDescription(`The coverage analysis strategy you want to use. Default value: "${defaultOptions.coverageAnalysis}"`), optional),
|
|
1304
|
+
testRunner: Flag.string("testRunner").pipe(Flag.withDescription("The name of the test runner you want to use"), optional),
|
|
1305
|
+
testRunnerNodeArgs: Flag.string("testRunnerNodeArgs").pipe(Flag.withDescription("A list of node args to be passed to test runner child processes. Split on spaces (commander characterization): `--testRunnerNodeArgs \"--inspect-brk --trace-warnings\"`."), Flag.map(splitOnSpace), optional),
|
|
1306
|
+
reporters: Flag.string("reporters").pipe(Flag.withDescription("A comma separated list of the names of the reporter(s) you want to use"), Flag.map(splitOnComma), optional),
|
|
1307
|
+
plugins: Flag.string("plugins").pipe(Flag.withDescription("A list of plugins you want stryker to load (`require`)."), Flag.map(splitOnComma), optional),
|
|
1308
|
+
appendPlugins: Flag.string("appendPlugins").pipe(Flag.withDescription("A list of additional plugins you want Stryker to load (`require`) without overwriting the (default) `plugins`."), Flag.map(splitOnComma), optional),
|
|
1309
|
+
timeoutMS: Flag.integer("timeoutMS").pipe(Flag.withDescription("Tweak the absolute timeout used to wait for a test runner to complete"), optional),
|
|
1310
|
+
timeoutFactor: Flag.float("timeoutFactor").pipe(Flag.withDescription("Tweak the standard deviation relative to the normal test run of a mutated test"), optional),
|
|
1311
|
+
dryRunTimeoutMinutes: Flag.float("dryRunTimeoutMinutes").pipe(Flag.withDescription("Configure an absolute timeout for the initial test run. (It can take a while.)"), optional),
|
|
1312
|
+
maxConcurrentTestRunners: Flag.integer("maxConcurrentTestRunners").pipe(Flag.withDescription("Set the number of max concurrent test runner to spawn (default: cpuCount)"), optional),
|
|
1313
|
+
concurrency: Flag.string("concurrency").pipe(Flag.withAlias("c"), Flag.withDescription("Set the concurrency of workers. Stryker will always run checkers and test runners in parallel by creating worker processes (default: cpuCount - 1)"), Flag.map(parseConcurrency), optional),
|
|
1314
|
+
disableBail: Flag.map(Flag.boolean("disableBail"), absentWhenFalse).pipe(Flag.withDescription("Force the test runner to keep running tests, even when a mutant is already killed.")),
|
|
1315
|
+
maxTestRunnerReuse: Flag.integer("maxTestRunnerReuse").pipe(Flag.withDescription("Restart each test runner worker process after `n` runs. Not recommended unless you are experiencing memory leaks that you are unable to resolve. Configuring `0` here means infinite reuse."), optional),
|
|
1316
|
+
logLevel: Flag.choice("logLevel", LOG_LEVELS).pipe(Flag.withDescription(`Set the log level for the console. Possible values: fatal, error, warn, info, debug, trace and off. Default is "${defaultOptions.logLevel}"`), optional),
|
|
1317
|
+
fileLogLevel: Flag.choice("fileLogLevel", LOG_LEVELS).pipe(Flag.withDescription(`Set the log level for the "stryker.log" file. Possible values: fatal, error, warn, info, debug, trace and off. Default is "${defaultOptions.fileLogLevel}"`), optional),
|
|
1318
|
+
inPlace: Flag.map(Flag.boolean("inPlace"), absentWhenFalse).pipe(Flag.withDescription("Determines whether or not Stryker should mutate your files in place. Note: mutating your files in place is generally not needed for mutation testing, unless you have a dependency in your project that is really dependent on the file locations (like \"app-root-path\" for example).\nWhen `true`, Stryker will override your files, but it will keep a copy of the originals in the temp directory (using `tempDirName`) and it will place the originals back after it is done. Also with `true` the `ignorePatterns` has no effect any more.\nWhen `false` (default) Stryker will work in the copy of your code inside the temp directory.")),
|
|
1319
|
+
tempDirName: Flag.string("tempDirName").pipe(Flag.withDescription("Set the name of the directory that is used by Stryker as a working directory. This directory will be cleaned after a successful run"), optional),
|
|
1320
|
+
cleanTempDir: Flag.string("cleanTempDir").pipe(Flag.withDescription(`Choose whether or not to clean the temp dir (which is "${defaultOptions.tempDirName}" inside the current working directory by default) after a run.\n- false: Never delete the temp dir;\n- true: Delete the tmp dir after a successful run;\n- always: Always delete the temp dir, regardless of whether the run was successful.`), Flag.map(parseCleanDirOption), optional),
|
|
1321
|
+
survivors: Flag.map(Flag.boolean("survivors"), absentWhenFalse).pipe(Flag.withDescription("Re-run only the mutants that survived a previous run. Admits against the previous run's mutation report (the `survivorsPriorReport` config option, default `reports/mutation-report.json`) and re-tests exactly the survivor set. Exits 2 with a remediation naming a full run when the report is missing, drifted, or the configuration changed; exits 0 with a null score when the report has no survivors."))
|
|
1322
|
+
};
|
|
1323
|
+
const runArgs = { configFile: Argument.optional(Argument.string("configFile")) };
|
|
1324
|
+
const runConfig = {
|
|
1325
|
+
...runOptions,
|
|
1326
|
+
...runArgs
|
|
1327
|
+
};
|
|
1328
|
+
const rootConfig = { llms: Flag.map(Flag.boolean("llms"), absentWhenFalse).pipe(Flag.withDescription("Print the agent-facing command manifest as one JSON object on stdout: every option, alias, kind, default, allowed value set and description, plus the subcommands and positional arguments, walked from the command descriptors.")) };
|
|
1329
|
+
function unwrap(value) {
|
|
1330
|
+
if (Option.isOption(value)) return Option.match(value, {
|
|
1331
|
+
onNone: () => void 0,
|
|
1332
|
+
onSome: (v) => v
|
|
1333
|
+
});
|
|
1334
|
+
return value;
|
|
1335
|
+
}
|
|
1336
|
+
function setIfPresent(target, key, value) {
|
|
1337
|
+
const unwrapped = unwrap(value);
|
|
1338
|
+
if (unwrapped !== void 0) target[key] = unwrapped;
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Builds the full command tree — root plus the `run` subcommand — from the
|
|
1342
|
+
* same option/arg records the parser matches against. Each handler leaves the
|
|
1343
|
+
* request the executor runs: the run handler writes the parsed options (and
|
|
1344
|
+
* the survivors flag, which the admission consumes and the pipeline must not
|
|
1345
|
+
* see), the `--llms` handler writes the pre-rendered manifest document, and
|
|
1346
|
+
* the bare root writes nothing — `helpRequested` makes the framework render
|
|
1347
|
+
* help, which the executor's finalizer turns into the `help` terminal event.
|
|
1348
|
+
*/
|
|
1349
|
+
function makeStrykerCommand(requestRef) {
|
|
1350
|
+
const runCommand = Command.make("run", runConfig, (config) => {
|
|
1351
|
+
const configFile = Option.getOrUndefined(config.configFile);
|
|
1352
|
+
if (configFile !== void 0 && configFile.startsWith("-")) return Console.error(`Received unknown argument: '${configFile}'`).pipe(Effect.andThen(Effect.failSync(() => CliError.UnexpectedArgument.make({ arguments: [configFile] }))));
|
|
1353
|
+
return Ref.set(requestRef, Option.some({
|
|
1354
|
+
_tag: "run",
|
|
1355
|
+
options: readStrykerOptions(config),
|
|
1356
|
+
survivors: config.survivors === true
|
|
1357
|
+
}));
|
|
1358
|
+
}).pipe(Command.withDescription("Run mutation testing"));
|
|
1359
|
+
/**
|
|
1360
|
+
* Rebuilds the `PartialStrykerOptions` object commander produced: only options
|
|
1361
|
+
* actually given on the command line become keys (KTD4). `survivors` is
|
|
1362
|
+
* deliberately not forwarded — the survivor re-run logic (U8) consumes it.
|
|
1363
|
+
*/
|
|
1364
|
+
function readStrykerOptions(config) {
|
|
1365
|
+
const options = {};
|
|
1366
|
+
setIfPresent(options, "ignorePatterns", config.ignorePatterns);
|
|
1367
|
+
setIfPresent(options, "ignoreStatic", config.ignoreStatic);
|
|
1368
|
+
setIfPresent(options, "incremental", config.incremental);
|
|
1369
|
+
setIfPresent(options, "allowEmpty", config.allowEmpty);
|
|
1370
|
+
setIfPresent(options, "incrementalFile", config.incrementalFile);
|
|
1371
|
+
setIfPresent(options, "force", config.force);
|
|
1372
|
+
setIfPresent(options, "mutate", config.mutate);
|
|
1373
|
+
setIfPresent(options, "testFiles", config.testFiles);
|
|
1374
|
+
setIfPresent(options, "buildCommand", config.buildCommand);
|
|
1375
|
+
setIfPresent(options, "dryRunOnly", config.dryRunOnly);
|
|
1376
|
+
setIfPresent(options, "checkers", config.checkers);
|
|
1377
|
+
setIfPresent(options, "checkerNodeArgs", config.checkerNodeArgs);
|
|
1378
|
+
setIfPresent(options, "coverageAnalysis", config.coverageAnalysis);
|
|
1379
|
+
setIfPresent(options, "testRunner", config.testRunner);
|
|
1380
|
+
setIfPresent(options, "testRunnerNodeArgs", config.testRunnerNodeArgs);
|
|
1381
|
+
setIfPresent(options, "reporters", config.reporters);
|
|
1382
|
+
setIfPresent(options, "plugins", config.plugins);
|
|
1383
|
+
setIfPresent(options, "appendPlugins", config.appendPlugins);
|
|
1384
|
+
setIfPresent(options, "timeoutMS", config.timeoutMS);
|
|
1385
|
+
setIfPresent(options, "timeoutFactor", config.timeoutFactor);
|
|
1386
|
+
setIfPresent(options, "dryRunTimeoutMinutes", config.dryRunTimeoutMinutes);
|
|
1387
|
+
setIfPresent(options, "maxConcurrentTestRunners", config.maxConcurrentTestRunners);
|
|
1388
|
+
setIfPresent(options, "concurrency", config.concurrency);
|
|
1389
|
+
setIfPresent(options, "disableBail", config.disableBail);
|
|
1390
|
+
setIfPresent(options, "maxTestRunnerReuse", config.maxTestRunnerReuse);
|
|
1391
|
+
setLogLevel(options, "logLevel", config.logLevel);
|
|
1392
|
+
setLogLevel(options, "fileLogLevel", config.fileLogLevel);
|
|
1393
|
+
setIfPresent(options, "inPlace", config.inPlace);
|
|
1394
|
+
setIfPresent(options, "tempDirName", config.tempDirName);
|
|
1395
|
+
setIfPresent(options, "cleanTempDir", config.cleanTempDir);
|
|
1396
|
+
if (Option.isSome(config["configFile"])) options["configFile"] = config["configFile"].value;
|
|
1397
|
+
return options;
|
|
1398
|
+
}
|
|
1399
|
+
const strykerCommand = Command.make("stryker", rootConfig, (config) => {
|
|
1400
|
+
if (config.llms === true) {
|
|
1401
|
+
const document = {
|
|
1402
|
+
kind: "manifest",
|
|
1403
|
+
schemaVersion: "1.0",
|
|
1404
|
+
code: 0,
|
|
1405
|
+
manifest: emitLLMSManifest(strykerCommand, strykerVersion)
|
|
1406
|
+
};
|
|
1407
|
+
return Ref.set(requestRef, Option.some({
|
|
1408
|
+
_tag: "llms",
|
|
1409
|
+
document
|
|
1410
|
+
}));
|
|
1411
|
+
}
|
|
1412
|
+
return Effect.failSync(() => CliError.ShowHelp.make({
|
|
1413
|
+
commandPath: ["stryker"],
|
|
1414
|
+
errors: []
|
|
1415
|
+
}));
|
|
1416
|
+
}).pipe(Command.withSubcommands([runCommand]));
|
|
1417
|
+
return strykerCommand;
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* The CLI parses only text/number/choice options, so the framework's platform
|
|
1421
|
+
* services are never read at runtime; the v4 runner still demands them in its
|
|
1422
|
+
* environment. The bootstrap provides `Path.layer` (the universal
|
|
1423
|
+
* implementation in `effect/Path`), an *empty* file system (`layerNoop`:
|
|
1424
|
+
* every operation reports not-found), and a process-stdio `Terminal` whose
|
|
1425
|
+
* interactive input primitives fail loudly — the run-only surface (R14) has
|
|
1426
|
+
* no prompts. `@effect/platform-node` is a declared dependency of this
|
|
1427
|
+
* package (it provides the `NodeRuntime` the bin runs through), but no
|
|
1428
|
+
* platform-node service is wired into the command environment: the parser
|
|
1429
|
+
* never reads a real file system at run time, so the noop layers are
|
|
1430
|
+
* sufficient.
|
|
1431
|
+
*/
|
|
1432
|
+
const terminalLayer = Layer.succeed(Terminal.Terminal, Terminal.make({
|
|
1433
|
+
columns: Effect.sync(() => process.stdout.columns),
|
|
1434
|
+
rows: Effect.sync(() => process.stdout.rows),
|
|
1435
|
+
readInput: Effect.die(/* @__PURE__ */ new Error("stryker has no interactive prompts: Terminal.readInput is not supported")),
|
|
1436
|
+
readLine: Effect.die(/* @__PURE__ */ new Error("stryker has no interactive prompts: Terminal.readLine is not supported")),
|
|
1437
|
+
display: (text) => Effect.sync(() => {
|
|
1438
|
+
process.stdout.write(text);
|
|
1439
|
+
})
|
|
1440
|
+
}));
|
|
1441
|
+
const cliLayer = Layer.mergeAll(CliConfig.layer({ builtIns: [
|
|
1442
|
+
GlobalFlag.Help,
|
|
1443
|
+
GlobalFlag.action({
|
|
1444
|
+
flag: Flag.boolean("version").pipe(Flag.withAlias("v"), Flag.withDescription("Show version information")),
|
|
1445
|
+
run: () => Console.log(strykerVersion)
|
|
1446
|
+
}),
|
|
1447
|
+
GlobalFlag.Wizard,
|
|
1448
|
+
GlobalFlag.Completions,
|
|
1449
|
+
GlobalFlag.LogLevel
|
|
1450
|
+
] }), Path.layer, FileSystem.layerNoop({}), terminalLayer, NodeStdio.layer, Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => Effect.die("no child processes"))));
|
|
1451
|
+
/**
|
|
1452
|
+
* The transport entry: builds the command tree, resolves the mode once at
|
|
1453
|
+
* the edge (never a second probe), provides the CLI and Console layers the
|
|
1454
|
+
* framework renders through, delegates the whole run to the executor cell and
|
|
1455
|
+
* returns the classed exit code it computes. The executor is the I/O
|
|
1456
|
+
* sandwich; this function only frames it.
|
|
1457
|
+
*/
|
|
1458
|
+
function strykerCliEffect(argv, runMutationTest, recordExitCode, detectMode, createRunEventStream) {
|
|
1459
|
+
return Effect.gen(function* () {
|
|
1460
|
+
const mode = detectMode();
|
|
1461
|
+
const requestRef = yield* Ref.make(Option.none());
|
|
1462
|
+
const command = makeStrykerCommand(requestRef);
|
|
1463
|
+
const cliEffect = Command.runWith(command, { version: strykerVersion })(argv).pipe(Effect.provide(Layer.mergeAll(mode.mode === "machine" ? strykerCliConsoleLayers.machine : Layer.empty, cliLayer)));
|
|
1464
|
+
const outcome = yield* Effect.result(runStrykerCli({
|
|
1465
|
+
program: cliEffect,
|
|
1466
|
+
requestRef,
|
|
1467
|
+
mode,
|
|
1468
|
+
runMutationTest,
|
|
1469
|
+
recordExitCode,
|
|
1470
|
+
argv
|
|
1471
|
+
}, createRunEventStream));
|
|
1472
|
+
return Result.isFailure(outcome) ? outcome.failure : outcome.success;
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
//#endregion
|
|
1476
|
+
//#region src/main.ts
|
|
1477
|
+
const EXIT_CODE_RUN_NEVER_REACHED_ITS_FINALIZER = 1;
|
|
1478
|
+
process.title = "stryker";
|
|
1479
|
+
if (!semver.satisfies(process.version, strykerEngines.node)) throw new Error(`Node.js version ${process.version} detected. StrykerJS requires version to match ${strykerEngines.node}. Please update your Node.js version or visit https://nodejs.org/ for additional instructions`);
|
|
1480
|
+
const resolvedExitCode = { current: EXIT_CODE_RUN_NEVER_REACHED_ITS_FINALIZER };
|
|
1481
|
+
const program = Effect.gen(function* () {
|
|
1482
|
+
const outputMode = yield* OutputModeProbe;
|
|
1483
|
+
const runEvents = yield* RunEventStreamPort;
|
|
1484
|
+
return yield* strykerCliEffect(process.argv.slice(2), void 0, (code) => {
|
|
1485
|
+
resolvedExitCode.current = code;
|
|
1486
|
+
}, outputMode.detectMode, runEvents.createRunEventStream);
|
|
1487
|
+
}).pipe(Effect.provide(Layer.merge(OutputModeProbeLive, RunEventStreamLive).pipe(Layer.provide(NodeStdio.layer))));
|
|
1488
|
+
NodeRuntime.runMain(program, {
|
|
1489
|
+
disableErrorReporting: true,
|
|
1490
|
+
teardown: (_exit, onExit) => {
|
|
1491
|
+
onExit(resolvedExitCode.current);
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
//#endregion
|
|
1495
|
+
export {};
|