@systemfsoftware/stryker-js-cli 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +8 -7
  3. package/dist/main.mjs +1440 -1202
  4. package/package.json +16 -16
package/dist/main.mjs CHANGED
@@ -1,493 +1,620 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
4
+ import * as NodePath from "@effect/platform-node-shared/NodePath";
2
5
  import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
3
6
  import * as NodeStdio from "@effect/platform-node/NodeStdio";
7
+ import { ConfigFileUnreadableError, buildVerdictEnvelope, defaultOptions, generateRunId, makeRunLayer, readConfig, runMutationTest, strykerEngines, strykerVersion, toRelativeNormalizedFileName } from "@systemfsoftware/stryker-js-platform-node";
4
8
  import * as Effect from "effect/Effect";
9
+ import * as Exit from "effect/Exit";
5
10
  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 Logger from "effect/Logger";
12
+ import * as NodeChildProcessSpawner from "@effect/platform-node-shared/NodeChildProcessSpawner";
13
+ import { Heartbeat, HelpRendered, ManifestRendered, RunEvents, RunFailed, RunStarted, VerdictReached } from "@systemfsoftware/stryker-js/Run";
14
+ import { RENDERED_OPTION_DEFAULTS } from "@systemfsoftware/stryker-js/Schema";
11
15
  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
16
  import * as Console from "effect/Console";
17
+ import * as Fiber from "effect/Fiber";
20
18
  import * as FileSystem from "effect/FileSystem";
19
+ import * as Match from "effect/Match";
21
20
  import * as Option from "effect/Option";
22
21
  import * as Path from "effect/Path";
22
+ import * as Queue from "effect/Queue";
23
23
  import * as Ref from "effect/Ref";
24
+ import * as Result from "effect/Result";
25
+ import * as S from "effect/Schema";
24
26
  import * as Terminal from "effect/Terminal";
25
27
  import * as Argument from "effect/unstable/cli/Argument";
26
28
  import * as CliConfig from "effect/unstable/cli/CliConfig";
29
+ import * as CliError from "effect/unstable/cli/CliError";
27
30
  import * as Command from "effect/unstable/cli/Command";
28
31
  import * as Flag from "effect/unstable/cli/Flag";
29
32
  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
33
  import { readFileSync } from "node:fs";
36
34
  import { resolve } from "node:path";
35
+ import { ExitClass, highestExitClass } from "@systemfsoftware/stryker-js/ExitClass";
36
+ import { Mutant, causeText } from "@systemfsoftware/stryker-js/Mutant";
37
+ import * as Clock from "effect/Clock";
38
+ import * as Formatter from "effect/Formatter";
39
+ import * as Predicate from "effect/Predicate";
37
40
  import { Cell, Workflow } from "@systemfsoftware/effect-cell-types";
38
- import "@systemfsoftware/stryker-js-plugin-api/core";
39
- import { noopLogger } from "@systemfsoftware/stryker-js-util";
40
- import * as Exit from "effect/Exit";
41
+ import * as Context from "effect/Context";
41
42
  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
43
+ import * as Stdio from "effect/Stdio";
44
+ import * as Stream from "effect/Stream";
45
+ import { sha256 } from "@noble/hashes/sha256";
46
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils";
47
+ //#region src/RunOutcome.workflow.ts
48
+ var RunOutcomeCommand = class extends S.TaggedClass()("RunOutcomeCommand", {
49
+ succeeded: S.Boolean,
50
+ signal: S.optional(S.Finite),
51
+ interrupted: S.Boolean,
52
+ helpErrorCount: S.optional(S.Finite),
53
+ cliError: S.Boolean,
54
+ unrecognized: S.optional(S.String),
55
+ survivorsReason: S.optional(S.Literals(["no-report", "mismatch"])),
56
+ survivorsDiagnostic: S.optional(S.String),
57
+ schemaError: S.Boolean,
58
+ successExitClass: S.optional(ExitClass),
59
+ highestExitClass: S.optional(ExitClass),
60
+ configDetail: S.optional(S.String),
61
+ diagnostic: S.optional(S.String)
62
+ }) {};
63
+ const CONFIG_CODE$1 = 2;
64
+ const classCode = (exitClass) => Match.value(exitClass).pipe(Match.when("VerdictFail", () => 1), Match.when("ConfigError", () => CONFIG_CODE$1), Match.when("RuntimeError", () => 3), Match.when("InternalError", () => 4), Match.exhaustive);
65
+ var RunOk = class extends S.TaggedClass()("RunOk", { help: S.Boolean }) {};
66
+ var RunInterrupted = class extends S.TaggedError()("RunInterrupted", { code: S.Finite }) {};
67
+ var RunParseFailed = class extends S.TaggedError()("RunParseFailed", { unrecognized: S.optional(S.String) }) {};
68
+ var RunSurvivorsRejected = class extends S.TaggedError()("RunSurvivorsRejected", {
69
+ reason: S.Literals(["no-report", "mismatch"]),
70
+ diagnostic: S.optional(S.String)
71
+ }) {};
72
+ var RunConfigFailed = class extends S.TaggedError()("RunConfigFailed", { detail: S.optional(S.String) }) {};
73
+ var RunFailed$1 = class extends S.TaggedError()("RunFailed", {
74
+ code: S.Finite,
75
+ diagnostic: S.optional(S.String)
76
+ }) {};
77
+ function classify(command) {
78
+ if (command.signal !== void 0) return RunInterrupted.make({ code: 128 + command.signal });
79
+ if (command.succeeded) {
80
+ if (command.successExitClass !== void 0) return RunFailed$1.make({
81
+ code: classCode(command.successExitClass),
82
+ diagnostic: command.diagnostic
83
+ });
84
+ return RunOk.make({ help: false });
85
+ }
86
+ if (command.interrupted) return RunInterrupted.make({ code: 1 });
87
+ if (command.helpErrorCount !== void 0) {
88
+ if (command.helpErrorCount > 0) return RunParseFailed.make({ unrecognized: command.unrecognized });
89
+ return RunOk.make({ help: true });
90
+ }
91
+ if (command.cliError) return RunParseFailed.make({ unrecognized: command.unrecognized });
92
+ if (command.survivorsReason !== void 0) return RunSurvivorsRejected.make({
93
+ reason: command.survivorsReason,
94
+ diagnostic: command.survivorsDiagnostic
96
95
  });
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
96
+ if (command.schemaError) return RunConfigFailed.make({ detail: command.configDetail });
97
+ if (command.highestExitClass !== void 0) {
98
+ if (command.highestExitClass === "ConfigError") return RunConfigFailed.make({ detail: command.configDetail });
99
+ return RunFailed$1.make({
100
+ code: classCode(command.highestExitClass),
101
+ diagnostic: command.diagnostic
103
102
  });
104
103
  }
105
- return Result.succeed({
106
- mode: "human",
107
- signal: "tty",
108
- stdoutIsTTY: true
104
+ return RunFailed$1.make({
105
+ code: 1,
106
+ diagnostic: command.diagnostic
109
107
  });
110
108
  }
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;
109
+ function succeedRun(ok) {
110
+ return Result.succeed(ok);
119
111
  }
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);
112
+ function failRun(error) {
113
+ return Result.fail(error);
128
114
  }
115
+ function runOutcomeDecision(command) {
116
+ return Match.value(classify(command)).pipe(Match.tag("RunOk", succeedRun), Match.tag("RunInterrupted", failRun), Match.tag("RunParseFailed", failRun), Match.tag("RunSurvivorsRejected", failRun), Match.tag("RunConfigFailed", failRun), Match.tag("RunFailed", failRun), Match.exhaustive);
117
+ }
118
+ const runOutcomeWorkflow = Workflow.make(RunOutcomeCommand, runOutcomeDecision);
129
119
  //#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
120
+ //#region src/Survivors.workflow.ts
145
121
  /**
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.
122
+ * The mutant shape the admission carries, named once because both the decision's
123
+ * `Admitted` payload and the command's precomputed survivor list are the same shape.
124
+ */
125
+ const MutantShape = S.Struct({
126
+ id: S.String,
127
+ fileName: S.String,
128
+ mutatorName: S.String,
129
+ replacement: S.String,
130
+ location: S.Struct({
131
+ start: S.Struct({
132
+ line: S.Finite,
133
+ column: S.Finite
134
+ }),
135
+ end: S.Struct({
136
+ line: S.Finite,
137
+ column: S.Finite
138
+ })
139
+ })
140
+ });
141
+ const PriorReportDocument = S.Struct({
142
+ config: S.optional(S.Record(S.String, S.Unknown)),
143
+ framework: S.optional(S.Struct({ version: S.optional(S.String) })),
144
+ files: S.Record(S.String, S.Struct({
145
+ source: S.String,
146
+ mutants: S.Array(S.Struct({
147
+ id: S.String,
148
+ mutatorName: S.String,
149
+ replacement: S.optional(S.String),
150
+ status: S.String,
151
+ location: S.Struct({
152
+ start: S.Struct({
153
+ line: S.Finite,
154
+ column: S.Finite
155
+ }),
156
+ end: S.Struct({
157
+ line: S.Finite,
158
+ column: S.Finite
159
+ })
160
+ })
161
+ }))
162
+ }))
163
+ });
164
+ /**
165
+ * U8 — survivor re-run admission (R10, R11, KTD6, KTD7).
166
+ *
167
+ * The `--survivors` run re-tests exactly the mutants that survived a previous
168
+ * run. Its input is the previous run's mutation report, and the run is
169
+ * admitted only when a single structural hash of the resolved options, the
170
+ * recorded framework version, and the per-file source content all match the
171
+ * current run (KTD6).
149
172
  */
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
173
  /**
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.
174
+ * The decision's helpers reach three language built-ins the purity gate cannot
175
+ * resolve as globals, so each is bound at module scope.
176
+ *
177
+ * These live here, beside the decision, because `make-body-purity` follows the
178
+ * decision's reachable set: a helper `admissionVerdict` calls is checked as part
179
+ * of the body even though it is declared outside it. That is why the properties
180
+ * covering them are in this file's in-source block rather than beside a pure helper -
181
+ * testing a copy the decision does not run is worse than not testing it, because
182
+ * the suite goes green either way.
161
183
  */
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
- };
184
+ const isArray = Array.isArray;
185
+ const { fromEntries: objectFromEntries$1, keys: objectKeys } = Object;
186
+ const stringify = JSON.stringify;
187
+ function isRecord(value) {
188
+ return typeof value === "object" && value !== null && !isArray(value);
171
189
  }
190
+ const SURVIVORS_RUN_FIRST_REMEDIATION = "run a full `stryker run` first, then re-run with --survivors";
191
+ const SURVIVORS_BOOKKEEPING_KEYS = ["survivorsPriorReport"];
172
192
  /**
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).
193
+ * The resolved options without the survivors-run bookkeeping keys, so both
194
+ * sides of the admission comparison describe the same configuration.
181
195
  */
182
- const drainOf = (stdio, framed) => Stream.run(framed, stdio.stdout({ endOnDone: true })).pipe(Effect.ignore);
196
+ function stripSurvivorsKeys(config) {
197
+ if (!isRecord(config)) return {};
198
+ const rest = { ...config };
199
+ for (const key of SURVIVORS_BOOKKEEPING_KEYS) delete rest[key];
200
+ return rest;
201
+ }
183
202
  /**
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.
203
+ * A report written by a survivors run embeds the bookkeeping key in its
204
+ * `config`. Such a report is never a valid input for another survivors run
205
+ * (KTD7): without this check the second run would either re-read a shrunken set
206
+ * or re-test a stale one.
189
207
  */
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"];
208
+ function wasProducedBySurvivorsRun(priorReport) {
209
+ const config = priorReport.config;
210
+ return isRecord(config) && "survivorsPriorReport" in config;
305
211
  }
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
- }
212
+ /**
213
+ * Serializes the comparison input with keys sorted at every level, so the result
214
+ * is a function of the data and not of key insertion order.
215
+ */
216
+ function serializeSurvivorsHashInput(input) {
217
+ return stringify(sortKeys(input));
324
218
  }
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;
219
+ function sortKeys(value) {
220
+ if (isArray(value)) return value.map(sortKeys);
221
+ if (isRecord(value)) return objectFromEntries$1(objectKeys(value).sort().map((key) => [key, sortKeys(value[key])]));
222
+ return value;
343
223
  }
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;
224
+ /**
225
+ * The prior report's facts the decision reads: its embedded configuration, which carries
226
+ * both the compared options and the survivors-run provenance marker, and the engine
227
+ * version it recorded. The report's files are not here — the survivors and the per-file
228
+ * source hashes derived from them need capabilities the command cannot hold, so they
229
+ * arrive already computed.
230
+ */
231
+ var PriorReportFacts = class extends S.Class("PriorReportFacts")({
232
+ config: S.Record(S.String, S.Unknown),
233
+ frameworkVersion: S.UndefinedOr(S.String)
234
+ }) {};
235
+ /**
236
+ * The command of the admission workflow: a schema class, because `Workflow.make`
237
+ * constrains its first argument on the class value and a declared interface produces no
238
+ * value to pass. Every field is pure data — the two capabilities the previous shape
239
+ * carried, a digest function and a path resolver, can never be schema fields, so their
240
+ * results arrive precomputed from the decode phase instead.
241
+ */
242
+ var AdmitSurvivorsRunCommand = class extends S.Class("AdmitSurvivorsRunCommand")({
243
+ /**
244
+ * The prior run's report facts, `undefined` when no report exists — the run cannot be
245
+ * admitted without one ('no-report'). Explicitly nullable rather than key-optional: a
246
+ * missing report is a state the edge determined and states, not a key it forgot.
247
+ */
248
+ priorReport: S.UndefinedOr(PriorReportFacts),
249
+ /** The current run's resolved options (defaults + config file + CLI). */
250
+ currentConfig: S.Record(S.String, S.Unknown),
251
+ /** The current CLI/framework version (`strykerVersion`). */
252
+ frameworkVersion: S.String,
253
+ /**
254
+ * Per-file content hashes of the current source, keyed by the prior report's relative
255
+ * file keys. The prior side is hashed from the sources the report embeds, so an editor
256
+ * save that shifts line ranges — which would silently re-test a different mutant than
257
+ * the one that survived — is caught here.
258
+ */
259
+ sourceContentHashes: S.Record(S.String, S.String),
260
+ /** The same hashes for the sources the prior report embeds, computed at the edge. */
261
+ priorSourceHashes: S.Record(S.String, S.String),
262
+ /** The prior report's survivors, already converted to the internal mutant shape. */
263
+ priorSurvivors: S.Array(MutantShape)
264
+ }) {};
265
+ const NO_REPORT_DETAIL = "No prior mutation report found — a --survivors run needs the report of a previous run.";
266
+ 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.";
267
+ const MISMATCH_DETAIL = "The prior mutation report does not match the current run (resolved options, framework version, or source content differ).";
268
+ /**
269
+ * Whether the admission inputs agree: the prior report's embedded resolved options,
270
+ * framework version and source content against the current run's.
271
+ *
272
+ * The comparison is on the canonical serializations rather than digests of them. Equal
273
+ * serializations are equal runs, so the digest was a lossy restatement of the check that
274
+ * also demanded a capability no command can carry.
275
+ */
276
+ function hashesMatch(priorReport, input) {
277
+ return serializeSurvivorsHashInput({
278
+ resolvedOptions: stripSurvivorsKeys(priorReport.config),
279
+ frameworkVersion: priorReport.frameworkVersion,
280
+ sourceContentHashes: input.priorSourceHashes
281
+ }) === serializeSurvivorsHashInput({
282
+ resolvedOptions: stripSurvivorsKeys(input.currentConfig),
283
+ frameworkVersion: input.frameworkVersion,
284
+ sourceContentHashes: input.sourceContentHashes
285
+ });
286
+ }
287
+ const SurvivorsAdmissionTypeId = Symbol.for("@systemfsoftware/stryker-js-cli/SurvivorsAdmission");
288
+ var Admitted = class extends S.TaggedClass()("Admitted", { survivors: S.Array(MutantShape) }) {
289
+ [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
290
+ };
291
+ var NoSurvivors = class extends S.TaggedClass()("NoSurvivors", {}) {
292
+ [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
293
+ };
294
+ S.Union([Admitted, NoSurvivors]);
295
+ var SurvivorsRejection = class extends S.TaggedError()("SurvivorsRejection", {
296
+ reason: S.Literals(["no-report", "mismatch"]),
297
+ remediation: S.String
298
+ }) {
299
+ [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
300
+ };
301
+ function reject(reason, detail) {
302
+ return Result.fail(SurvivorsRejection.make({
303
+ reason,
304
+ remediation: `${detail} ${SURVIVORS_RUN_FIRST_REMEDIATION}`
305
+ }));
306
+ }
307
+ function decideAdmission(input) {
308
+ const priorReport = input.priorReport;
309
+ if (priorReport === void 0) return reject("no-report", NO_REPORT_DETAIL);
310
+ if (wasProducedBySurvivorsRun(priorReport)) return reject("mismatch", SURVIVORS_RUN_SOURCE_DETAIL);
311
+ if (input.priorSurvivors.length === 0) return Result.succeed(NoSurvivors.make());
312
+ if (!hashesMatch(priorReport, input)) return reject("mismatch", MISMATCH_DETAIL);
313
+ return Result.succeed(Admitted.make({ survivors: input.priorSurvivors }));
314
+ }
315
+ function admissionDecision(command) {
316
+ return decideAdmission(command);
350
317
  }
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);
318
+ const admitSurvivorsRun = Workflow.make(AdmitSurvivorsRunCommand, admissionDecision);
319
+ //#endregion
320
+ //#region src/Envelope.ts
353
321
  /**
354
- * v4 option descriptions are stored as `Option.some(string)` on the compiled
355
- * `Single`; the walker unwraps the option.
322
+ * Envelope the failure envelope and console capture leaf.
323
+ *
324
+ * Extracted from Cli.ts to break the import cycle Cli <-> Output.
325
+ * Both Cli and Output import from this leaf, so neither depends on the other
326
+ * for these values. This file imports only from external packages and from
327
+ * StreamVersion (leaf) and Survivors.workflow (leaf), never from Cli or Output
328
+ * themselves.
356
329
  */
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 "";
330
+ const CONFIG_CODE = 2;
331
+ function runOutcomeCode(result) {
332
+ if (Result.isSuccess(result)) return 0;
333
+ return Match.value(result.failure).pipe(Match.tag("RunInterrupted", (error) => error.code), Match.tag("RunParseFailed", () => CONFIG_CODE), Match.tag("RunSurvivorsRejected", () => CONFIG_CODE), Match.tag("RunConfigFailed", () => CONFIG_CODE), Match.tag("RunFailed", (error) => error.code), Match.exhaustive);
334
+ }
335
+ function isExitClass(value) {
336
+ return S.is(ExitClass)(value);
337
+ }
338
+ function exitClassOf(value) {
339
+ if (typeof value !== "object" || value === null) return;
340
+ if (!("exitClass" in value)) return;
341
+ const raw = Reflect.get(value, "exitClass");
342
+ if (!isExitClass(raw)) return;
343
+ return raw;
344
+ }
345
+ function collectExitClassesFromValue(value, out, seen, depth) {
346
+ if (depth > 10 || value === null || value === void 0) return;
347
+ if (typeof value !== "object") return;
348
+ if (seen.has(value)) return;
349
+ seen.add(value);
350
+ const ec = exitClassOf(value);
351
+ if (ec !== void 0) out.push(ec);
352
+ if ("cause" in value) {
353
+ const causeVal = Reflect.get(value, "cause");
354
+ if (Array.isArray(causeVal)) for (const entry of causeVal) collectExitClassesFromValue(entry, out, seen, depth + 1);
355
+ else collectExitClassesFromValue(causeVal, out, seen, depth + 1);
366
356
  }
367
357
  }
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;
358
+ function collectExitClasses(exit) {
359
+ const out = [];
360
+ const seen = /* @__PURE__ */ new WeakSet();
361
+ if (Exit.isFailure(exit)) for (const reason of exit.cause.reasons) {
362
+ let candidate;
363
+ if (Cause.isFailReason(reason)) candidate = reason.error;
364
+ else if (Cause.isDieReason(reason)) candidate = reason.defect;
365
+ else candidate = void 0;
366
+ if (candidate !== void 0) collectExitClassesFromValue(candidate, out, seen, 0);
391
367
  }
392
- out.flags.push(described);
368
+ return out;
369
+ }
370
+ function reasonOf(value) {
371
+ if (!("reason" in value)) return;
372
+ const reason = Reflect.get(value, "reason");
373
+ if (typeof reason !== "string" || reason.length === 0) return;
374
+ const detail = causeTextOf(value);
375
+ if (detail === void 0) return reason;
376
+ return `${reason}: ${detail}`;
377
+ }
378
+ function causeTextOf(value, depth = 0) {
379
+ if (depth > 4 || !("cause" in value)) return;
380
+ const cause = Reflect.get(value, "cause");
381
+ return causeText(cause, depth + 1);
382
+ }
383
+ function configDetailOf(value) {
384
+ if (!("reason" in value) && !("message" in value)) return;
385
+ const reason = Reflect.get(value, "reason");
386
+ if (typeof reason === "string" && reason.length > 0) return reason;
387
+ const message = Reflect.get(value, "message");
388
+ if (typeof message === "string" && message.length > 0) return message;
389
+ }
390
+ function shouldVisitConfigValue(value, depth, seen) {
391
+ if (depth > 10) return false;
392
+ if (value === null || value === void 0) return false;
393
+ if (typeof value !== "object") return false;
394
+ if (seen.has(value)) return false;
395
+ return true;
396
+ }
397
+ function pushConfigCauses(value, depth, stack) {
398
+ if (!("cause" in value)) return;
399
+ const causeVal = Reflect.get(value, "cause");
400
+ if (Array.isArray(causeVal)) for (let index = causeVal.length - 1; index >= 0; index--) stack.push({
401
+ value: causeVal[index],
402
+ depth: depth + 1
403
+ });
404
+ else stack.push({
405
+ value: causeVal,
406
+ depth: depth + 1
407
+ });
393
408
  }
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;
409
+ function firstConfigErrorDetail(exit) {
410
+ if (!Exit.isFailure(exit)) return;
411
+ const seen = /* @__PURE__ */ new WeakSet();
412
+ const stack = [];
413
+ for (const reason of exit.cause.reasons) {
414
+ let candidate;
415
+ if (Cause.isFailReason(reason)) candidate = reason.error;
416
+ else if (Cause.isDieReason(reason)) candidate = reason.defect;
417
+ else candidate = void 0;
418
+ if (candidate !== void 0) stack.push({
419
+ value: candidate,
420
+ depth: 0
421
+ });
422
+ }
423
+ while (stack.length > 0) {
424
+ const entry = stack.pop();
425
+ if (entry === void 0) continue;
426
+ const { value, depth } = entry;
427
+ if (!shouldVisitConfigValue(value, depth, seen)) continue;
428
+ seen.add(value);
429
+ if (exitClassOf(value) === "ConfigError") {
430
+ const detail = configDetailOf(value);
431
+ if (detail !== void 0) return detail;
402
432
  }
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;
433
+ pushConfigCauses(value, depth, stack);
410
434
  }
411
435
  }
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);
436
+ function describeFailure(exit) {
437
+ if (!Exit.isFailure(exit)) return "Unknown failure";
438
+ const value = failureValue(exit);
439
+ if (value !== void 0) {
440
+ if (S.is(SurvivorsRejection)(value)) return value.remediation;
441
+ if (typeof value === "object" && value !== null) {
442
+ const reason = reasonOf(value);
443
+ if (reason !== void 0) return reason;
444
+ }
445
+ if (value instanceof Error && value.message.length > 0) return value.message;
446
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") return String(value);
425
447
  }
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);
448
+ if (collectExitClasses(exit).includes("ConfigError")) {
449
+ const detail = firstConfigErrorDetail(exit);
450
+ if (detail !== void 0) return detail;
451
+ }
452
+ const rendered = Cause.pretty(exit.cause);
453
+ if (rendered.length > 0) return rendered;
454
+ return "Unknown failure";
455
+ }
456
+ function unrecognizedArgumentOf(exit, argv) {
457
+ if (!Exit.isFailure(exit)) return;
458
+ const value = failureValue(exit);
459
+ if (value === void 0 || !CliError.isCliError(value)) return;
460
+ const errors = (() => {
461
+ if (S.is(CliError.ShowHelp)(value)) return value.errors;
462
+ return [value];
463
+ })();
464
+ for (const error of errors) {
465
+ if (S.is(CliError.UnrecognizedOption)(error)) {
466
+ const at = argv.indexOf(error.option);
467
+ const next = (() => {
468
+ if (at >= 0) return argv[at + 1];
469
+ })();
470
+ if (next !== void 0 && !next.startsWith("-")) return next;
471
+ return error.option;
433
472
  }
473
+ if (S.is(CliError.UnexpectedArgument)(error)) return error.arguments[0];
474
+ if (S.is(CliError.UnknownSubcommand)(error)) return error.subcommand;
434
475
  }
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
476
  }
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
- };
477
+ function failureValue(exit) {
478
+ if (!Exit.isFailure(exit)) return;
479
+ const failure = Cause.findErrorOption(exit.cause);
480
+ if (Option.isSome(failure)) return failure.value;
481
+ }
482
+ const SIGNAL_REMEDIATION = "the run was interrupted by a signal; re-run it to continue";
483
+ const PARSE_REMEDIATION = "re-run with --help to see the full usage";
484
+ const DEFAULT_REMEDIATION = "see --reportFile or the verdict envelope on stdout";
485
+ function successExitClassOf(exit) {
486
+ if (!Exit.isSuccess(exit)) return;
487
+ const value = exit.value;
488
+ if (!Predicate.hasProperty(value, "verdict")) return;
489
+ const candidate = value.verdict;
490
+ if (!isExitClass(candidate)) return;
491
+ return candidate;
492
+ }
493
+ function helpErrorCountOf(value) {
494
+ if (value === void 0) return;
495
+ if (!S.is(CliError.ShowHelp)(value)) return;
496
+ return value.errors.length;
497
+ }
498
+ function survivorsRejectionOf(value) {
499
+ if (value === void 0) return;
500
+ if (!S.is(SurvivorsRejection)(value)) return;
501
+ return value;
502
+ }
503
+ function present(value) {
504
+ if (value === null) return;
505
+ return value;
506
+ }
507
+ function survivorsReasonOf(survivors) {
508
+ if (survivors === void 0) return;
509
+ return survivors.reason;
510
+ }
511
+ function survivorsDiagnosticOf(survivors) {
512
+ if (survivors === void 0) return;
513
+ return survivors.remediation;
514
+ }
515
+ function omitUnknownFailure(diagnostic) {
516
+ if (diagnostic === "Unknown failure") return;
517
+ return diagnostic;
518
+ }
519
+ function capturedOrUnknown(captured) {
520
+ if (captured.length > 0) return captured;
521
+ return "Unknown failure";
522
+ }
523
+ function gatherRunOutcome(exit, signal, argv) {
524
+ const value = failureValue(exit);
525
+ const survivors = survivorsRejectionOf(value);
526
+ return RunOutcomeCommand.make({
527
+ succeeded: Exit.isSuccess(exit),
528
+ signal: present(signal),
529
+ interrupted: Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause),
530
+ helpErrorCount: helpErrorCountOf(value),
531
+ cliError: value !== void 0 && CliError.isCliError(value),
532
+ unrecognized: unrecognizedArgumentOf(exit, argv),
533
+ survivorsReason: survivorsReasonOf(survivors),
534
+ survivorsDiagnostic: survivorsDiagnosticOf(survivors),
535
+ schemaError: value !== void 0 && S.isSchemaError(value),
536
+ successExitClass: successExitClassOf(exit),
537
+ highestExitClass: present(highestExitClass(collectExitClasses(exit))),
538
+ configDetail: firstConfigErrorDetail(exit),
539
+ diagnostic: omitUnknownFailure(describeFailure(exit))
540
+ });
541
+ }
542
+ function errorText(error, captured) {
543
+ return Match.value(error).pipe(Match.tag("RunParseFailed", (failed) => {
544
+ if (failed.unrecognized !== void 0) return `Received unknown argument: '${failed.unrecognized}'`;
545
+ return capturedOrUnknown(captured);
546
+ }), Match.tag("RunSurvivorsRejected", (failed) => {
547
+ if (failed.diagnostic !== void 0) return failed.diagnostic;
548
+ return "Unknown failure";
549
+ }), Match.tag("RunInterrupted", () => capturedOrUnknown(captured)), Match.tag("RunConfigFailed", (failed) => {
550
+ if (captured.length > 0) return captured;
551
+ if (failed.detail !== void 0) return failed.detail;
552
+ return "Unknown failure";
553
+ }), Match.tag("RunFailed", (failed) => {
554
+ if (captured.length > 0) return captured;
555
+ if (failed.diagnostic !== void 0) return failed.diagnostic;
556
+ return "Unknown failure";
557
+ }), Match.exhaustive);
558
+ }
559
+ function remediationText(error) {
560
+ return Match.value(error).pipe(Match.tag("RunInterrupted", (failed) => {
561
+ if (failed.code > 128) return SIGNAL_REMEDIATION;
562
+ return DEFAULT_REMEDIATION;
563
+ }), Match.tag("RunParseFailed", () => PARSE_REMEDIATION), Match.tag("RunSurvivorsRejected", (failed) => {
564
+ if (failed.diagnostic !== void 0) return failed.diagnostic;
565
+ return DEFAULT_REMEDIATION;
566
+ }), Match.tag("RunConfigFailed", (failed) => {
567
+ if (failed.detail !== void 0) return `check the config file: ${failed.detail}`;
568
+ return "check the config file";
569
+ }), Match.tag("RunFailed", () => DEFAULT_REMEDIATION), Match.exhaustive);
570
+ }
571
+ function shapeEnvelope(error, captured) {
456
572
  return {
457
573
  schemaVersion: "1.0",
458
- tool: root.name,
459
- version,
460
- commands: [root]
574
+ code: runOutcomeCode(Result.fail(error)),
575
+ error: errorText(error, captured),
576
+ remediation: remediationText(error)
461
577
  };
462
578
  }
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));
579
+ function classifyRunOutcome(exit, signal, argv) {
580
+ return runOutcomeWorkflow(gatherRunOutcome(exit, signal, argv));
466
581
  }
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
582
  const capturedConsoleChunks = [];
487
583
  const countByLabel = /* @__PURE__ */ new Map();
488
584
  const timeByLabel = /* @__PURE__ */ new Map();
585
+ function inspectValue(value) {
586
+ if (typeof value === "string") return value;
587
+ return Formatter.format(value);
588
+ }
489
589
  function formatArgs(args) {
490
- return format(...args);
590
+ if (args.length === 0) return "";
591
+ const first = args[0];
592
+ if (typeof first === "string") {
593
+ let index = 1;
594
+ let result = first.replace(/%[sdijfopO%]/g, (match) => {
595
+ if (match === "%%") return "%";
596
+ if (index >= args.length) return match;
597
+ const arg = args[index++];
598
+ switch (match) {
599
+ case "%s": return String(arg);
600
+ case "%d":
601
+ case "%i":
602
+ case "%f": return Number(arg).toString();
603
+ case "%j": try {
604
+ return String(JSON.stringify(arg));
605
+ } catch {
606
+ return "[Circular]";
607
+ }
608
+ case "%o":
609
+ case "%O":
610
+ case "%p": return inspectValue(arg);
611
+ default: return match;
612
+ }
613
+ });
614
+ for (; index < args.length; index++) result += ` ${inspectValue(args[index])}`;
615
+ return result;
616
+ }
617
+ return args.map(inspectValue).join(" ");
491
618
  }
492
619
  function captureSync(args) {
493
620
  capturedConsoleChunks.push(formatArgs(args));
@@ -501,282 +628,453 @@ function captureCount(label) {
501
628
  countByLabel.set(key, next);
502
629
  capturedConsoleChunks.push(`${key}: ${next}`);
503
630
  }
504
- function captureTimeEnd(label, now) {
631
+ function captureTimeEnd(label, nowNanos) {
505
632
  const key = label ?? "default";
506
633
  const started = timeByLabel.get(key);
507
634
  if (started !== void 0) {
508
635
  timeByLabel.delete(key);
509
- capturedConsoleChunks.push(`${key}: ${now - started}ms`);
636
+ const diffMs = Number(nowNanos - started) / 1e6;
637
+ capturedConsoleChunks.push(`${key}: ${diffMs}ms`);
510
638
  }
511
639
  }
512
640
  function captureTrace(args) {
513
641
  capturedConsoleChunks.push(`Trace: ${formatArgs(args)}\n${(/* @__PURE__ */ new Error()).stack ?? ""}`);
514
642
  }
515
- const capturingConsole = {
643
+ const makeCapturingConsole = (clock) => ({
516
644
  assert: (condition, ...args) => captureAssert(condition, args),
517
645
  clear: () => {},
518
646
  count: (label) => captureCount(label),
519
647
  countReset: (label) => countByLabel.delete(label ?? "default"),
520
648
  debug: (...args) => captureSync(args),
521
- dir: (item, options) => capturedConsoleChunks.push(inspect(item, options)),
522
- dirxml: (item) => capturedConsoleChunks.push(inspect(item)),
649
+ dir: (item, _options) => capturedConsoleChunks.push(Formatter.format(item)),
650
+ dirxml: (item) => capturedConsoleChunks.push(Formatter.format(item)),
523
651
  error: (...args) => captureSync(args),
524
652
  group: () => {},
525
653
  groupCollapsed: () => {},
526
654
  groupEnd: () => {},
527
655
  info: (...args) => captureSync(args),
528
656
  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()),
657
+ table: (tabularData) => capturedConsoleChunks.push(Formatter.format(tabularData)),
658
+ time: (label) => timeByLabel.set(label ?? "default", clock.monotonicTimeNanosUnsafe()),
659
+ timeEnd: (label) => captureTimeEnd(label, clock.monotonicTimeNanosUnsafe()),
535
660
  timeLog: (label, ...args) => {
536
661
  const key = label ?? "default";
537
662
  const started = timeByLabel.get(key);
538
- if (started !== void 0) capturedConsoleChunks.push(`${key}: ${performance.now() - started}ms ${formatArgs(args)}`);
663
+ if (started === void 0) return;
664
+ const diffMs = Number(clock.monotonicTimeNanosUnsafe() - started) / 1e6;
665
+ if (args.length === 0) {
666
+ capturedConsoleChunks.push(`${key}: ${diffMs}ms`);
667
+ return;
668
+ }
669
+ capturedConsoleChunks.push(`${key}: ${diffMs}ms ${formatArgs(args)}`);
539
670
  },
540
671
  trace: (...args) => captureTrace(args),
541
672
  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(() => {
673
+ });
674
+ const machineConsoleLayer = Layer.effect(Console.Console, Clock.clockWith((clock) => Effect.sync(() => {
562
675
  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
- */
676
+ return makeCapturingConsole(clock);
677
+ })));
570
678
  function readCapturedConsole() {
571
679
  return capturedConsoleChunks.join("\n");
572
680
  }
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
681
  function resetCapturedConsole() {
578
682
  capturedConsoleChunks.length = 0;
579
683
  countByLabel.clear();
580
684
  timeByLabel.clear();
581
685
  }
582
686
  //#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
- }
687
+ //#region src/Output.workflow.ts
688
+ const TOOL_VARIABLES$1 = ["CLAUDECODE", "CODEX_SANDBOX"];
631
689
  /**
632
- * The resolved options without the survivors-run bookkeeping keys, so both
633
- * sides of the admission hash describe the same configuration.
690
+ * The command of the output-mode workflow: a schema class, because `Workflow.make`
691
+ * derives the command type from it and pins the error channel at the construction site.
634
692
  */
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
- }
693
+ var ResolveModeCommand = class extends S.TaggedClass()("ResolveModeCommand", {
694
+ stdoutIsTTY: S.Boolean,
695
+ text: S.optional(S.Boolean),
696
+ json: S.optional(S.Boolean),
697
+ envMode: S.optional(S.String),
698
+ agent: S.optional(S.String),
699
+ toolVars: S.optional(S.Record(S.String, S.String))
700
+ }) {};
641
701
  /**
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.
702
+ * The conflict error for mutually exclusive format flags. Defined locally so the
703
+ * workflow decision remains pure the sealed effect surface does not include
704
+ * `effect/unstable/cli/CliError`, so the decision returns this local error and
705
+ * the shell maps it to `CliError.InvalidValue` at the boundary.
646
706
  */
647
- function wasProducedBySurvivorsRun(priorReport) {
648
- const config = priorReport.config;
649
- return isRecord(config) && "survivorsPriorReport" in config;
707
+ var ModeConflictError = class extends S.TaggedError()("ModeConflictError", {
708
+ option: S.String,
709
+ value: S.String,
710
+ expected: S.String
711
+ }) {};
712
+ const CONFLICT_EXPECTED = "the \"--format text\" and \"--json\" flags are mutually exclusive — use one or the other";
713
+ function r4(command) {
714
+ if (command.text === true && command.json === true) return Result.fail(ModeConflictError.make({
715
+ option: "json",
716
+ value: "text",
717
+ expected: CONFLICT_EXPECTED
718
+ }));
719
+ if (command.text === true) return Result.succeed({
720
+ mode: "human",
721
+ signal: "flag",
722
+ stdoutIsTTY: command.stdoutIsTTY
723
+ });
724
+ if (command.json === true) return Result.succeed({
725
+ mode: "machine",
726
+ signal: "flag",
727
+ stdoutIsTTY: command.stdoutIsTTY
728
+ });
729
+ if (command.envMode !== void 0 && command.envMode.length > 0) {
730
+ if (command.envMode === "machine") return Result.succeed({
731
+ mode: "machine",
732
+ signal: "env",
733
+ stdoutIsTTY: command.stdoutIsTTY
734
+ });
735
+ return Result.succeed({
736
+ mode: "human",
737
+ signal: "env",
738
+ stdoutIsTTY: command.stdoutIsTTY
739
+ });
740
+ }
741
+ if (!command.stdoutIsTTY) return Result.succeed({
742
+ mode: "machine",
743
+ signal: "tty",
744
+ stdoutIsTTY: false
745
+ });
746
+ if (command.agent !== void 0 && command.agent.length > 0) return Result.succeed({
747
+ mode: "machine",
748
+ signal: "agent",
749
+ stdoutIsTTY: true
750
+ });
751
+ const toolVars = command.toolVars ?? {};
752
+ for (const variable of TOOL_VARIABLES$1) {
753
+ const value = toolVars[variable];
754
+ if (typeof value === "string" && value.length > 0) return Result.succeed({
755
+ mode: "machine",
756
+ signal: "tool",
757
+ stdoutIsTTY: true
758
+ });
759
+ }
760
+ return Result.succeed({
761
+ mode: "human",
762
+ signal: "tty",
763
+ stdoutIsTTY: true
764
+ });
650
765
  }
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);
766
+ function modeDecision(command) {
767
+ return r4(command);
658
768
  }
769
+ const resolveModeWorkflow = Workflow.make(ResolveModeCommand, modeDecision);
770
+ //#endregion
771
+ //#region src/Output.ts
659
772
  /**
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.
773
+ * Output the machine/human output capability.
774
+ *
775
+ * The NDJSON run-event stream, wire protocol constants, mode resolution probes,
776
+ * and machine-mode terminal output. Pure mode resolution lives in
777
+ * Output.workflow.ts.
664
778
  */
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
779
  /**
674
- * Converts a report mutant (1-based schema location) into the internal mutant
675
- * shape a run consumes (0-based positions, absolute file name) — the exact
676
- * inverse of `objectUtils.toSchemaLocation` and the same shift the
677
- * incremental report reader applies (`project-reader.ts`). Mutants without a
678
- * replacement fall back to their mutator name, the same convention the
679
- * incremental differ uses.
780
+ * The heartbeat interval (R19), matching Terraform's `apply_progress`
781
+ * cadence: long enough that a slow phase is not noisy, short enough that a
782
+ * consumer can tell "slow" from "hung" without waiting for a mutant event.
680
783
  */
681
- function reportMutantToMutant(file, mutant, resolveAbsolutePath) {
784
+ const TICK_INTERVAL_MS = 1e4;
785
+ var RunEventStreamPortTag = class extends Context.Service()("@systemfsoftware/stryker-js-cli/Output/RunEventStreamPortTag") {};
786
+ const RunEventStreamPort = RunEventStreamPortTag;
787
+ const isTerminalEvent = (event) => Match.value(event).pipe(Match.tag("verdict", () => true), Match.tag("error", () => true), Match.tag("help", () => true), Match.tag("manifest", () => true), Match.orElse(() => false));
788
+ const wireKind = (event) => Match.value(event).pipe(Match.tag("stream", () => "stream"), Match.tag("phase", () => "phase"), Match.tag("plan", () => "plan"), Match.tag("mutant", () => "mutant"), Match.tag("tick", () => "tick"), Match.tag("verdict", () => "verdict"), Match.tag("error", () => "error"), Match.tag("help", () => "help"), Match.tag("manifest", () => "manifest"), Match.exhaustive);
789
+ const toWireLine = (event) => {
790
+ const fields = Object.fromEntries(Object.entries(event).filter(([key]) => key !== "_tag"));
791
+ return JSON.stringify({
792
+ kind: wireKind(event),
793
+ ...fields
794
+ });
795
+ };
796
+ function numberText(value, fallback) {
797
+ if (typeof value === "number") return String(value);
798
+ return fallback;
799
+ }
800
+ function phaseLine(phase) {
801
+ if (typeof phase === "string") return `phase ${phase}`;
802
+ return "phase ";
803
+ }
804
+ const stderrProgressLine = (event, alreadyClosed) => {
805
+ if (alreadyClosed) return;
806
+ return Match.value(event).pipe(Match.tag("plan", (e) => `plan ${numberText(e.total, "0")} mutants`), Match.tag("phase", (e) => phaseLine(e.phase)), Match.tag("tick", (e) => `${numberText(e.completed, "0")}/${numberText(e.total, "?")} elapsed ${numberText(e.elapsedMs, "0")}ms`), Match.tag("verdict", (e) => `score ${numberText(e.score, "n/a")} killed ${numberText(e.counts.killed, "0")} survived ${numberText(e.counts.survived, "0")}`), Match.tag("error", (e) => {
807
+ if (typeof e.error === "string") return `error ${e.error}`;
808
+ return "error ";
809
+ }), Match.orElse(() => void 0));
810
+ };
811
+ const writeStderr = (stdio, line) => Stream.run(Stream.succeed(`${line}\n`), stdio.stderr({ endOnDone: false })).pipe(Effect.ignore);
812
+ const drainOf = (stdio, framed) => Stream.run(framed, stdio.stdout({ endOnDone: true })).pipe(Effect.ignore);
813
+ const makeRunEventStream = (stdio, resolved, drainFramed = drainOf.bind(null, stdio)) => Effect.gen(function* () {
814
+ const runId = generateRunId();
815
+ const startedAt = yield* Clock.currentTimeMillis;
816
+ const queue = yield* Queue.unbounded();
817
+ const state = {
818
+ mode: resolved.mode,
819
+ signal: resolved.signal,
820
+ headerWritten: false,
821
+ terminalWritten: false,
822
+ progress: {
823
+ completed: 0,
824
+ total: null
825
+ },
826
+ findingsPrinted: 0
827
+ };
828
+ const queueStream = Stream.fromQueue(queue).pipe(Stream.tap((event) => Effect.sync(() => {
829
+ Match.value(event).pipe(Match.tag("plan", (e) => {
830
+ state.progress = {
831
+ ...state.progress,
832
+ total: e.total
833
+ };
834
+ }), Match.tag("mutant", (e) => {
835
+ state.progress = {
836
+ completed: e.completed,
837
+ total: e.total
838
+ };
839
+ }), Match.orElse(() => {}));
840
+ })));
841
+ const tickStream = Stream.tick(TICK_INTERVAL_MS).pipe(Stream.drop(1), Stream.filter(() => state.headerWritten && !state.terminalWritten), Stream.mapEffect(() => Effect.gen(function* () {
842
+ const now = yield* Clock.currentTimeMillis;
843
+ return Heartbeat.make({
844
+ elapsedMs: now - startedAt,
845
+ completed: state.progress.completed,
846
+ total: state.progress.total
847
+ });
848
+ })));
849
+ let terminalSeen = false;
850
+ const drain = drainFramed(Stream.merge(queueStream, tickStream, { haltStrategy: "either" }).pipe(Stream.tap((event) => {
851
+ const line = stderrProgressLine(event, state.terminalWritten);
852
+ if (isTerminalEvent(event)) state.terminalWritten = true;
853
+ if (line === void 0) return Effect.void;
854
+ return writeStderr(stdio, line);
855
+ })).pipe(Stream.filter((event) => {
856
+ if (state.mode !== "machine") return false;
857
+ if (terminalSeen) return false;
858
+ if (isTerminalEvent(event)) terminalSeen = true;
859
+ return true;
860
+ }), Stream.map((event) => `${toWireLine(event)}\n`)));
861
+ let drainFiber = null;
682
862
  return {
683
- id: mutant.id,
684
- fileName: resolveAbsolutePath(file),
685
- mutatorName: mutant.mutatorName,
686
- replacement: mutant.replacement ?? mutant.mutatorName,
687
- location: {
688
- start: {
689
- line: mutant.location.start.line - 1,
690
- column: mutant.location.start.column - 1
691
- },
692
- end: {
693
- line: mutant.location.end.line - 1,
694
- column: mutant.location.end.column - 1
863
+ queue,
864
+ runId,
865
+ startedAt,
866
+ isOpen: () => state.mode === "machine" && !state.terminalWritten && drainFiber !== null,
867
+ ensureOpen: (openResolved) => {
868
+ if (state.headerWritten) return;
869
+ state.mode = openResolved.mode;
870
+ state.signal = openResolved.signal;
871
+ },
872
+ open: Effect.gen(function* () {
873
+ if (drainFiber === null) {
874
+ if (!state.headerWritten) {
875
+ state.headerWritten = true;
876
+ if (state.mode === "machine") yield* Queue.offer(queue, RunStarted.make({
877
+ schemaVersion: "1.0",
878
+ runId,
879
+ mode: state.mode,
880
+ signal: state.signal
881
+ }));
882
+ }
883
+ drainFiber = yield* Effect.forkDetach(drain);
695
884
  }
696
- }
885
+ }),
886
+ closeAndDrain: Effect.gen(function* () {
887
+ state.terminalWritten = true;
888
+ yield* Queue.end(queue);
889
+ if (drainFiber !== null) yield* Fiber.join(drainFiber);
890
+ })
697
891
  };
698
- }
892
+ });
893
+ Layer.effect(RunEventStreamPort, Effect.map(Stdio.Stdio, (stdio) => RunEventStreamPort.of({ createRunEventStream: (resolved) => makeRunEventStream(stdio, resolved) })));
894
+ const TOOL_VARIABLES = ["CLAUDECODE", "CODEX_SANDBOX"];
699
895
  /**
700
- * The survivors of the prior report: exactly the mutants whose status is
701
- * `Survived`, converted to the internal mutant shape so a run can re-test
702
- * them.
896
+ * The log colouriser's gate (R8). Machine mode never emits colour, so a
897
+ * harness merging `2>&1` is not handed escape sequences it must strip, and
898
+ * `NO_COLOR` is honoured for the human path per the convention: any value
899
+ * other than an unset or empty variable disables colour.
703
900
  */
704
- function extractSurvivors(priorReport, resolveAbsolutePath) {
705
- const survivors = [];
706
- for (const [file, fileResult] of objectEntries(priorReport.files)) for (const mutant of fileResult.mutants) if (mutant.status === "Survived") survivors.push(reportMutantToMutant(file, mutant, resolveAbsolutePath));
707
- return survivors;
901
+ function isColorEnabled(resolved, noColor) {
902
+ if (resolved.mode !== "human") return false;
903
+ if (noColor === void 0) return true;
904
+ if (noColor.length === 0) return true;
905
+ return false;
708
906
  }
907
+ var OutputModeProbeTag = class extends Context.Service()("@systemfsoftware/stryker-js-cli/Output/OutputModeProbeTag") {};
908
+ const OutputModeProbe = OutputModeProbeTag;
909
+ const outputModeProbeDescription = pipe(Cell.read((command) => Effect.succeed((() => {
910
+ const toolVarsRecord = {};
911
+ for (const variable of TOOL_VARIABLES) {
912
+ const value = process.env[variable];
913
+ if (value !== void 0) toolVarsRecord[variable] = value;
914
+ }
915
+ const envMode = process.env["STRYKER_MODE"];
916
+ const agent = process.env["AGENT"];
917
+ let result = { stdoutIsTTY: process.stdout.isTTY === true };
918
+ if (Object.keys(toolVarsRecord).length > 0) result = {
919
+ ...result,
920
+ toolVars: toolVarsRecord
921
+ };
922
+ if (envMode !== void 0) result = {
923
+ ...result,
924
+ envMode
925
+ };
926
+ if (agent !== void 0) result = {
927
+ ...result,
928
+ agent
929
+ };
930
+ if (command.text !== void 0) result = {
931
+ ...result,
932
+ text: command.text
933
+ };
934
+ if (command.json !== void 0) result = {
935
+ ...result,
936
+ json: command.json
937
+ };
938
+ return result;
939
+ })())), Cell.decode((raw) => Result.succeed((() => {
940
+ const filteredToolVars = {};
941
+ if (raw.toolVars !== void 0) {
942
+ for (const [key, value] of Object.entries(raw.toolVars)) if (value !== void 0) filteredToolVars[key] = value;
943
+ }
944
+ let commandInput = { stdoutIsTTY: raw.stdoutIsTTY };
945
+ if (raw.text !== void 0) commandInput = {
946
+ ...commandInput,
947
+ text: raw.text
948
+ };
949
+ if (raw.json !== void 0) commandInput = {
950
+ ...commandInput,
951
+ json: raw.json
952
+ };
953
+ if (raw.envMode !== void 0) commandInput = {
954
+ ...commandInput,
955
+ envMode: raw.envMode
956
+ };
957
+ if (raw.agent !== void 0) commandInput = {
958
+ ...commandInput,
959
+ agent: raw.agent
960
+ };
961
+ if (Object.keys(filteredToolVars).length > 0) commandInput = {
962
+ ...commandInput,
963
+ toolVars: filteredToolVars
964
+ };
965
+ return ResolveModeCommand.make(commandInput);
966
+ })())), Cell.decide(resolveModeWorkflow), Cell.encode((outcome) => outcome), Cell.write((outcome) => Result.match(outcome, {
967
+ onFailure: (error) => Effect.fail(error),
968
+ onSuccess: (mode) => Effect.succeed(mode)
969
+ })));
970
+ const detectModeWithProbe = (flags = {}) => Cell.apply(outputModeProbeDescription, flags).pipe(Effect.mapError((error) => CliError.InvalidValue.make({
971
+ option: error.option,
972
+ value: error.value,
973
+ expected: error.expected,
974
+ kind: "flag"
975
+ })));
976
+ const OutputModeProbeLive = Layer.succeed(OutputModeProbe, OutputModeProbe.of({ detectMode: detectModeWithProbe({}) }));
709
977
  /**
710
- * The survivor spans as `file:startLine:startCol-endLine:endCol` mutate
711
- * ranges: the report's 1-based lines with the internal 0-based columns,
712
- * relative file names, deduplicated in first-seen order.
978
+ * Machine mode emits the U4 verdict envelope for a run that produced no
979
+ * mutants and no report file: a `--survivors` run with zero survivors (AE3)
980
+ * or a successful `--dryRunOnly` run that ended before the mutation
981
+ * pipeline. The envelope carries a null score and an empty mutant list and is
982
+ * written as the terminal `verdict` line of the stdout stream (U6), carrying
983
+ * the run id the stream header already opened with (KTD11 — never a fresh
984
+ * id). Human mode prints nothing (the sink drops in human mode).
713
985
  */
714
- function survivorMutateSpans(survivors) {
715
- const spans = [];
716
- const seen = /* @__PURE__ */ new Set();
717
- for (const survivor of survivors) {
718
- const file = toRelativeNormalizedFileName(survivor.fileName);
719
- const { start, end } = survivor.location;
720
- const span = `${file}:${start.line + 1}:${start.column}-${end.line + 1}:${end.column}`;
721
- if (!seen.has(span)) {
722
- seen.add(span);
723
- spans.push(span);
986
+ function emitNullScoreVerdict(stream, mode, thresholds, config, basePath, pathService) {
987
+ const envelope = buildVerdictEnvelope({
988
+ schemaVersion: "1.0",
989
+ files: {},
990
+ thresholds,
991
+ projectRoot: basePath,
992
+ config,
993
+ framework: {
994
+ name: "StrykerJS",
995
+ version: strykerVersion
724
996
  }
725
- }
726
- return spans;
997
+ }, mode.mode, mode.signal, stream.runId, basePath, pathService);
998
+ return Queue.offer(stream.queue, VerdictReached.make({
999
+ schemaVersion: envelope.schemaVersion,
1000
+ runId: envelope.runId,
1001
+ mode: envelope.mode,
1002
+ signal: envelope.signal,
1003
+ score: envelope.score,
1004
+ thresholds: envelope.thresholds,
1005
+ reportFile: envelope.reportFile,
1006
+ counts: envelope.counts,
1007
+ mutants: envelope.mutants
1008
+ }));
727
1009
  }
728
1010
  /**
729
- * The mutant shape the admission carries, named once because both the decision's
730
- * `Admitted` payload and the command's precomputed survivor list are the same shape.
731
- */
732
- const MutantShape = S.Struct({
733
- id: S.String,
734
- fileName: S.String,
735
- mutatorName: S.String,
736
- replacement: S.String,
737
- location: S.Struct({
738
- start: S.Struct({
739
- line: S.Finite,
740
- column: S.Finite
741
- }),
742
- end: S.Struct({
743
- line: S.Finite,
744
- column: S.Finite
745
- })
746
- })
747
- });
748
- /**
749
- * The prior report as a document, decoded at the boundary. Module-internal: consumers
750
- * get {@link decodePriorReport}, not the schema, so the report's wire shape is not a
751
- * surface commitment and the codec has exactly one caller.
752
- *
753
- * `status` is a bare string rather than the closed status set on purpose: the decide only
754
- * compares it to `'Survived'`, so a report written by a newer engine that added a status
755
- * must not be refused for carrying one.
1011
+ * Emits the machine-mode output from the run's finalizer it runs on
1012
+ * success, failure and interruption alike (R30): a failed run writes the
1013
+ * `error` terminal event as the last line of the stdout stream; a successful
1014
+ * run whose only console output was the framework's help/version rendering
1015
+ * emits that captured document as the `help` terminal event, so `--help` in
1016
+ * machine mode never leaks an ANSI document. A successful run with an empty
1017
+ * buffer (the normal verdict path) emits nothing extra — the run already
1018
+ * wrote its terminal `verdict` line through the same module — unless the
1019
+ * stream is still open, which means the run never reached a verdict (the
1020
+ * `--dryRunOnly` early return): then a null-score `verdict` closes the
1021
+ * stream so the last stdout line is always a terminal event (R5).
1022
+ */
1023
+ function emitMachineModeOutput(stream, mode, outcome, basePath, pathService) {
1024
+ return Effect.gen(function* () {
1025
+ const captured = readCapturedConsole();
1026
+ if (Result.isSuccess(outcome)) {
1027
+ if (outcome.success.help) {
1028
+ yield* Queue.offer(stream.queue, HelpRendered.make({
1029
+ schemaVersion: "1.0",
1030
+ code: 0,
1031
+ help: captured
1032
+ }));
1033
+ return;
1034
+ }
1035
+ if (captured.length > 0) {
1036
+ yield* Queue.offer(stream.queue, HelpRendered.make({
1037
+ schemaVersion: "1.0",
1038
+ code: 0,
1039
+ help: captured
1040
+ }));
1041
+ return;
1042
+ }
1043
+ if (stream.isOpen()) yield* emitNullScoreVerdict(stream, mode, (yield* defaultOptions).thresholds, {}, basePath, pathService);
1044
+ return;
1045
+ }
1046
+ const envelope = shapeEnvelope(outcome.failure, captured);
1047
+ yield* Queue.offer(stream.queue, RunFailed.make({
1048
+ schemaVersion: envelope.schemaVersion,
1049
+ code: envelope.code,
1050
+ error: envelope.error,
1051
+ remediation: envelope.remediation
1052
+ }));
1053
+ });
1054
+ }
1055
+ //#endregion
1056
+ //#region src/Survivors.ts
1057
+ /**
1058
+ * Survivors — the survivors-admission capability.
1059
+ *
1060
+ * The prior-report decoding, source hashing, mutant conversion, and admission
1061
+ * pipeline for --survivors runs. Pure admission decision lives in
1062
+ * Survivors.workflow.ts.
1063
+ */
1064
+ const DEFAULT_SURVIVORS_PRIOR_REPORT = "reports/mutation-report.json";
1065
+ /**
1066
+ * The mutant shape the admission carries, named once because both the decision's
1067
+ * `Admitted` payload and the command's precomputed survivor list are the same shape.
1068
+ */
1069
+ /**
1070
+ * The prior report as a document, decoded at the boundary. Module-internal: consumers
1071
+ * get the decode function, not the schema, so the report's wire shape is not a
1072
+ * surface commitment and the codec has exactly one caller.
1073
+ *
1074
+ * `status` is a bare string rather than the closed status set on purpose: the decide only
1075
+ * compares it to `'Survived'`, so a report written by a newer engine that added a status
1076
+ * must not be refused for carrying one.
756
1077
  */
757
- const PriorReportDocument = S.Struct({
758
- config: S.optional(S.Record(S.String, S.Unknown)),
759
- framework: S.optional(S.Struct({ version: S.optional(S.String) })),
760
- files: S.Record(S.String, S.Struct({
761
- source: S.String,
762
- mutants: S.Array(S.Struct({
763
- id: S.String,
764
- mutatorName: S.String,
765
- replacement: S.optional(S.String),
766
- status: S.String,
767
- location: S.Struct({
768
- start: S.Struct({
769
- line: S.Finite,
770
- column: S.Finite
771
- }),
772
- end: S.Struct({
773
- line: S.Finite,
774
- column: S.Finite
775
- })
776
- })
777
- }))
778
- }))
779
- });
780
1078
  /**
781
1079
  * Decodes a prior report read from disk. Pure, so it runs in the decode phase, whose
782
1080
  * `Left` is fatal by construction — it reaches the derived error channel and no write
@@ -784,165 +1082,80 @@ const PriorReportDocument = S.Struct({
784
1082
  * a third-party report type.
785
1083
  */
786
1084
  const decodePriorReport = S.decodeUnknownResult(PriorReportDocument);
1085
+ const { entries: objectEntries, fromEntries: objectFromEntries } = Object;
787
1086
  /**
788
- * The prior report's facts the decision reads: its embedded configuration, which carries
789
- * both the compared options and the survivors-run provenance marker, and the engine
790
- * version it recorded. The report's files are not here — the survivors and the per-file
791
- * source hashes derived from them need capabilities the command cannot hold, so they
792
- * arrive already computed.
1087
+ * Thin by design: the digest is the caller's capability, and naming the call
1088
+ * keeps every hashing site in the admission path reading the same way.
793
1089
  */
794
- var PriorReportFacts = class extends S.Class("PriorReportFacts")({
795
- config: S.Record(S.String, S.Unknown),
796
- frameworkVersion: S.UndefinedOr(S.String)
797
- }) {};
1090
+ function sourceContentHash(content, hash) {
1091
+ return hash(content);
1092
+ }
798
1093
  /**
799
- * The command of the admission workflow: a schema class, because `Workflow.make`
800
- * constrains its first argument on the class value and a declared interface produces no
801
- * value to pass. Every field is pure data the two capabilities the previous shape
802
- * carried, a digest function and a path resolver, can never be schema fields, so their
803
- * results arrive precomputed from the decode phase instead.
1094
+ * The per-file source hashes of the sources a prior report embeds.
1095
+ *
1096
+ * The current run's side of the comparison is gathered by the shell from disk;
1097
+ * this is the recorded side, read back out of the report.
804
1098
  */
805
- var AdmitSurvivorsRunCommand = class extends S.Class("AdmitSurvivorsRunCommand")({
806
- /**
807
- * The prior run's report facts, `undefined` when no report exists — the run cannot be
808
- * admitted without one ('no-report'). Explicitly nullable rather than key-optional: a
809
- * missing report is a state the edge determined and states, not a key it forgot.
810
- */
811
- priorReport: S.UndefinedOr(PriorReportFacts),
812
- /** The current run's resolved options (defaults + config file + CLI). */
813
- currentConfig: S.Record(S.String, S.Unknown),
814
- /** The current CLI/framework version (`strykerVersion`). */
815
- frameworkVersion: S.String,
816
- /**
817
- * Per-file content hashes of the current source, keyed by the prior report's relative
818
- * file keys. The prior side is hashed from the sources the report embeds, so an editor
819
- * save that shifts line ranges — which would silently re-test a different mutant than
820
- * the one that survived — is caught here.
821
- */
822
- sourceContentHashes: S.Record(S.String, S.String),
823
- /** The same hashes for the sources the prior report embeds, computed at the edge. */
824
- priorSourceHashes: S.Record(S.String, S.String),
825
- /** The prior report's survivors, already converted to the internal mutant shape. */
826
- priorSurvivors: S.Array(MutantShape)
827
- }) {};
828
- const NO_REPORT_DETAIL = "No prior mutation report found — a --survivors run needs the report of a previous run.";
829
- 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.";
830
- const MISMATCH_DETAIL = "The prior mutation report does not match the current run (resolved options, framework version, or source content differ).";
831
- /** The per-file source hashes of the sources the prior report embeds. */
832
1099
  function priorSourceHashes(priorReport, hashContent) {
833
1100
  return objectFromEntries(objectEntries(priorReport.files).map(([file, fileResult]) => [file, sourceContentHash(fileResult.source, hashContent)]));
834
1101
  }
835
1102
  /**
836
- * Whether the admission inputs agree: the prior report's embedded resolved options,
837
- * framework version and source content against the current run's.
838
- *
839
- * The comparison is on the canonical serializations rather than digests of them. Equal
840
- * serializations are equal runs, so the digest was a lossy restatement of the check that
841
- * also demanded a capability no command can carry.
1103
+ * Converts a report mutant (1-based schema location) into the internal mutant
1104
+ * shape a run consumes (0-based positions, absolute file name) — the exact
1105
+ * inverse of `objectUtils.toSchemaLocation` and the same shift the
1106
+ * incremental report reader applies (`project-reader.ts`). Mutants without a
1107
+ * replacement fall back to their mutator name, the same convention the
1108
+ * incremental differ uses.
842
1109
  */
843
- function hashesMatch(priorReport, input) {
844
- return serializeSurvivorsHashInput({
845
- resolvedOptions: stripSurvivorsKeys(priorReport.config),
846
- frameworkVersion: priorReport.frameworkVersion,
847
- sourceContentHashes: input.priorSourceHashes
848
- }) === serializeSurvivorsHashInput({
849
- resolvedOptions: stripSurvivorsKeys(input.currentConfig),
850
- frameworkVersion: input.frameworkVersion,
851
- sourceContentHashes: input.sourceContentHashes
1110
+ function reportMutantToMutant(file, mutant, resolveAbsolutePath) {
1111
+ return Mutant.make({
1112
+ id: mutant.id,
1113
+ fileName: resolveAbsolutePath(file),
1114
+ mutatorName: mutant.mutatorName,
1115
+ replacement: mutant.replacement ?? mutant.mutatorName,
1116
+ location: {
1117
+ start: {
1118
+ line: mutant.location.start.line - 1,
1119
+ column: mutant.location.start.column - 1
1120
+ },
1121
+ end: {
1122
+ line: mutant.location.end.line - 1,
1123
+ column: mutant.location.end.column - 1
1124
+ }
1125
+ }
852
1126
  });
853
1127
  }
854
- const rejection = (reason, detail) => ({
855
- kind: "reject",
856
- reason,
857
- remediation: `${detail} ${SURVIVORS_RUN_FIRST_REMEDIATION}`
858
- });
859
- function admissionVerdict(input) {
860
- const priorReport = input.priorReport;
861
- if (priorReport === void 0) return rejection("no-report", NO_REPORT_DETAIL);
862
- if (wasProducedBySurvivorsRun(priorReport)) return rejection("mismatch", SURVIVORS_RUN_SOURCE_DETAIL);
863
- if (input.priorSurvivors.length === 0) return { kind: "no-survivors" };
864
- if (!hashesMatch(priorReport, input)) return rejection("mismatch", MISMATCH_DETAIL);
865
- return {
866
- kind: "admit",
867
- survivors: input.priorSurvivors
868
- };
869
- }
870
- const SurvivorsAdmissionTypeId = Symbol.for("@systemfsoftware/stryker-js-cli/SurvivorsAdmission");
871
- var Admitted = class extends S.TaggedClass()("Admitted", { survivors: S.Array(MutantShape) }) {
872
- [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
873
- };
874
- var NoSurvivors = class extends S.TaggedClass()("NoSurvivors", {}) {
875
- [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
876
- };
877
- S.Union([Admitted, NoSurvivors]);
878
- var SurvivorsRejection = class extends S.TaggedError()("SurvivorsRejection", {
879
- reason: S.Literals(["no-report", "mismatch"]),
880
- remediation: S.String
881
- }) {
882
- [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
883
- };
884
- /**
885
- * The survivors admission decision: the classification `admissionVerdict`
886
- * produces, assigned to the workflow channels — one arm per kind, no guard
887
- * chain. A missing report, a survivors-sourced report and a hash mismatch are
888
- * the same reject outcome with different reasons; only the rejection's
889
- * remediation names the full run to do first (R10).
890
- */
891
- const admitSurvivorsRun = Workflow.make(AdmitSurvivorsRunCommand, (command) => Match.value(admissionVerdict(command)).pipe(Match.discriminator("kind")("reject", (verdict) => Result.fail(SurvivorsRejection.make({
892
- reason: verdict.reason,
893
- remediation: verdict.remediation
894
- }))), Match.discriminator("kind")("no-survivors", () => Result.succeed(NoSurvivors.make())), Match.discriminator("kind")("admit", (verdict) => Result.succeed(Admitted.make({ survivors: verdict.survivors }))), Match.exhaustive));
895
- //#endregion
896
- //#region src/SurvivorsExit.ts
897
- /** The exit class a rejected survivors run exits with (R6: exit 2). */
898
- const SURVIVORS_REJECT_EXIT_CLASS = ExitClass.ConfigError;
899
- //#endregion
900
- //#region src/cli-request.schema.ts
901
- const RunRequestSchema = S.TaggedStruct("run", {
902
- options: S.Any,
903
- survivors: S.Boolean
904
- });
905
- const LlmsRequestSchema = S.TaggedStruct("llms", { document: S.Any });
906
- S.Union([RunRequestSchema, LlmsRequestSchema]);
907
- //#endregion
908
- //#region src/StrykerCliExecutor.ts
909
- /**
910
- * The default run: binds the host-resolved run options (the sink, the mode,
911
- * the timing) to a fresh `Stryker` and runs mutation testing.
912
- */
913
- const defaultRunMutationTest = (hostOptions) => (options) => new Stryker(options, hostOptions).runMutationTest();
914
1128
  /**
915
- * The machine-mode `Console` layer, bundled so the transport (which resolves
916
- * the mode) can provide it without importing the state cell. Human mode
917
- * provides no layer — effect's own default console is the prose rendering
918
- * (OutputModeConsoleState.ts).
1129
+ * The survivors of the prior report: exactly the mutants whose status is
1130
+ * `Survived`, converted to the internal mutant shape so a run can re-test
1131
+ * them.
919
1132
  */
920
- const strykerCliConsoleLayers = { machine: machineConsoleLayer };
921
- const SIGNAL_NUMBERS = Object.freeze({
922
- SIGINT: 2,
923
- SIGTERM: 15
924
- });
925
- const hashContent = (content) => createHash("sha256").update(content, "utf-8").digest("hex");
926
- const resolveAbsolutePath = (file) => resolve(file);
1133
+ function extractSurvivors(priorReport, resolveAbsolutePath) {
1134
+ const survivors = [];
1135
+ for (const [file, fileResult] of objectEntries(priorReport.files)) for (const mutant of fileResult.mutants) if (mutant.status === "Survived") survivors.push(reportMutantToMutant(file, mutant, resolveAbsolutePath));
1136
+ return survivors;
1137
+ }
927
1138
  /**
928
- * The host options a run is bound to: the sink, the mode, the timing and the
929
- * log descriptor chosen by the mode machine mode keeps stdout exclusively
930
- * for the NDJSON stream, so the logging backend is pointed at stderr; human
931
- * mode keeps the stdout sink. The fix is the descriptor, never the log level.
1139
+ * The survivor spans as `file:startLine:startCol-endLine:endCol` mutate
1140
+ * ranges: the report's 1-based lines with the internal 0-based columns,
1141
+ * relative file names, deduplicated in first-seen order.
932
1142
  */
933
- function hostOptionsOf(mode, stream) {
934
- return {
935
- loggerConsoleOut: mode.mode === "machine" ? process.stderr : process.stdout,
936
- showColors: isColorEnabled(mode, process.env["NO_COLOR"]),
937
- runEventSink: stream.sink,
938
- runId: stream.runId,
939
- resolvedMode: mode,
940
- progressEnabled: isProgressEnabled(mode),
941
- clearTextEnabled: mode.mode === "human",
942
- runStartedAt: stream.startedAt,
943
- reporterPluginModules: [import.meta.resolve("@systemfsoftware/stryker-js-mutation-report/stryker-plugins")]
944
- };
1143
+ function survivorMutateSpans(survivors, basePath) {
1144
+ const spans = [];
1145
+ const seen = /* @__PURE__ */ new Set();
1146
+ for (const survivor of survivors) {
1147
+ const file = toRelativeNormalizedFileName(survivor.fileName, basePath);
1148
+ const { start, end } = survivor.location;
1149
+ const span = `${file}:${start.line + 1}:${start.column}-${end.line + 1}:${end.column}`;
1150
+ if (!seen.has(span)) {
1151
+ seen.add(span);
1152
+ spans.push(span);
1153
+ }
1154
+ }
1155
+ return spans;
945
1156
  }
1157
+ const hashContent = (content) => bytesToHex(sha256(utf8ToBytes(content)));
1158
+ const resolveAbsolutePath = (file) => resolve(file);
946
1159
  /**
947
1160
  * The survivors admission, as a description whose phases chain by type and
948
1161
  * read in the order they run. The read gathers the admission's whole input
@@ -954,68 +1167,56 @@ function hostOptionsOf(mode, stream) {
954
1167
  * stashed context back and dispatches the decision to the verdict/run,
955
1168
  * failing the run with a rejection.
956
1169
  */
957
- const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext) => pipe(
958
- Cell.read((cliOptions) => Effect.promise(() => resolveSurvivorsRunOptions(cliOptions)).pipe(Effect.flatMap((resolvedOptions) => {
959
- const priorReportPath = priorReportPathOf(resolvedOptions);
960
- const read = readPriorReport(priorReportPath);
961
- return Ref.set(runContext, {
962
- resolvedOptions,
963
- priorReportPath
964
- }).pipe(Effect.as({
965
- resolvedOptions,
966
- priorReportRaw: read.raw,
967
- priorReportFound: read.found,
968
- priorReportPath,
969
- sourceContentHashes: currentSourceHashesFor(priorReportFileKeys(read.raw))
970
- }));
971
- }))),
972
- /**
973
- * The one place the prior report is decoded, and the one place the two capabilities
974
- * are applied. A report that was never there yields a command with no facts, which
975
- * the decider rejects as `no-report`; a report that was there and does not decode
976
- * yields a `Left`, which stops the run before the decider sees it.
977
- */
978
- Cell.decode(({ resolvedOptions, priorReportRaw, priorReportFound, sourceContentHashes }) => {
979
- if (!priorReportFound) return Result.succeed(AdmitSurvivorsRunCommand.make({
980
- priorReport: void 0,
981
- currentConfig: resolvedOptions,
982
- frameworkVersion: strykerVersion,
983
- sourceContentHashes,
984
- priorSourceHashes: {},
985
- priorSurvivors: []
986
- }));
987
- return Result.map(decodePriorReport(priorReportRaw), (document) => AdmitSurvivorsRunCommand.make({
988
- priorReport: PriorReportFacts.make({
989
- config: document.config ?? {},
990
- frameworkVersion: document.framework?.version
991
- }),
992
- currentConfig: resolvedOptions,
993
- frameworkVersion: strykerVersion,
994
- sourceContentHashes,
995
- priorSourceHashes: priorSourceHashes(document, hashContent),
996
- priorSurvivors: extractSurvivors(document, resolveAbsolutePath)
997
- }));
998
- }),
999
- Cell.decide(admitSurvivorsRun),
1000
- Cell.encode((outcome) => outcome),
1001
- Cell.write((outcome) => Effect.flatMap(Ref.get(runContext), (context) => {
1002
- if (context === void 0) return Effect.die("the survivors admission read must run before its write");
1003
- const { resolvedOptions, priorReportPath } = context;
1004
- return Result.match(outcome, {
1005
- onSuccess: (decision) => Match.value(decision).pipe(Match.tag("NoSurvivors", () => Effect.sync(() => emitEmptySurvivorsVerdict(stream, mode, resolvedOptions))), Match.tag("Admitted", (admitted) => {
1006
- const restricted = {
1007
- ...resolvedOptions,
1008
- survivors: admitted.survivors,
1009
- mutate: survivorMutateSpans(admitted.survivors),
1010
- survivorsPriorReport: priorReportPath,
1011
- incremental: false
1012
- };
1013
- return Effect.promise(() => runMutationTest(restricted));
1014
- }), Match.orElse(() => Effect.die("unreachable admission decision variant"))),
1015
- onFailure: (rejection) => Effect.fail(rejection)
1016
- });
1017
- }))
1018
- );
1170
+ const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext, basePath, services) => pipe(Cell.read((cliOptions) => Effect.provideContext(Effect.flatMap(Path.Path, (pathService) => resolveSurvivorsRunOptions(cliOptions, basePath).pipe(Effect.flatMap((resolvedOptions) => {
1171
+ const priorReportPath = priorReportPathOf(resolvedOptions);
1172
+ return Effect.flatMap(readPriorReport(priorReportPath), (read) => Effect.flatMap(currentSourceHashesFor(priorReportFileKeys(read.raw)), (sourceContentHashes) => Ref.set(runContext, {
1173
+ resolvedOptions,
1174
+ priorReportPath,
1175
+ pathService
1176
+ }).pipe(Effect.as({
1177
+ resolvedOptions,
1178
+ priorReportRaw: read.raw,
1179
+ priorReportFound: read.found,
1180
+ priorReportPath,
1181
+ sourceContentHashes
1182
+ }))));
1183
+ }))), services)), Cell.decode(({ resolvedOptions, priorReportRaw, priorReportFound, sourceContentHashes }) => {
1184
+ if (!priorReportFound) return Result.succeed(AdmitSurvivorsRunCommand.make({
1185
+ priorReport: void 0,
1186
+ currentConfig: resolvedOptions,
1187
+ frameworkVersion: strykerVersion,
1188
+ sourceContentHashes,
1189
+ priorSourceHashes: {},
1190
+ priorSurvivors: []
1191
+ }));
1192
+ return Result.map(decodePriorReport(priorReportRaw), (document) => AdmitSurvivorsRunCommand.make({
1193
+ priorReport: PriorReportFacts.make({
1194
+ config: document.config ?? {},
1195
+ frameworkVersion: document.framework?.version
1196
+ }),
1197
+ currentConfig: resolvedOptions,
1198
+ frameworkVersion: strykerVersion,
1199
+ sourceContentHashes,
1200
+ priorSourceHashes: priorSourceHashes(document, hashContent),
1201
+ priorSurvivors: extractSurvivors(document, resolveAbsolutePath)
1202
+ }));
1203
+ }), Cell.decide(admitSurvivorsRun), Cell.encode((outcome) => outcome), Cell.write((outcome) => Effect.flatMap(Ref.get(runContext), (context) => {
1204
+ if (context === void 0) return Effect.die("the survivors admission read must run before its write");
1205
+ const { resolvedOptions, priorReportPath, pathService } = context;
1206
+ return Result.match(outcome, {
1207
+ onSuccess: (decision) => Match.value(decision).pipe(Match.tag("NoSurvivors", () => emitNullScoreVerdict(stream, mode, resolvedOptions.thresholds, resolvedOptions, basePath, pathService)), Match.tag("Admitted", (admitted) => {
1208
+ const admittedMutants = admitted.survivors.map((s) => Mutant.make(s));
1209
+ return runMutationTest({
1210
+ ...resolvedOptions,
1211
+ survivors: admittedMutants,
1212
+ mutate: survivorMutateSpans(admittedMutants, basePath),
1213
+ survivorsPriorReport: priorReportPath,
1214
+ incremental: false
1215
+ }).pipe(Effect.orDie);
1216
+ }), Match.orElse(() => Effect.die("unreachable admission decision variant"))),
1217
+ onFailure: (rejection) => Effect.fail(rejection)
1218
+ });
1219
+ })));
1019
1220
  /**
1020
1221
  * The `--survivors` request: re-test exactly the prior report's survivor set.
1021
1222
  * The survivors flag was parsed as a boolean; the admission decides between
@@ -1029,65 +1230,38 @@ const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext
1029
1230
  * is made; it is in this signature because the phase types put it there, not because the
1030
1231
  * admission chose it.
1031
1232
  */
1032
- function runSurvivorsAdmission(runMutationTest, stream, mode, cliOptions) {
1233
+ function runSurvivorsAdmission(runMutationTest, stream, mode, cliOptions, basePath) {
1033
1234
  return Effect.gen(function* () {
1235
+ const services = yield* Effect.context();
1034
1236
  const admissionContext = yield* Ref.make(void 0);
1035
- return yield* Cell.apply(survivorsAdmissionDescription(runMutationTest, stream, mode, admissionContext), cliOptions);
1237
+ return yield* Cell.apply(survivorsAdmissionDescription(runMutationTest, stream, mode, admissionContext, basePath, services), cliOptions);
1036
1238
  });
1037
1239
  }
1038
- /**
1039
- * Resolves the current options the same way the pipeline does — defaults +
1040
- * config file + CLI, validated against the fork schema (which carries the
1041
- * survivors-run properties). The admission hash compares these resolved
1042
- * options against the prior report's embedded config.
1043
- */
1044
- function resolveSurvivorsRunOptions(cliOptions) {
1045
- return new ConfigReader(noopLogger, new OptionsValidator(forkCoreSchema, noopLogger)).readConfig(cliOptions);
1240
+ function resolveSurvivorsRunOptions(cliOptions, basePath) {
1241
+ return readConfig(cliOptions, basePath);
1046
1242
  }
1047
- /**
1048
- * The prior report a `--survivors` run reads: the `survivorsPriorReport`
1049
- * config option when set, else the default path. The report path is run
1050
- * bookkeeping, never a CLI flag.
1051
- */
1052
1243
  function priorReportPathOf(resolved) {
1053
1244
  const configured = resolved["survivorsPriorReport"];
1054
- return typeof configured === "string" ? configured : DEFAULT_SURVIVORS_PRIOR_REPORT;
1245
+ if (typeof configured === "string") return configured;
1246
+ return DEFAULT_SURVIVORS_PRIOR_REPORT;
1055
1247
  }
1056
- /**
1057
- * Reads the prior report without validating it. Absence and malformation are different
1058
- * outcomes and the caller must be able to tell them apart: an absent report is the
1059
- * `no-report` rejection the decider states, while a present-but-malformed one is a decode
1060
- * failure that stops the run. Text that is not JSON is reported as found, carrying the
1061
- * text itself, so the codec refuses it and names what it got.
1062
- */
1063
1248
  function readPriorReport(priorReportPath) {
1064
- let text;
1065
- try {
1066
- text = readFileSync(priorReportPath, "utf-8");
1067
- } catch {
1068
- return {
1249
+ return Effect.gen(function* () {
1250
+ return yield* (yield* FileSystem.FileSystem).readFileString(priorReportPath).pipe(Effect.map((text) => ({
1251
+ found: true,
1252
+ raw: Result.match(S.decodeResult(S.fromJsonString(S.Unknown))(text), {
1253
+ onFailure: () => text,
1254
+ onSuccess: (value) => value
1255
+ })
1256
+ })), Effect.catchTag("PlatformError", (cause) => Match.value(cause.reason).pipe(Match.tag("NotFound", () => Effect.succeed({
1069
1257
  found: false,
1070
1258
  raw: void 0
1071
- };
1072
- }
1073
- try {
1074
- return {
1075
- found: true,
1076
- raw: JSON.parse(text)
1077
- };
1078
- } catch {
1079
- return {
1080
- found: true,
1081
- raw: text
1082
- };
1083
- }
1259
+ })), Match.orElse(() => Effect.fail(ConfigFileUnreadableError.make({
1260
+ file: priorReportPath,
1261
+ cause
1262
+ }))))));
1263
+ });
1084
1264
  }
1085
- /**
1086
- * The relative file names a report claims, read structurally rather than through the
1087
- * codec because the current sources must be hashed before the report is decoded — the
1088
- * read phase does the disk I/O, and the keys are what tell it which files to read. An
1089
- * unrecognisable report yields no keys and is refused a phase later by the codec.
1090
- */
1091
1265
  function priorReportFileKeys(raw) {
1092
1266
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return [];
1093
1267
  if (!("files" in raw)) return [];
@@ -1096,288 +1270,252 @@ function priorReportFileKeys(raw) {
1096
1270
  return Object.keys(files);
1097
1271
  }
1098
1272
  function readSourceFile(file) {
1099
- try {
1100
- return readFileSync(file, "utf-8");
1101
- } catch {
1102
- return "";
1103
- }
1273
+ return Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString(file).pipe(Effect.mapError((cause) => ConfigFileUnreadableError.make({
1274
+ file,
1275
+ cause
1276
+ }))));
1104
1277
  }
1105
- /**
1106
- * The per-file content hashes of the current sources, keyed by the relative file names
1107
- * the prior report uses — the current side of the admission comparison. The prior side is
1108
- * hashed from the sources the report embeds, in the decode phase.
1109
- */
1110
1278
  function currentSourceHashesFor(files) {
1111
- const hashes = {};
1112
- for (const file of files) hashes[file] = sourceContentHash(readSourceFile(file), hashContent);
1113
- return hashes;
1279
+ return Effect.map(Effect.forEach(files, (file) => Effect.map(readSourceFile(file), (content) => [file, sourceContentHash(content, hashContent)]), { concurrency: 24 }), (pairs) => Object.fromEntries(pairs));
1114
1280
  }
1281
+ //#endregion
1282
+ //#region src/Cli.ts
1283
+ const SIGNAL_NUMBERS = Object.freeze({
1284
+ SIGINT: 2,
1285
+ SIGTERM: 15
1286
+ });
1115
1287
  /**
1116
- * Machine mode emits the U4 verdict envelope for a run that produced no
1117
- * mutants and no report file: a `--survivors` run with zero survivors (AE3)
1118
- * or a successful `--dryRunOnly` run that ended before the mutation
1119
- * pipeline. The envelope carries a null score and an empty mutant list and is
1120
- * written as the terminal `verdict` line of the stdout stream (U6), carrying
1121
- * the run id the stream header already opened with (KTD11 — never a fresh
1122
- * id). Human mode prints nothing (the sink drops in human mode).
1288
+ * Installs the listeners and returns the reader.
1289
+ *
1290
+ * The listener records and returns: interrupting the run is the runtime's job,
1291
+ * and doing it from here would race the run's own finalizer for the stream.
1292
+ * `once` per signal, because a second delivery of the same signal cannot
1293
+ * change the answer.
1123
1294
  */
1124
- function emitNullScoreVerdict(stream, mode, thresholds, config) {
1125
- const report = {
1126
- schemaVersion: "1.0",
1127
- files: {},
1128
- thresholds,
1129
- projectRoot: process.cwd(),
1130
- config,
1131
- framework: {
1132
- name: "StrykerJS",
1133
- version: strykerVersion
1134
- }
1135
- };
1136
- const envelope = buildVerdictEnvelope(report, mode.mode, mode.signal, stream.runId);
1137
- stream.sink({
1138
- kind: "verdict",
1139
- ...envelope
1295
+ function observeTerminatingSignal() {
1296
+ let observed = null;
1297
+ for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {
1298
+ observed = SIGNAL_NUMBERS[signal] ?? null;
1140
1299
  });
1300
+ return () => observed;
1141
1301
  }
1142
- /**
1143
- * The `--survivors` zero-survivor path: the prior report held no survivors,
1144
- * so the run emits the null-score verdict without starting the pipeline. The
1145
- * full resolved options ride along as the report's embedded config (KTD7).
1146
- */
1147
- function emitEmptySurvivorsVerdict(stream, mode, resolved) {
1148
- emitNullScoreVerdict(stream, mode, resolved.thresholds, resolved);
1302
+ function isObject(value) {
1303
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1149
1304
  }
1150
- /**
1151
- * The contextual remediation for a failure, picked from the cause's shape:
1152
- * signal terminations (POSIX `128 + n`) are called out as interruptions,
1153
- * usage/parse errors point at `--help`, config errors name the offending file
1154
- * (ConfigError messages carry it), and rejected survivors runs name the full
1155
- * run to do first. Everything else points at the report file and the verdict
1156
- * envelope, which is where a runtime failure's detail already is.
1157
- */
1158
- function remediationFor(exit, code) {
1159
- if (code > 128) return "the run was interrupted by a signal; re-run it to continue";
1160
- const value = failureValue(exit);
1161
- if (value !== void 0) {
1162
- if (CliError.isCliError(value)) return "re-run with --help to see the full usage";
1163
- if (value instanceof ConfigError) return `check the config file: ${value.message}`;
1164
- if (S.is(SurvivorsRejection)(value)) return value.remediation;
1305
+ function stringField(node, key) {
1306
+ const value = node[key];
1307
+ return (() => {
1308
+ if (typeof value === "string") return value;
1309
+ })();
1310
+ }
1311
+ function stringArrayField(node, key) {
1312
+ const value = node[key];
1313
+ if (!Array.isArray(value)) return [];
1314
+ const strings = [];
1315
+ for (const item of value) if (typeof item === "string") strings.push(item);
1316
+ return strings;
1317
+ }
1318
+ /** The compiled shapes discriminate on `_tag`; read it once, off the record. */
1319
+ function tagOf(node) {
1320
+ return node["_tag"];
1321
+ }
1322
+ function walkParam(param, isOptional, out) {
1323
+ if (!isObject(param)) return;
1324
+ switch (tagOf(param)) {
1325
+ case "Single":
1326
+ describeSingle(param, isOptional, out);
1327
+ return;
1328
+ case "Map":
1329
+ case "Transform":
1330
+ walkParam(param["param"], isOptional, out);
1331
+ return;
1332
+ case "Optional":
1333
+ walkParam(param["param"], true, out);
1334
+ return;
1335
+ case "Variadic":
1336
+ walkParam(param["param"], isOptional, out);
1337
+ return;
1338
+ default: return;
1165
1339
  }
1166
- return "see --reportFile or the verdict envelope on stdout";
1167
1340
  }
1168
- /**
1169
- * The failure's own text, used when the capture buffer is empty — a failure
1170
- * stryker reported through its own logger rather than the framework's
1171
- * `Console`. Falls back to a rendered cause.
1172
- */
1173
- function describeFailure(exit) {
1174
- if (Exit.isFailure(exit)) {
1175
- const value = failureValue(exit);
1176
- if (value !== void 0) {
1177
- if (S.is(SurvivorsRejection)(value)) return value.remediation;
1178
- if (value instanceof Error) return value.message;
1179
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") return String(value);
1180
- return Object.prototype.toString.call(value);
1181
- }
1182
- return Cause.pretty(exit.cause);
1183
- }
1184
- return "";
1341
+ const PRIMITIVE_KIND = {
1342
+ Boolean: "boolean",
1343
+ Choice: "choice",
1344
+ Date: "date",
1345
+ FileParse: "file",
1346
+ FileSchema: "file",
1347
+ FileText: "file",
1348
+ Float: "float",
1349
+ Integer: "integer",
1350
+ KeyValuePair: "key=value",
1351
+ None: "none",
1352
+ Path: "path",
1353
+ Redacted: "redacted",
1354
+ String: "text"
1355
+ };
1356
+ function kindOf(primitive) {
1357
+ const tag = stringField(primitive, "_tag");
1358
+ return (() => {
1359
+ if (tag === void 0) return "unknown";
1360
+ return PRIMITIVE_KIND[tag] ?? tag;
1361
+ })();
1362
+ }
1363
+ function choiceValues(primitive) {
1364
+ const keys = primitive["choiceKeys"];
1365
+ if (!Array.isArray(keys)) return;
1366
+ const values = [];
1367
+ for (const key of keys) if (typeof key === "string") values.push(key);
1368
+ return values;
1185
1369
  }
1370
+ const REPORTER_NAMES = [
1371
+ "clear-text",
1372
+ "progress",
1373
+ "html",
1374
+ "json",
1375
+ "progress-stream"
1376
+ ];
1186
1377
  /**
1187
- * The argument the framework reports it does not know, named the way the wire
1188
- * contract spells it. The v4 parser fails wrapped in a ShowHelp whose errors
1189
- * carry the offending flag or operand; when the unrecognized flag was given a
1190
- * separate value (`--format text`), the value is the token the old parser
1191
- * reported, so the token after the flag is named when one was given.
1378
+ * v4 option descriptions are stored as `Option.some(string)` on the compiled
1379
+ * `Single`; the walker unwraps the option.
1192
1380
  */
1193
- function unrecognizedArgumentOf(exit, argv) {
1194
- if (!Exit.isFailure(exit)) return;
1195
- const value = failureValue(exit);
1196
- if (value === void 0 || !CliError.isCliError(value)) return;
1197
- const errors = S.is(CliError.ShowHelp)(value) ? value.errors : [value];
1198
- for (const error of errors) {
1199
- if (S.is(CliError.UnrecognizedOption)(error)) {
1200
- const at = argv.indexOf(error.option);
1201
- const next = at >= 0 ? argv[at + 1] : void 0;
1202
- return next !== void 0 && !next.startsWith("-") ? next : error.option;
1381
+ function descriptionOf(single) {
1382
+ const description = single["description"];
1383
+ if (!isObject(description)) return "";
1384
+ switch (tagOf(description)) {
1385
+ case "Some": {
1386
+ const value = description["value"];
1387
+ return (() => {
1388
+ if (typeof value === "string") return value;
1389
+ return "";
1390
+ })();
1203
1391
  }
1204
- if (S.is(CliError.UnexpectedArgument)(error)) return error.arguments[0];
1205
- if (S.is(CliError.UnknownSubcommand)(error)) return error.subcommand;
1392
+ default: return "";
1206
1393
  }
1207
1394
  }
1208
- /**
1209
- * The first typed error in the exit's cause. The framework fails with
1210
- * `Cause.fail` (usage errors); the run handler is `Effect.promise`, whose
1211
- * rejected promises surface as *defects* (`Die` reasons) rather than
1212
- * failures — so stryker's own ConfigError/StrykerError values arrive there
1213
- * and must be read from the cause's `Die` reasons.
1214
- */
1215
- function failureValue(exit) {
1216
- if (!Exit.isFailure(exit)) return;
1217
- const failure = Cause.findErrorOption(exit.cause);
1218
- if (Option.isSome(failure)) return failure.value;
1219
- const dieReason = exit.cause.reasons.find(Cause.isDieReason);
1220
- return dieReason === void 0 ? void 0 : dieReason.defect;
1221
- }
1222
- function buildErrorEnvelope(exit, code, captured, argv) {
1223
- const unrecognized = unrecognizedArgumentOf(exit, argv);
1224
- return {
1225
- schemaVersion: "1.0",
1226
- code,
1227
- error: unrecognized !== void 0 ? `Received unknown argument: '${unrecognized}'` : captured.length > 0 ? captured : describeFailure(exit),
1228
- remediation: remediationFor(exit, code)
1395
+ function describeSingle(single, isOptional, out) {
1396
+ const name = stringField(single, "name") ?? "";
1397
+ const primitive = (() => {
1398
+ if (isObject(single["primitiveType"])) return single["primitiveType"];
1399
+ return {};
1400
+ })();
1401
+ const kind = kindOf(primitive);
1402
+ const choices = (() => {
1403
+ if (name === "reporters") return REPORTER_NAMES;
1404
+ if (kind === "choice") return choiceValues(primitive);
1405
+ })();
1406
+ const description = descriptionOf(single);
1407
+ const required = kind !== "boolean" && !isOptional;
1408
+ const described = {
1409
+ name,
1410
+ aliases: stringArrayField(single, "aliases"),
1411
+ kind,
1412
+ required,
1413
+ ...(() => {
1414
+ if (choices !== void 0) return { choices };
1415
+ return {};
1416
+ })(),
1417
+ description
1229
1418
  };
1230
- }
1231
- /**
1232
- * Emits the machine-mode output from the run's finalizer — it runs on
1233
- * success, failure and interruption alike (R30): a failed run writes the
1234
- * `error` terminal event as the last line of the stdout stream; a successful
1235
- * run whose only console output was the framework's help/version rendering
1236
- * emits that captured document as the `help` terminal event, so `--help` in
1237
- * machine mode never leaks an ANSI document. A successful run with an empty
1238
- * buffer (the normal verdict path) emits nothing extra — the run already
1239
- * wrote its terminal `verdict` line through the same module — unless the
1240
- * stream is still open, which means the run never reached a verdict (the
1241
- * `--dryRunOnly` early return): then a null-score `verdict` closes the
1242
- * stream so the last stdout line is always a terminal event (R5).
1243
- */
1244
- function emitMachineModeOutput(stream, mode, exit, code, argv) {
1245
- const captured = readCapturedConsole();
1246
- const value = failureValue(exit);
1247
- if (Exit.isFailure(exit) && S.is(CliError.ShowHelp)(value) && value.errors.length === 0) {
1248
- const document = {
1249
- kind: "help",
1250
- schemaVersion: "1.0",
1251
- code: 0,
1252
- help: captured
1253
- };
1254
- stream.sink(document);
1255
- return;
1256
- }
1257
- if (Exit.isFailure(exit)) {
1258
- stream.sink({
1259
- kind: "error",
1260
- ...buildErrorEnvelope(exit, code, captured, argv)
1419
+ if (single["kind"] === "argument") {
1420
+ out.args.push({
1421
+ name,
1422
+ kind,
1423
+ required,
1424
+ description
1261
1425
  });
1262
1426
  return;
1263
1427
  }
1264
- if (captured.length > 0) {
1265
- const document = {
1266
- kind: "help",
1267
- schemaVersion: "1.0",
1268
- code: 0,
1269
- help: captured
1270
- };
1271
- stream.sink(document);
1272
- return;
1428
+ out.flags.push(described);
1429
+ }
1430
+ function walkConfigNode(node, orderedParams, out) {
1431
+ if (!isObject(node)) return;
1432
+ switch (tagOf(node)) {
1433
+ case "Param": {
1434
+ const index = node["index"];
1435
+ const param = (() => {
1436
+ if (typeof index === "number") return orderedParams[index];
1437
+ })();
1438
+ if (param !== void 0) walkParam(param, false, out);
1439
+ return;
1440
+ }
1441
+ case "Array":
1442
+ if (Array.isArray(node["children"])) for (const child of node["children"]) walkConfigNode(child, orderedParams, out);
1443
+ return;
1444
+ case "Nested":
1445
+ if (isObject(node["tree"])) walkConfigTree(node["tree"], orderedParams, out);
1446
+ return;
1447
+ default: return;
1273
1448
  }
1274
- if (stream.isOpen()) emitNullScoreVerdict(stream, mode, defaultOptions.thresholds, {});
1275
1449
  }
1276
- /**
1277
- * A rejected config reaches the finalizer as a typed failure or as a defect
1278
- * depending on where the validator threw, and typed-inject may have wrapped
1279
- * it, so both channels are searched and each candidate is unwrapped.
1280
- */
1281
- function carriesConfigError(cause) {
1282
- for (const reason of cause.reasons) {
1283
- const candidate = Cause.isFailReason(reason) ? reason.error : Cause.isDieReason(reason) ? reason.defect : void 0;
1284
- if (candidate !== void 0 && (candidate instanceof ConfigError || retrieveCause(candidate) instanceof ConfigError)) return true;
1450
+ function walkConfigTree(tree, orderedParams, out) {
1451
+ for (const key of Object.keys(tree)) walkConfigNode(tree[key], orderedParams, out);
1452
+ }
1453
+ function describeCommandNode(node) {
1454
+ if (!isObject(node)) return;
1455
+ const out = {
1456
+ flags: [],
1457
+ args: []
1458
+ };
1459
+ const config = node["config"];
1460
+ if (isObject(config) && isObject(config["tree"])) {
1461
+ let orderedParams = [];
1462
+ const maybeOrdered = config["orderedParams"];
1463
+ if (Array.isArray(maybeOrdered)) orderedParams = maybeOrdered;
1464
+ walkConfigTree(config["tree"], orderedParams, out);
1285
1465
  }
1286
- return false;
1466
+ const subcommands = [];
1467
+ const grouped = node["subcommands"];
1468
+ if (Array.isArray(grouped)) for (const group of grouped) {
1469
+ if (!isObject(group) || !Array.isArray(group["commands"])) continue;
1470
+ for (const child of group["commands"]) {
1471
+ const described = describeCommandNode(child);
1472
+ if (described !== void 0) subcommands.push(described);
1473
+ }
1474
+ }
1475
+ return {
1476
+ name: stringField(node, "name") ?? "",
1477
+ description: (() => {
1478
+ if (typeof node["description"] === "string") return node["description"];
1479
+ return "";
1480
+ })(),
1481
+ options: out.flags,
1482
+ args: out.args,
1483
+ subcommands
1484
+ };
1287
1485
  }
1288
- /**
1289
- * Classifies a failed run for the finalizer: usage/parse failures
1290
- * (`CliError` except a bare help request, which exits 0), rejected
1291
- * survivors runs (`SurvivorsRejection`), an unreadable prior report
1292
- * (`S.SchemaError`) and a rejected config (`ConfigError`) all exit 2, all
1293
- * other failures exit 1 (the framework's default). A successful run exits 0;
1294
- * the verdict gates (U5) then resolve the final classed code.
1295
- *
1296
- * The report parse failure shares the survivors class deliberately. It is not a
1297
- * verdict — the decider never sees the report — but the operator's answer is the
1298
- * same class of answer as a rejection: the input you named cannot be used. Letting
1299
- * it fall through to 1 would make an unusable `--survivors` input indistinguishable
1300
- * from a crash.
1301
- */
1302
- function resolveCliExitCode(exit) {
1303
- if (Exit.isSuccess(exit)) return 0;
1304
- if (Cause.hasInterruptsOnly(exit.cause)) return 1;
1305
- const failure = Cause.findErrorOption(exit.cause);
1306
- if (Option.isSome(failure)) {
1307
- const value = failure.value;
1308
- if (S.is(CliError.ShowHelp)(value)) return value.errors.length > 0 ? 2 : 0;
1309
- if (CliError.isCliError(value)) return 2;
1310
- if (S.is(SurvivorsRejection)(value)) return SURVIVORS_REJECT_EXIT_CLASS;
1311
- if (value instanceof S.SchemaError) return SURVIVORS_REJECT_EXIT_CLASS;
1486
+ function readCoreEntries() {
1487
+ try {
1488
+ const manifestPath = createRequire(import.meta.url).resolve("@systemfsoftware/stryker-js-platform-node/package.json");
1489
+ const raw = readFileSync(manifestPath, "utf-8");
1490
+ const parsed = JSON.parse(raw);
1491
+ const ExportsSchema = S.Struct({ exports: S.Record(S.String, S.Unknown) });
1492
+ const decoded = S.decodeUnknownResult(ExportsSchema)(parsed);
1493
+ if (Result.isSuccess(decoded)) return Object.keys(decoded.success.exports).filter((key) => key !== "./package.json");
1494
+ return [];
1495
+ } catch {
1496
+ return [];
1312
1497
  }
1313
- if (carriesConfigError(exit.cause)) return ExitClass.ConfigError;
1314
- return 1;
1315
1498
  }
1316
- /**
1317
- * The single operation of the CLI's executor cell: the impure shell that
1318
- * wraps the transport's command effect with the run bootstrap. It creates the
1319
- * run's stream from the resolved mode, binds the host options a run is
1320
- * executed with, opens the stream, runs the command effect, dispatches the
1321
- * request the handlers left, and on every outcome — success, failure and
1322
- * interruption alike — emits the machine-mode terminal event (error/help/
1323
- * null verdict) and drains the stream, returning the classed exit code as its
1324
- * value. SIGINT/SIGTERM interrupt the current fiber so the finalizer runs
1325
- * before the process exits; the code is resolved exactly once (R6), in the
1326
- * finalizer, where the terminal event's `code` is chosen from the same inputs
1327
- * the teardown used before.
1328
- */
1329
- const runStrykerCli = (input, createRunEventStream) => Effect.gen(function* () {
1330
- const stream = yield* createRunEventStream(input.mode);
1331
- const runMutationTest = input.runMutationTest ?? defaultRunMutationTest(hostOptionsOf(input.mode, stream));
1332
- let currentFiber = null;
1333
- let lastSignal = null;
1334
- const resolveClassedExitCode = (exit) => {
1335
- const signal = lastSignal;
1336
- if (signal !== null) return 128 + signal;
1337
- if (Exit.isFailure(exit)) return resolveCliExitCode(exit);
1338
- return resolveExitCode(getPendingExitClasses(), null);
1499
+ function buildLLMSManifest(command, version) {
1500
+ const root = describeCommandNode(command) ?? {
1501
+ name: "",
1502
+ description: "",
1503
+ options: [],
1504
+ args: [],
1505
+ subcommands: []
1339
1506
  };
1340
- const onSignal = (signal) => {
1341
- lastSignal = SIGNAL_NUMBERS[signal] ?? null;
1342
- process.removeListener("SIGINT", onSignal);
1343
- process.removeListener("SIGTERM", onSignal);
1344
- if (currentFiber !== null) currentFiber.interruptUnsafe(currentFiber.id);
1507
+ return {
1508
+ schemaVersion: "1.0",
1509
+ tool: root.name,
1510
+ version,
1511
+ commands: [root],
1512
+ entries: readCoreEntries()
1345
1513
  };
1346
- 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(() => {
1347
- stream.ensureOpen({
1348
- mode: "machine",
1349
- signal: "flag",
1350
- stdoutIsTTY: process.stdout.isTTY === true
1351
- });
1352
- stream.sink(llmsRequest.document);
1353
- })), Match.orElse(() => Effect.die("unreachable cli request variant")));
1354
- const program = Effect.acquireUseRelease(Effect.sync(() => {
1355
- currentFiber = Fiber.getCurrent() ?? null;
1356
- process.on("SIGINT", onSignal);
1357
- process.on("SIGTERM", onSignal);
1358
- }), () => Effect.gen(function* () {
1359
- yield* stream.open;
1360
- yield* input.program;
1361
- const request = yield* Ref.get(input.requestRef);
1362
- yield* Option.match(request, {
1363
- onNone: () => Effect.void,
1364
- onSome: (cliRequest) => dispatch(cliRequest)
1365
- });
1366
- }), () => Effect.sync(() => {
1367
- process.removeListener("SIGINT", onSignal);
1368
- process.removeListener("SIGTERM", onSignal);
1369
- }));
1370
- return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
1371
- const exit = yield* Effect.exit(restore(program));
1372
- const code = resolveClassedExitCode(exit);
1373
- input.recordExitCode(code);
1374
- if (input.mode.mode === "machine") emitMachineModeOutput(stream, input.mode, exit, code, input.argv);
1375
- yield* stream.closeAndDrain;
1376
- return code;
1377
- }));
1378
- });
1379
- //#endregion
1380
- //#region src/StrykerCliHandler.ts
1514
+ }
1515
+ /** The manifest as one JSON document, ready for stdout — the U4 convention. */
1516
+ function emitLLMSManifest(command, version) {
1517
+ return JSON.stringify(buildLLMSManifest(command, version));
1518
+ }
1381
1519
  function createSplitter(separator) {
1382
1520
  return (value) => value.split(separator).filter(Boolean);
1383
1521
  }
@@ -1390,7 +1528,10 @@ const splitOnSpace = createSplitter(" ");
1390
1528
  */
1391
1529
  function parseCleanDirOption(value) {
1392
1530
  const v = value.toLocaleLowerCase();
1393
- return v === "always" ? v : v !== "false" && v !== "0";
1531
+ return (() => {
1532
+ if (v === "always") return v;
1533
+ return v !== "false" && v !== "0";
1534
+ })();
1394
1535
  }
1395
1536
  /**
1396
1537
  * Commander characterization: a pure integer is parsed as a number, anything
@@ -1408,31 +1549,12 @@ const optional = (option) => Flag.optional(option);
1408
1549
  * `Option.some(false)` for an explicit `--no-x`, so both map back to
1409
1550
  * `undefined` and leave the config-file default in force (KTD4).
1410
1551
  */
1411
- const absentWhenFalse = (value) => Option.isSome(value) && value.value ? true : void 0;
1412
- const LOG_LEVELS = [
1413
- "fatal",
1414
- "error",
1415
- "warn",
1416
- "info",
1417
- "debug",
1418
- "trace",
1419
- "off"
1420
- ];
1421
- const LOG_LEVEL_LOOKUP = {
1422
- fatal: true,
1423
- error: true,
1424
- warn: true,
1425
- info: true,
1426
- debug: true,
1427
- trace: true,
1428
- off: true
1552
+ const absentWhenFalse = (value) => {
1553
+ if (Option.isSome(value) && value.value) return true;
1429
1554
  };
1430
- function isLogLevel(value) {
1431
- return LOG_LEVEL_LOOKUP[value] === true;
1432
- }
1433
1555
  function setLogLevel(target, key, value) {
1434
1556
  const unwrapped = unwrap(value);
1435
- if (unwrapped !== void 0 && isLogLevel(unwrapped)) target[key] = unwrapped;
1557
+ if (unwrapped !== void 0) target[key] = unwrapped;
1436
1558
  }
1437
1559
  const runOptions = {
1438
1560
  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),
@@ -1451,7 +1573,7 @@ const runOptions = {
1451
1573
  "perTest",
1452
1574
  "all",
1453
1575
  "off"
1454
- ]).pipe(Flag.withDescription(`The coverage analysis strategy you want to use. Default value: "${defaultOptions.coverageAnalysis}"`), optional),
1576
+ ]).pipe(Flag.withDescription(`The coverage analysis strategy you want to use. Default value: "${RENDERED_OPTION_DEFAULTS.coverageAnalysis}"`), optional),
1455
1577
  testRunner: Flag.string("testRunner").pipe(Flag.withDescription("The name of the test runner you want to use"), optional),
1456
1578
  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),
1457
1579
  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),
@@ -1464,11 +1586,27 @@ const runOptions = {
1464
1586
  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),
1465
1587
  disableBail: Flag.map(optional(Flag.boolean("disableBail")), absentWhenFalse).pipe(Flag.withDescription("Force the test runner to keep running tests, even when a mutant is already killed.")),
1466
1588
  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),
1467
- 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),
1468
- 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),
1589
+ logLevel: Flag.choice("logLevel", [
1590
+ "fatal",
1591
+ "error",
1592
+ "warn",
1593
+ "info",
1594
+ "debug",
1595
+ "trace",
1596
+ "off"
1597
+ ]).pipe(Flag.withDescription(`Set the log level for the console. Possible values: fatal, error, warn, info, debug, trace and off. Default is "${RENDERED_OPTION_DEFAULTS.logLevel}"`), optional),
1598
+ fileLogLevel: Flag.choice("fileLogLevel", [
1599
+ "fatal",
1600
+ "error",
1601
+ "warn",
1602
+ "info",
1603
+ "debug",
1604
+ "trace",
1605
+ "off"
1606
+ ]).pipe(Flag.withDescription(`Set the log level for the "stryker.log" file. Possible values: fatal, error, warn, info, debug, trace and off. Default is "${RENDERED_OPTION_DEFAULTS.fileLogLevel}"`), optional),
1469
1607
  inPlace: Flag.map(optional(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.")),
1470
1608
  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),
1471
- 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),
1609
+ cleanTempDir: Flag.string("cleanTempDir").pipe(Flag.withDescription(`Choose whether or not to clean the temp dir (which is "${RENDERED_OPTION_DEFAULTS.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),
1472
1610
  survivors: Flag.map(optional(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."))
1473
1611
  };
1474
1612
  const runArgs = { configFile: Argument.optional(Argument.string("configFile")) };
@@ -1550,7 +1688,7 @@ function makeStrykerCommand(requestRef) {
1550
1688
  const strykerCommand = Command.make("stryker", rootConfig, (config) => {
1551
1689
  if (config.llms === true) {
1552
1690
  const document = {
1553
- kind: "manifest",
1691
+ _tag: "manifest",
1554
1692
  schemaVersion: "1.0",
1555
1693
  code: 0,
1556
1694
  manifest: emitLLMSManifest(strykerCommand, strykerVersion)
@@ -1598,48 +1736,148 @@ const cliLayer = Layer.mergeAll(CliConfig.layer({ builtIns: [
1598
1736
  GlobalFlag.Wizard,
1599
1737
  GlobalFlag.Completions,
1600
1738
  GlobalFlag.LogLevel
1601
- ] }), Path.layer, FileSystem.layerNoop({}), terminalLayer, NodeStdio.layer, Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => Effect.die("no child processes"))));
1602
- /**
1603
- * The transport entry: builds the command tree, resolves the mode once at
1604
- * the edge (never a second probe), provides the CLI and Console layers the
1605
- * framework renders through, delegates the whole run to the executor cell and
1606
- * returns the classed exit code it computes. The executor is the I/O
1607
- * sandwich; this function only frames it.
1608
- */
1609
- function strykerCliEffect(argv, runMutationTest, recordExitCode, detectMode, createRunEventStream) {
1739
+ ] }), Path.layer, FileSystem.layerNoop({}), terminalLayer, NodeStdio.layer, NodeChildProcessSpawner.layer.pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))));
1740
+ function strykerCliEffect(argv, runMutationTest, detectMode, createRunEventStream, lastSignal) {
1610
1741
  return Effect.gen(function* () {
1611
- const mode = detectMode();
1742
+ const mode = yield* detectMode;
1612
1743
  const requestRef = yield* Ref.make(Option.none());
1613
1744
  const command = makeStrykerCommand(requestRef);
1614
- const cliEffect = Command.runWith(command, { version: strykerVersion })(argv).pipe(Effect.provide(Layer.mergeAll(mode.mode === "machine" ? strykerCliConsoleLayers.machine : Layer.empty, cliLayer)));
1615
- const outcome = yield* Effect.result(runStrykerCli({
1745
+ const consoleLayer = (() => {
1746
+ if (mode.mode === "machine") return machineConsoleLayer;
1747
+ return Layer.empty;
1748
+ })();
1749
+ const cliEffect = Command.runWith(command, { version: strykerVersion })(argv).pipe(Effect.provide(Layer.mergeAll(consoleLayer, cliLayer)));
1750
+ const result = yield* Effect.result(runStrykerCli({
1616
1751
  program: cliEffect,
1617
1752
  requestRef,
1618
1753
  mode,
1619
1754
  runMutationTest,
1620
- recordExitCode,
1621
- argv
1755
+ argv,
1756
+ lastSignal
1622
1757
  }, createRunEventStream));
1623
- return Result.isFailure(outcome) ? outcome.failure : outcome.success;
1624
- });
1758
+ if (Result.isFailure(result)) return result.failure;
1759
+ return result.success;
1760
+ }).pipe(Effect.orElseSucceed(() => 2));
1625
1761
  }
1762
+ const defaultRunMutationTest = (hostOptions, queue) => (options) => Effect.scoped(runMutationTest(options)).pipe(Effect.provideService(RunEvents, queue), Effect.provide(makeRunLayer(hostOptions)));
1763
+ function hostOptionsOf(mode, stream) {
1764
+ return {
1765
+ runId: stream.runId,
1766
+ resolvedMode: mode,
1767
+ runStartedAt: stream.startedAt,
1768
+ basePath: resolve(process.cwd()),
1769
+ reporterPluginModules: [import.meta.resolve("@systemfsoftware/stryker-js-html-reporter"), import.meta.resolve("@systemfsoftware/stryker-js-platform-node/builtin-reporters")],
1770
+ allowConsoleColors: isColorEnabled(mode, process.env["NO_COLOR"])
1771
+ };
1772
+ }
1773
+ const runStrykerCli = (input, createRunEventStream) => Effect.gen(function* () {
1774
+ const stream = yield* createRunEventStream(input.mode);
1775
+ const hostOptions = hostOptionsOf(input.mode, stream);
1776
+ const runMutationTestImpl = input.runMutationTest ?? defaultRunMutationTest(hostOptions, stream.queue);
1777
+ const basePath = hostOptions.basePath;
1778
+ const pathService = yield* Path.Path.pipe(Effect.provide(NodePath.layer));
1779
+ let currentFiber = null;
1780
+ const onSignal = () => {
1781
+ process.removeListener("SIGINT", onSignal);
1782
+ process.removeListener("SIGTERM", onSignal);
1783
+ if (currentFiber !== null) currentFiber.interruptUnsafe(currentFiber.id);
1784
+ };
1785
+ const dispatch = (request) => Match.value(request).pipe(Match.tag("run", (runRequest) => (() => {
1786
+ if (runRequest.survivors) return runSurvivorsAdmission(runMutationTestImpl, stream, input.mode, runRequest.options, basePath).pipe(Effect.provide(makeRunLayer(hostOptions)));
1787
+ return runMutationTestImpl(runRequest.options).pipe(Effect.orDie);
1788
+ })()), Match.tag("llms", (llmsRequest) => Effect.gen(function* () {
1789
+ stream.ensureOpen({
1790
+ mode: "machine",
1791
+ signal: "flag",
1792
+ stdoutIsTTY: process.stdout.isTTY === true
1793
+ });
1794
+ yield* Queue.offer(stream.queue, ManifestRendered.make({
1795
+ schemaVersion: "1.0",
1796
+ code: 0,
1797
+ manifest: llmsRequest.document.manifest
1798
+ }));
1799
+ })), Match.orElse(() => Effect.die("unreachable cli request variant")));
1800
+ const program = Effect.acquireUseRelease(Effect.sync(() => {
1801
+ currentFiber = Fiber.getCurrent() ?? null;
1802
+ process.on("SIGINT", onSignal);
1803
+ process.on("SIGTERM", onSignal);
1804
+ }), () => Effect.gen(function* () {
1805
+ yield* stream.open;
1806
+ yield* input.program;
1807
+ const request = yield* Ref.get(input.requestRef);
1808
+ return yield* Option.match(request, {
1809
+ onNone: () => Effect.void,
1810
+ onSome: (cliRequest) => dispatch(cliRequest)
1811
+ });
1812
+ }), () => Effect.sync(() => {
1813
+ process.removeListener("SIGINT", onSignal);
1814
+ process.removeListener("SIGTERM", onSignal);
1815
+ }));
1816
+ return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
1817
+ const outcome = classifyRunOutcome(yield* Effect.exit(restore(program)), input.lastSignal(), input.argv);
1818
+ const code = runOutcomeCode(outcome);
1819
+ if (input.mode.mode === "machine") yield* emitMachineModeOutput(stream, input.mode, outcome, basePath, pathService);
1820
+ yield* stream.closeAndDrain;
1821
+ return code;
1822
+ }));
1823
+ });
1824
+ //#endregion
1825
+ //#region src/StreamFile.ts
1826
+ const STREAM_FILE_DIR = "reports";
1827
+ const STREAM_FILE_NAME = "mutation-stream.jsonl";
1828
+ const encodeUtf8 = (line) => new TextEncoder().encode(line);
1829
+ const drainStreamFile = (framed) => Effect.gen(function* () {
1830
+ const fs = yield* FileSystem.FileSystem;
1831
+ const file = (yield* Path.Path).join(STREAM_FILE_DIR, STREAM_FILE_NAME);
1832
+ yield* fs.makeDirectory(STREAM_FILE_DIR, { recursive: true }).pipe(Effect.orDie);
1833
+ yield* Stream.run(framed.pipe(Stream.map(encodeUtf8)), fs.sink(file, { flag: "w" })).pipe(Effect.orDie);
1834
+ });
1835
+ const RunEventStreamFileLive = Layer.effect(RunEventStreamPort, Effect.gen(function* () {
1836
+ const stdio = yield* Stdio.Stdio;
1837
+ const fs = yield* FileSystem.FileSystem;
1838
+ const path = yield* Path.Path;
1839
+ const drainFramed = (framed) => drainStreamFile(framed).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path));
1840
+ return RunEventStreamPort.of({ createRunEventStream: (resolved) => makeRunEventStream(stdio, resolved, drainFramed) });
1841
+ }));
1626
1842
  //#endregion
1627
1843
  //#region src/main.ts
1628
1844
  const EXIT_CODE_RUN_NEVER_REACHED_ITS_FINALIZER = 1;
1629
1845
  process.title = "stryker";
1630
- 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`);
1631
- const resolvedExitCode = { current: EXIT_CODE_RUN_NEVER_REACHED_ITS_FINALIZER };
1846
+ const lastSignal = observeTerminatingSignal();
1847
+ function isSupportedNodeVersion(version) {
1848
+ let withoutV = version;
1849
+ if (version.startsWith("v")) withoutV = version.slice(1);
1850
+ const dashBaseRaw = withoutV.split("-")[0];
1851
+ let dashBase = withoutV;
1852
+ if (dashBaseRaw !== void 0) dashBase = dashBaseRaw;
1853
+ const baseRaw = dashBase.split("+")[0];
1854
+ let base = dashBase;
1855
+ if (baseRaw !== void 0) base = baseRaw;
1856
+ const parts = base.split(".").map((p) => Number.parseInt(p, 10));
1857
+ const major = parts[0] ?? 0;
1858
+ const minor = parts[1] ?? 0;
1859
+ const patch = parts[2] ?? 0;
1860
+ if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) return false;
1861
+ if (major !== 20) return major > 20;
1862
+ if (minor !== 0) return minor > 0;
1863
+ return patch >= 0;
1864
+ }
1865
+ if (!isSupportedNodeVersion(process.version)) 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`);
1632
1866
  const program = Effect.gen(function* () {
1633
1867
  const outputMode = yield* OutputModeProbe;
1634
1868
  const runEvents = yield* RunEventStreamPort;
1635
- return yield* strykerCliEffect(process.argv.slice(2), void 0, (code) => {
1636
- resolvedExitCode.current = code;
1637
- }, outputMode.detectMode, runEvents.createRunEventStream);
1638
- }).pipe(Effect.provide(Layer.merge(OutputModeProbeLive, RunEventStreamLive).pipe(Layer.provide(NodeStdio.layer))));
1869
+ return yield* strykerCliEffect(process.argv.slice(2), void 0, outputMode.detectMode, runEvents.createRunEventStream, lastSignal);
1870
+ }).pipe(Effect.provideService(Logger.LogToStderr, true), Effect.provide(Layer.merge(OutputModeProbeLive, RunEventStreamFileLive).pipe(Layer.provide(Layer.mergeAll(NodeStdio.layer, NodeFileSystem.layer, NodePath.layer)))));
1639
1871
  NodeRuntime.runMain(program, {
1640
1872
  disableErrorReporting: true,
1641
- teardown: (_exit, onExit) => {
1642
- onExit(resolvedExitCode.current);
1873
+ teardown: (exit, onExit) => {
1874
+ if (Exit.isSuccess(exit) && typeof exit.value === "number") {
1875
+ onExit(exit.value);
1876
+ return;
1877
+ }
1878
+ const signal = lastSignal();
1879
+ if (signal === null) onExit(EXIT_CODE_RUN_NEVER_REACHED_ITS_FINALIZER);
1880
+ else onExit(128 + signal);
1643
1881
  }
1644
1882
  });
1645
1883
  //#endregion