@systemfsoftware/stryker-js-cli 3.2.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.mjs CHANGED
@@ -1,303 +1,123 @@
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";
5
9
  import * as Exit from "effect/Exit";
6
10
  import * as Layer from "effect/Layer";
7
11
  import * as Logger from "effect/Logger";
8
- import { ExitClass, buildVerdictEnvelope, defaultOptions, defaultStages, forkCoreSchema, generateRunId, highestExitClass, makeRunLayer, readConfig, resolveExitCode, runMutationTest, strykerEngines, strykerVersion, toRelativeNormalizedFileName } from "@systemfsoftware/stryker-js-mutation-run";
9
- import * as Context from "effect/Context";
10
- import * as Result from "effect/Result";
11
- import * as CliError from "effect/unstable/cli/CliError";
12
- import * as Cause from "effect/Cause";
13
- import * as Clock from "effect/Clock";
14
- import * as Deferred from "effect/Deferred";
15
- import * as Fiber from "effect/Fiber";
16
- import * as Queue from "effect/Queue";
17
- import * as Stdio from "effect/Stdio";
18
- import * as Stream from "effect/Stream";
19
12
  import * as NodeChildProcessSpawner from "@effect/platform-node-shared/NodeChildProcessSpawner";
20
- import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
21
- import * as NodePath$1 from "@effect/platform-node-shared/NodePath";
22
- import { RENDERED_OPTION_DEFAULTS, causeText } from "@systemfsoftware/stryker-js-plugin-api/core";
13
+ import { Heartbeat, HelpRendered, ManifestRendered, RunEvents, RunFailed, RunStarted, VerdictReached } from "@systemfsoftware/stryker-js/Run";
14
+ import { RENDERED_OPTION_DEFAULTS } from "@systemfsoftware/stryker-js/Schema";
15
+ import * as Cause from "effect/Cause";
23
16
  import * as Console from "effect/Console";
17
+ import * as Fiber from "effect/Fiber";
24
18
  import * as FileSystem from "effect/FileSystem";
19
+ import * as Match from "effect/Match";
25
20
  import * as Option from "effect/Option";
26
21
  import * as Path from "effect/Path";
22
+ import * as Queue from "effect/Queue";
27
23
  import * as Ref from "effect/Ref";
24
+ import * as Result from "effect/Result";
25
+ import * as S from "effect/Schema";
28
26
  import * as Terminal from "effect/Terminal";
29
27
  import * as Argument from "effect/unstable/cli/Argument";
30
28
  import * as CliConfig from "effect/unstable/cli/CliConfig";
29
+ import * as CliError from "effect/unstable/cli/CliError";
31
30
  import * as Command from "effect/unstable/cli/Command";
32
31
  import * as Flag from "effect/unstable/cli/Flag";
33
32
  import * as GlobalFlag from "effect/unstable/cli/GlobalFlag";
33
+ import { readFileSync } from "node:fs";
34
34
  import { resolve } from "node:path";
35
- import { NodePath } from "@effect/platform-node";
36
- import * as Match from "effect/Match";
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";
37
39
  import * as Predicate from "effect/Predicate";
38
- import * as S from "effect/Schema";
39
40
  import { Cell, Workflow } from "@systemfsoftware/effect-cell-types";
40
- import { performance } from "node:perf_hooks";
41
- import { format, inspect } from "node:util";
42
- import { createHash } from "node:crypto";
43
- import { readFileSync } from "node:fs";
44
- import { noopLogger } from "@systemfsoftware/stryker-js-plugin-api/logging";
41
+ import * as Context from "effect/Context";
45
42
  import { pipe } from "effect/Function";
46
- import { PluginKind } from "@systemfsoftware/stryker-js-plugin-api/plugin";
47
- import { strykerPlugins } from "@systemfsoftware/stryker-js-mutation-report/stryker-plugins";
48
- //#region src/output-mode.ts
49
- /**
50
- * The known tool variables. Narrow per the plan — exactly
51
- * `['CLAUDECODE', 'CODEX_SANDBOX']` and load-bearing rather than a
52
- * fallback: they cover the PTY-allocating harnesses a stdin condition would
53
- * have rescued.
54
- */
55
- const TOOL_VARIABLES = ["CLAUDECODE", "CODEX_SANDBOX"];
56
- /**
57
- * Resolves the output mode by R4 precedence. Pure — reads nothing, so it is
58
- * fully testable; the caller supplies every input once at startup. The
59
- * mutually-exclusive-flags case is a caller error, returned as a `failure` so
60
- * the function stays total.
61
- */
62
- function resolveMode(input) {
63
- if (input.text === true && input.json === true) return Result.fail(CliError.InvalidValue.make({
64
- option: "json",
65
- value: "text",
66
- expected: "the \"--format text\" and \"--json\" flags are mutually exclusive — use one or the other",
67
- kind: "flag"
68
- }));
69
- if (input.text === true) return Result.succeed({
70
- mode: "human",
71
- signal: "flag",
72
- stdoutIsTTY: input.stdoutIsTTY
73
- });
74
- if (input.json === true) return Result.succeed({
75
- mode: "machine",
76
- signal: "flag",
77
- stdoutIsTTY: input.stdoutIsTTY
78
- });
79
- if (input.envMode !== void 0 && input.envMode.length > 0) return Result.succeed({
80
- mode: input.envMode === "machine" ? "machine" : "human",
81
- signal: "env",
82
- stdoutIsTTY: input.stdoutIsTTY
83
- });
84
- if (!input.stdoutIsTTY) return Result.succeed({
85
- mode: "machine",
86
- signal: "tty",
87
- stdoutIsTTY: false
88
- });
89
- if (input.agent !== void 0 && input.agent.length > 0) return Result.succeed({
90
- mode: "machine",
91
- signal: "agent",
92
- 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
93
95
  });
94
- for (const variable of TOOL_VARIABLES) {
95
- const value = input.toolVars?.[variable];
96
- if (value !== void 0 && value.length > 0) return Result.succeed({
97
- mode: "machine",
98
- signal: "tool",
99
- 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
100
102
  });
101
103
  }
102
- return Result.succeed({
103
- mode: "human",
104
- signal: "tty",
105
- stdoutIsTTY: true
104
+ return RunFailed$1.make({
105
+ code: 1,
106
+ diagnostic: command.diagnostic
106
107
  });
107
108
  }
108
- /**
109
- * The log colouriser's gate (R8). Machine mode never emits colour, so a
110
- * harness merging `2>&1` is not handed escape sequences it must strip, and
111
- * `NO_COLOR` is honoured for the human path per the convention: any value
112
- * other than an unset or empty variable disables colour.
113
- */
114
- function isColorEnabled(resolved, noColor) {
115
- return resolved.mode === "human" && (noColor === void 0 || noColor.length === 0);
109
+ function succeedRun(ok) {
110
+ return Result.succeed(ok);
116
111
  }
117
- //#endregion
118
- //#region src/output-mode-probe.ts
119
- var OutputModeProbeTag = class extends Context.Service()("@systemfsoftware/stryker-js-cli/output-mode-probe/OutputModeProbeTag") {};
120
- const OutputModeProbe = OutputModeProbeTag;
121
- const OutputModeProbeLive = Layer.succeed(OutputModeProbe, OutputModeProbe.of({ detectMode: () => {
122
- const envMode = process.env["STRYKER_MODE"];
123
- const agent = process.env["AGENT"];
124
- return Result.getOrThrow(resolveMode({
125
- stdoutIsTTY: process.stdout.isTTY === true,
126
- ...envMode !== void 0 ? { envMode } : {},
127
- ...agent !== void 0 ? { agent } : {},
128
- toolVars: Object.fromEntries(TOOL_VARIABLES.map((variable) => [variable, process.env[variable]]))
129
- }));
130
- } }));
131
- //#endregion
132
- //#region src/stream-protocol.ts
133
- /**
134
- * The heartbeat interval (R19), matching Terraform's `apply_progress`
135
- * cadence: long enough that a slow phase is not noisy, short enough that a
136
- * consumer can tell "slow" from "hung" without waiting for a mutant event.
137
- */
138
- const TICK_INTERVAL_MS = 1e4;
139
- //#endregion
140
- //#region src/run-event-stream.ts
141
- var RunEventStreamPortTag = class extends Context.Service()("@systemfsoftware/stryker-js-cli/run-event-stream/RunEventStreamPortTag") {};
142
- const RunEventStreamPort = RunEventStreamPortTag;
143
- const isTerminalEvent = (event) => event.kind === "verdict" || event.kind === "error" || event.kind === "help" || event.kind === "manifest";
144
- /**
145
- * The push adapter from the run's synchronous sink to the callback mailbox.
146
- * `Queue.offer`/`Queue.end` on an unbounded queue never block or fail, so the
147
- * sync sink can drive them with `Effect.runSyncWith(ctx)` using the context
148
- * captured where the sink is constructed. Takes the queue and the captured
149
- * context, keeping the adapter pure with respect to its environment.
150
- */
151
- function queueEmit(queue, ctx) {
152
- return {
153
- single: (event) => {
154
- Effect.runSyncWith(ctx)(Queue.offer(queue, event));
155
- },
156
- end: () => {
157
- Effect.runSyncWith(ctx)(Queue.end(queue));
158
- }
159
- };
112
+ function failRun(error) {
113
+ return Result.fail(error);
160
114
  }
161
- /**
162
- * The drain writes the framed lines through the platform's `Stdio` stdout
163
- * sink — `NodeStdio.layer` at the composition root supplies the service, and
164
- * the sink owns the writable's backpressure, the scoped `'error'` listener a
165
- * closed consumer raises, and the final `'finish'` wait (`endOnDone`). A
166
- * write failure surfaces as a `PlatformError` which this catch swallows — a
167
- * consumer closing the pipe must not replace the run's classed exit code
168
- * (R31) — and the drain still completes only after every byte was handed to
169
- * the OS (R30).
170
- */
171
- const drainOf = (stdio, framed) => Stream.run(framed, stdio.stdout({ endOnDone: true })).pipe(Effect.ignore);
172
- /**
173
- * Creates a run's stream. The drain is an Effect the composition root forks
174
- * before the run; until then the sink is unbound and every push is dropped,
175
- * which is what makes human mode — or an absent drain — inert without
176
- * per-call probing (R2). The run's clock zero is read from the runtime so
177
- * the adapter never touches the wall clock directly.
178
- */
179
- const makeRunEventStream = (stdio, resolved) => Effect.gen(function* () {
180
- const runId = generateRunId();
181
- const startedAt = yield* Clock.currentTimeMillis;
182
- const ctx = yield* Effect.context();
183
- const state = {
184
- mode: resolved.mode,
185
- signal: resolved.signal,
186
- emit: null,
187
- headerWritten: false,
188
- terminalWritten: false,
189
- progress: {
190
- completed: 0,
191
- total: null
192
- }
193
- };
194
- const registered = yield* Deferred.make();
195
- const eventStream = Stream.callback((queue) => Effect.sync(() => {
196
- state.emit = queueEmit(queue, ctx);
197
- }).pipe(Effect.andThen(Deferred.succeed(registered, void 0))));
198
- const tickStream = Stream.tick(TICK_INTERVAL_MS).pipe(Stream.filter(() => state.mode === "machine" && state.headerWritten && !state.terminalWritten), Stream.mapEffect(() => Effect.gen(function* () {
199
- return {
200
- kind: "tick",
201
- elapsedMs: (yield* Clock.currentTimeMillis) - startedAt,
202
- completed: state.progress.completed,
203
- total: state.progress.total
204
- };
205
- })));
206
- let terminalSeen = false;
207
- const framed = Stream.merge(eventStream, tickStream, { haltStrategy: "either" }).pipe(Stream.filter((event) => {
208
- if (terminalSeen) return false;
209
- if (isTerminalEvent(event)) terminalSeen = true;
210
- return true;
211
- }), Stream.map((event) => `${JSON.stringify(event)}\n`));
212
- const drain = drainOf(stdio, framed);
213
- let drainFiber = null;
214
- const sink = (event) => {
215
- const emit = state.emit;
216
- if (emit === null || state.terminalWritten || state.mode !== "machine") return;
217
- if (!state.headerWritten) {
218
- state.headerWritten = true;
219
- emit.single({
220
- kind: "stream",
221
- schemaVersion: "1.0",
222
- runId,
223
- mode: state.mode,
224
- signal: state.signal
225
- });
226
- }
227
- switch (event.kind) {
228
- case "stream":
229
- case "tick": return;
230
- case "phase": break;
231
- case "plan":
232
- state.progress = {
233
- ...state.progress,
234
- total: event.total
235
- };
236
- break;
237
- case "mutant":
238
- state.progress = {
239
- completed: event.completed,
240
- total: event.total
241
- };
242
- break;
243
- case "verdict":
244
- case "error":
245
- case "help":
246
- case "manifest":
247
- emit.single(event);
248
- state.terminalWritten = true;
249
- emit.end();
250
- return;
251
- }
252
- emit.single(event);
253
- };
254
- return {
255
- sink,
256
- runId,
257
- startedAt,
258
- isOpen: () => state.emit !== null && state.mode === "machine" && !state.terminalWritten,
259
- ensureOpen: (openResolved) => {
260
- if (state.headerWritten) return;
261
- state.mode = openResolved.mode;
262
- state.signal = openResolved.signal;
263
- },
264
- open: Effect.gen(function* () {
265
- if (drainFiber === null) {
266
- drainFiber = yield* Effect.forkDetach(drain);
267
- yield* Effect.race(Deferred.await(registered), Fiber.await(drainFiber));
268
- }
269
- }),
270
- closeAndDrain: Effect.gen(function* () {
271
- state.terminalWritten = true;
272
- state.emit?.end();
273
- if (drainFiber !== null) yield* Fiber.join(drainFiber);
274
- })
275
- };
276
- });
277
- const RunEventStreamLive = Layer.effect(RunEventStreamPort, Effect.map(Stdio.Stdio, (stdio) => RunEventStreamPort.of({ createRunEventStream: (resolved) => makeRunEventStream(stdio, resolved) })));
278
- //#endregion
279
- //#region src/signal-observer.ts
280
- const SIGNAL_NUMBERS = Object.freeze({
281
- SIGINT: 2,
282
- SIGTERM: 15
283
- });
284
- /**
285
- * Installs the listeners and returns the reader.
286
- *
287
- * The listener records and returns: interrupting the run is the runtime's job,
288
- * and doing it from here would race the run's own finalizer for the stream.
289
- * `once` per signal, because a second delivery of the same signal cannot
290
- * change the answer.
291
- */
292
- function observeTerminatingSignal() {
293
- let observed = null;
294
- for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {
295
- observed = SIGNAL_NUMBERS[signal] ?? null;
296
- });
297
- return () => observed;
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);
298
117
  }
118
+ const runOutcomeWorkflow = Workflow.make(RunOutcomeCommand, runOutcomeDecision);
299
119
  //#endregion
300
- //#region src/survivors-report.schema.ts
120
+ //#region src/Survivors.workflow.ts
301
121
  /**
302
122
  * The mutant shape the admission carries, named once because both the decision's
303
123
  * `Admitted` payload and the command's precomputed survivor list are the same shape.
@@ -318,15 +138,6 @@ const MutantShape = S.Struct({
318
138
  })
319
139
  })
320
140
  });
321
- /**
322
- * The prior report as a document, decoded at the boundary. Module-internal: consumers
323
- * get the decode function, not the schema, so the report's wire shape is not a
324
- * surface commitment and the codec has exactly one caller.
325
- *
326
- * `status` is a bare string rather than the closed status set on purpose: the decide only
327
- * compares it to `'Survived'`, so a report written by a newer engine that added a status
328
- * must not be refused for carrying one.
329
- */
330
141
  const PriorReportDocument = S.Struct({
331
142
  config: S.optional(S.Record(S.String, S.Unknown)),
332
143
  framework: S.optional(S.Struct({ version: S.optional(S.String) })),
@@ -350,8 +161,6 @@ const PriorReportDocument = S.Struct({
350
161
  }))
351
162
  }))
352
163
  });
353
- //#endregion
354
- //#region src/survivors-admission.workflow.ts
355
164
  /**
356
165
  * U8 — survivor re-run admission (R10, R11, KTD6, KTD7).
357
166
  *
@@ -475,22 +284,6 @@ function hashesMatch(priorReport, input) {
475
284
  sourceContentHashes: input.sourceContentHashes
476
285
  });
477
286
  }
478
- const rejection = (reason, detail) => ({
479
- kind: "reject",
480
- reason,
481
- remediation: `${detail} ${SURVIVORS_RUN_FIRST_REMEDIATION}`
482
- });
483
- function admissionVerdict(input) {
484
- const priorReport = input.priorReport;
485
- if (priorReport === void 0) return rejection("no-report", NO_REPORT_DETAIL);
486
- if (wasProducedBySurvivorsRun(priorReport)) return rejection("mismatch", SURVIVORS_RUN_SOURCE_DETAIL);
487
- if (input.priorSurvivors.length === 0) return { kind: "no-survivors" };
488
- if (!hashesMatch(priorReport, input)) return rejection("mismatch", MISMATCH_DETAIL);
489
- return {
490
- kind: "admit",
491
- survivors: input.priorSurvivors
492
- };
493
- }
494
287
  const SurvivorsAdmissionTypeId = Symbol.for("@systemfsoftware/stryker-js-cli/SurvivorsAdmission");
495
288
  var Admitted = class extends S.TaggedClass()("Admitted", { survivors: S.Array(MutantShape) }) {
496
289
  [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
@@ -505,48 +298,50 @@ var SurvivorsRejection = class extends S.TaggedError()("SurvivorsRejection", {
505
298
  }) {
506
299
  [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
507
300
  };
508
- /**
509
- * The survivors admission decision: the classification `admissionVerdict`
510
- * produces, assigned to the workflow channels — one arm per kind, no guard
511
- * chain. A missing report, a survivors-sourced report and a hash mismatch are
512
- * the same reject outcome with different reasons; only the rejection's
513
- * remediation names the full run to do first (R10).
514
- */
515
- const admitSurvivorsRun = Workflow.make(AdmitSurvivorsRunCommand, (command) => Match.value(admissionVerdict(command)).pipe(Match.discriminator("kind")("reject", (verdict) => Result.fail(SurvivorsRejection.make({
516
- reason: verdict.reason,
517
- remediation: verdict.remediation
518
- }))), Match.discriminator("kind")("no-survivors", () => Result.succeed(NoSurvivors.make())), Match.discriminator("kind")("admit", (verdict) => Result.succeed(Admitted.make({ survivors: verdict.survivors }))), Match.exhaustive));
519
- //#endregion
520
- //#region src/survivors-exit.ts
521
- /** The exit class a rejected survivors run exits with (R6: exit 2). */
522
- const SURVIVORS_REJECT_EXIT_CLASS = ExitClass.ConfigError;
523
- //#endregion
524
- //#region src/cli-exit-code.ts
525
- function isExitClass(value) {
526
- return value === ExitClass.VerdictFail || value === ExitClass.ConfigError || value === ExitClass.RuntimeError || value === ExitClass.InternalError;
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 }));
527
314
  }
315
+ function admissionDecision(command) {
316
+ return decideAdmission(command);
317
+ }
318
+ const admitSurvivorsRun = Workflow.make(AdmitSurvivorsRunCommand, admissionDecision);
319
+ //#endregion
320
+ //#region src/Envelope.ts
528
321
  /**
529
- * The `exitClass` of a tagged error, when it carries one.
322
+ * Envelope the failure envelope and console capture leaf.
530
323
  *
531
- * Every `*.schema.ts` error in `mutation-run` carries a `readonly exitClass`
532
- * member (or schema field) `2` for config, `3` for runtime, `4` for
533
- * internal. The field is deliberately off-wire, but at the CLI edge the
534
- * errors are in-process objects, so it is readable via `Reflect.get`.
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.
535
329
  */
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
+ }
536
338
  function exitClassOf(value) {
537
339
  if (typeof value !== "object" || value === null) return;
538
340
  if (!("exitClass" in value)) return;
539
341
  const raw = Reflect.get(value, "exitClass");
540
- if (typeof raw !== "number" || !isExitClass(raw)) return;
342
+ if (!isExitClass(raw)) return;
541
343
  return raw;
542
344
  }
543
- /**
544
- * Collects every `exitClass` present in a value's nested `cause` chain.
545
- *
546
- * Depth-capped and cycle-safe: schema errors nest (`PrepareFailedError`
547
- * wrapping `ConfigFileInvalidError` wrapping a validation detail), and a
548
- * malformed chain must not recurse unboundedly.
549
- */
550
345
  function collectExitClassesFromValue(value, out, seen, depth) {
551
346
  if (depth > 10 || value === null || value === void 0) return;
552
347
  if (typeof value !== "object") return;
@@ -560,105 +355,37 @@ function collectExitClassesFromValue(value, out, seen, depth) {
560
355
  else collectExitClassesFromValue(causeVal, out, seen, depth + 1);
561
356
  }
562
357
  }
563
- /**
564
- * Collects every `exitClass` present in the failure's `Cause` and in each
565
- * error's nested `cause` field.
566
- *
567
- * A `Cause` holds typed `Fail` and defect `Die` reasons
568
- * (`repos/effect/packages/effect/src/Cause.ts:144-196`, `isFailReason` /
569
- * `isDieReason`). `Effect.mapError` replaces the error value while keeping
570
- * only the mapped error in `cause.reasons`
571
- * (`repos/effect/packages/effect/src/internal/effect.ts:3253-3267`), so a
572
- * wrapped config error is invisible at the top level and must be found by
573
- * walking both the `Cause` reasons and each error's own `cause` field.
574
- */
575
358
  function collectExitClasses(exit) {
576
359
  const out = [];
577
360
  const seen = /* @__PURE__ */ new WeakSet();
578
361
  if (Exit.isFailure(exit)) for (const reason of exit.cause.reasons) {
579
- const candidate = Cause.isFailReason(reason) ? reason.error : Cause.isDieReason(reason) ? reason.defect : void 0;
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;
580
366
  if (candidate !== void 0) collectExitClassesFromValue(candidate, out, seen, 0);
581
367
  }
582
368
  return out;
583
369
  }
584
- /**
585
- * Classifies a failed run for the finalizer: usage/parse failures
586
- * (`CliError` — except a bare help request, which exits 0), rejected
587
- * survivors runs (`SurvivorsRejection`), an unreadable prior report
588
- * (`S.SchemaError`) all exit 2; otherwise the highest `exitClass` found by
589
- * walking the failure's `Cause` reasons and each error's nested `cause` field
590
- * wins by the precedence `4 > 3 > 2 > 1` (via `highestExitClass`); no class
591
- * found is 1 (the framework's default). A successful run exits 0; the verdict
592
- * gates (U5) then resolve the final classed code.
593
- *
594
- * The report parse failure shares the survivors class deliberately. It is not a
595
- * verdict — the decider never sees the report — but the operator's answer is the
596
- * same class of answer as a rejection: the input you named cannot be used. Letting
597
- * it fall through to 1 would make an unusable `--survivors` input indistinguishable
598
- * from a crash.
599
- *
600
- * Previously this matched only a top-level `ConfigError` via `carriesConfigError`,
601
- * so a `PrepareFailedError` wrapping a `ConfigFileInvalidError` was invisible
602
- * and classes 3 and 4 were unreachable. Walking the chain makes them reachable.
603
- */
604
- function resolveCliExitCode(exit) {
605
- if (Exit.isSuccess(exit)) return 0;
606
- if (Cause.hasInterruptsOnly(exit.cause)) return 1;
607
- const failure = Cause.findErrorOption(exit.cause);
608
- if (Option.isSome(failure)) {
609
- const value = failure.value;
610
- if (S.is(CliError.ShowHelp)(value)) return value.errors.length > 0 ? 2 : 0;
611
- if (CliError.isCliError(value)) return 2;
612
- if (S.is(SurvivorsRejection)(value)) return SURVIVORS_REJECT_EXIT_CLASS;
613
- if (S.isSchemaError(value)) return SURVIVORS_REJECT_EXIT_CLASS;
614
- }
615
- const classes = collectExitClasses(exit);
616
- const highest = highestExitClass(classes);
617
- if (highest !== null) return highest;
618
- return 1;
619
- }
620
- //#endregion
621
- //#region src/cli-failure-text.ts
622
- /**
623
- * The reason a domain error carries, when it carries one.
624
- *
625
- * Every stage error in this engine is an `S.TaggedError` whose payload field is
626
- * `reason` — `DryRunNoTestsError`, `DryRunFailedError`, `PrepareFailedError`
627
- * and friends. Those classes extend `Error`, but nothing assigns `.message`, so
628
- * reading `.message` off one yields the empty string and the operator is told
629
- * a run failed with no indication of why. Read the field the errors actually
630
- * populate, and fall back only when it is absent.
631
- */
632
370
  function reasonOf(value) {
633
371
  if (!("reason" in value)) return;
634
372
  const reason = Reflect.get(value, "reason");
635
373
  if (typeof reason !== "string" || reason.length === 0) return;
636
374
  const detail = causeTextOf(value);
637
- return detail === void 0 ? reason : `${reason}: ${detail}`;
375
+ if (detail === void 0) return reason;
376
+ return `${reason}: ${detail}`;
638
377
  }
639
- /**
640
- * The human-readable text of a domain error's wrapped `cause`, if it has one.
641
- *
642
- * Recurses, because these errors nest: a stage error wraps a
643
- * `TestRunnerFailed`, which wraps the spawn or import failure that actually
644
- * happened. Stopping at the first layer reports a tag name — "TestRunnerFailed"
645
- * — and leaves the operator to guess. Each layer contributes only what it
646
- * knows, so the reader gets the chain down to the real fault.
647
- */
648
378
  function causeTextOf(value, depth = 0) {
649
379
  if (depth > 4 || !("cause" in value)) return;
650
380
  const cause = Reflect.get(value, "cause");
651
381
  return causeText(cause, depth + 1);
652
382
  }
653
383
  function configDetailOf(value) {
654
- const reason = reasonOf(value);
655
- if (reason !== void 0) return reason;
656
- const text = causeText(value, 0);
657
- if (text !== void 0 && text.length > 0) return text;
658
- if ("message" in value) {
659
- const msg = Reflect.get(value, "message");
660
- if (typeof msg === "string" && msg.length > 0) return msg;
661
- }
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;
662
389
  }
663
390
  function shouldVisitConfigValue(value, depth, seen) {
664
391
  if (depth > 10) return false;
@@ -679,16 +406,15 @@ function pushConfigCauses(value, depth, stack) {
679
406
  depth: depth + 1
680
407
  });
681
408
  }
682
- /**
683
- * The first config-class error's detail in cause-chain order, for the
684
- * config remediation.
685
- */
686
409
  function firstConfigErrorDetail(exit) {
687
410
  if (!Exit.isFailure(exit)) return;
688
411
  const seen = /* @__PURE__ */ new WeakSet();
689
412
  const stack = [];
690
413
  for (const reason of exit.cause.reasons) {
691
- const candidate = Cause.isFailReason(reason) ? reason.error : Cause.isDieReason(reason) ? reason.defect : void 0;
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;
692
418
  if (candidate !== void 0) stack.push({
693
419
  value: candidate,
694
420
  depth: 0
@@ -700,128 +426,195 @@ function firstConfigErrorDetail(exit) {
700
426
  const { value, depth } = entry;
701
427
  if (!shouldVisitConfigValue(value, depth, seen)) continue;
702
428
  seen.add(value);
703
- if (exitClassOf(value) === ExitClass.ConfigError) {
429
+ if (exitClassOf(value) === "ConfigError") {
704
430
  const detail = configDetailOf(value);
705
431
  if (detail !== void 0) return detail;
706
432
  }
707
433
  pushConfigCauses(value, depth, stack);
708
434
  }
709
435
  }
710
- /**
711
- * The contextual remediation for a failure, picked from the class of the
712
- * failure whose cause chain contains an `exitClass` of `ConfigError` (2)
713
- * names the config file, rejected survivors runs name the full run to do
714
- * first. Everything else points at the report file and the verdict envelope,
715
- * which is where a runtime failure's detail already is. The classification
716
- * walks the `Cause` reasons and each error's nested `cause` field — see
717
- * `collectExitClasses`.
718
- */
719
- function remediationFor(exit, code) {
720
- if (code > 128) return "the run was interrupted by a signal; re-run it to continue";
436
+ function describeFailure(exit) {
437
+ if (!Exit.isFailure(exit)) return "Unknown failure";
721
438
  const value = failureValue(exit);
722
439
  if (value !== void 0) {
723
- if (CliError.isCliError(value)) return "re-run with --help to see the full usage";
724
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);
725
447
  }
726
- if (collectExitClasses(exit).includes(ExitClass.ConfigError)) {
448
+ if (collectExitClasses(exit).includes("ConfigError")) {
727
449
  const detail = firstConfigErrorDetail(exit);
728
- return detail !== void 0 ? `check the config file: ${detail}` : "check the config file";
450
+ if (detail !== void 0) return detail;
729
451
  }
730
- return "see --reportFile or the verdict envelope on stdout";
452
+ const rendered = Cause.pretty(exit.cause);
453
+ if (rendered.length > 0) return rendered;
454
+ return "Unknown failure";
731
455
  }
732
- /**
733
- * The failure's own text, used when the capture buffer is empty — a failure
734
- * stryker reported through its own logger rather than the framework's
735
- * `Console`. Falls back to a rendered cause.
736
- */
737
- function describeFailure(exit) {
738
- if (Exit.isFailure(exit)) {
739
- const value = failureValue(exit);
740
- if (value !== void 0) {
741
- if (S.is(SurvivorsRejection)(value)) return value.remediation;
742
- if (typeof value === "object" && value !== null) {
743
- const reason = reasonOf(value);
744
- if (reason !== void 0) return reason;
745
- }
746
- if (value instanceof Error && value.message.length > 0) return value.message;
747
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") return String(value);
748
- return Cause.pretty(exit.cause);
749
- }
750
- return Cause.pretty(exit.cause);
751
- }
752
- return "";
753
- }
754
- /**
755
- * The argument the framework reports it does not know, named the way the wire
756
- * contract spells it. The v4 parser fails wrapped in a ShowHelp whose errors
757
- * carry the offending flag or operand; when the unrecognized flag was given a
758
- * separate value (`--format text`), the value is the token the old parser
759
- * reported, so the token after the flag is named when one was given.
760
- */
761
456
  function unrecognizedArgumentOf(exit, argv) {
762
457
  if (!Exit.isFailure(exit)) return;
763
458
  const value = failureValue(exit);
764
459
  if (value === void 0 || !CliError.isCliError(value)) return;
765
- const errors = S.is(CliError.ShowHelp)(value) ? value.errors : [value];
460
+ const errors = (() => {
461
+ if (S.is(CliError.ShowHelp)(value)) return value.errors;
462
+ return [value];
463
+ })();
766
464
  for (const error of errors) {
767
465
  if (S.is(CliError.UnrecognizedOption)(error)) {
768
466
  const at = argv.indexOf(error.option);
769
- const next = at >= 0 ? argv[at + 1] : void 0;
770
- return next !== void 0 && !next.startsWith("-") ? next : 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;
771
472
  }
772
473
  if (S.is(CliError.UnexpectedArgument)(error)) return error.arguments[0];
773
474
  if (S.is(CliError.UnknownSubcommand)(error)) return error.subcommand;
774
475
  }
775
476
  }
776
- /**
777
- * The first typed error in the exit's cause. The framework fails with
778
- * `Cause.fail` (usage errors); the run handler is `Effect.promise`, whose
779
- * rejected promises surface as *defects* (`Die` reasons) rather than
780
- * failures — so stryker's own ConfigError/StrykerError values arrive there
781
- * and must be read from the cause's `Die` reasons.
782
- */
783
477
  function failureValue(exit) {
784
478
  if (!Exit.isFailure(exit)) return;
785
479
  const failure = Cause.findErrorOption(exit.cause);
786
480
  if (Option.isSome(failure)) return failure.value;
787
- const dieReason = exit.cause.reasons.find(Cause.isDieReason);
788
- return dieReason === void 0 ? void 0 : dieReason.defect;
789
481
  }
790
- //#endregion
791
- //#region src/cli-error-envelope.ts
792
- function buildErrorEnvelope(exit, code, captured, argv) {
793
- const unrecognized = unrecognizedArgumentOf(exit, argv);
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) {
794
572
  return {
795
573
  schemaVersion: "1.0",
796
- code,
797
- error: unrecognized !== void 0 ? `Received unknown argument: '${unrecognized}'` : captured.length > 0 ? captured : describeFailure(exit),
798
- remediation: remediationFor(exit, code)
574
+ code: runOutcomeCode(Result.fail(error)),
575
+ error: errorText(error, captured),
576
+ remediation: remediationText(error)
799
577
  };
800
578
  }
801
- //#endregion
802
- //#region src/console-capture.ts
803
- /**
804
- * U6 — the machine-mode `Console` layer (KTD3, R7).
805
- *
806
- * The v4 CLI renders help and errors through an ANSI renderer and prints them
807
- * with the `Console` reference — and there is no seam to intercept: the
808
- * document is written *before* the failure propagates. Machine mode therefore
809
- * replaces the `Console` reference itself with a capturing implementation:
810
- * every write lands in an in-memory buffer instead of the real stdout/stderr,
811
- * and the terminating bootstrap (StrykerCliHandler.ts) emits the buffer as
812
- * one JSON envelope at teardown. Human mode keeps the default console so the
813
- * framework's prose rendering is untouched.
814
- *
815
- * The v4 `Console.Console` interface is the sync `globalThis.console` shape
816
- * (the v3 service's effect-returning surface became the module-level
817
- * wrapper functions), so the capture implementation needs no `unsafe`
818
- * mirror — every method stores into the buffer directly.
819
- */
579
+ function classifyRunOutcome(exit, signal, argv) {
580
+ return runOutcomeWorkflow(gatherRunOutcome(exit, signal, argv));
581
+ }
820
582
  const capturedConsoleChunks = [];
821
583
  const countByLabel = /* @__PURE__ */ new Map();
822
584
  const timeByLabel = /* @__PURE__ */ new Map();
585
+ function inspectValue(value) {
586
+ if (typeof value === "string") return value;
587
+ return Formatter.format(value);
588
+ }
823
589
  function formatArgs(args) {
824
- 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(" ");
825
618
  }
826
619
  function captureSync(args) {
827
620
  capturedConsoleChunks.push(formatArgs(args));
@@ -835,86 +628,352 @@ function captureCount(label) {
835
628
  countByLabel.set(key, next);
836
629
  capturedConsoleChunks.push(`${key}: ${next}`);
837
630
  }
838
- function captureTimeEnd(label, now) {
631
+ function captureTimeEnd(label, nowNanos) {
839
632
  const key = label ?? "default";
840
633
  const started = timeByLabel.get(key);
841
634
  if (started !== void 0) {
842
635
  timeByLabel.delete(key);
843
- capturedConsoleChunks.push(`${key}: ${now - started}ms`);
636
+ const diffMs = Number(nowNanos - started) / 1e6;
637
+ capturedConsoleChunks.push(`${key}: ${diffMs}ms`);
844
638
  }
845
639
  }
846
640
  function captureTrace(args) {
847
641
  capturedConsoleChunks.push(`Trace: ${formatArgs(args)}\n${(/* @__PURE__ */ new Error()).stack ?? ""}`);
848
642
  }
849
- const capturingConsole = {
643
+ const makeCapturingConsole = (clock) => ({
850
644
  assert: (condition, ...args) => captureAssert(condition, args),
851
645
  clear: () => {},
852
646
  count: (label) => captureCount(label),
853
647
  countReset: (label) => countByLabel.delete(label ?? "default"),
854
648
  debug: (...args) => captureSync(args),
855
- dir: (item, options) => capturedConsoleChunks.push(inspect(item, options)),
856
- dirxml: (item) => capturedConsoleChunks.push(inspect(item)),
649
+ dir: (item, _options) => capturedConsoleChunks.push(Formatter.format(item)),
650
+ dirxml: (item) => capturedConsoleChunks.push(Formatter.format(item)),
857
651
  error: (...args) => captureSync(args),
858
652
  group: () => {},
859
653
  groupCollapsed: () => {},
860
654
  groupEnd: () => {},
861
655
  info: (...args) => captureSync(args),
862
656
  log: (...args) => captureSync(args),
863
- table: (tabularData) => capturedConsoleChunks.push(inspect(tabularData, {
864
- colors: false,
865
- depth: null
866
- })),
867
- time: (label) => timeByLabel.set(label ?? "default", performance.now()),
868
- 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()),
869
660
  timeLog: (label, ...args) => {
870
661
  const key = label ?? "default";
871
662
  const started = timeByLabel.get(key);
872
- 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)}`);
873
670
  },
874
671
  trace: (...args) => captureTrace(args),
875
672
  warn: (...args) => captureSync(args)
876
- };
673
+ });
674
+ const machineConsoleLayer = Layer.effect(Console.Console, Clock.clockWith((clock) => Effect.sync(() => {
675
+ resetCapturedConsole();
676
+ return makeCapturingConsole(clock);
677
+ })));
678
+ function readCapturedConsole() {
679
+ return capturedConsoleChunks.join("\n");
680
+ }
681
+ function resetCapturedConsole() {
682
+ capturedConsoleChunks.length = 0;
683
+ countByLabel.clear();
684
+ timeByLabel.clear();
685
+ }
686
+ //#endregion
687
+ //#region src/Output.workflow.ts
688
+ const TOOL_VARIABLES$1 = ["CLAUDECODE", "CODEX_SANDBOX"];
877
689
  /**
878
- * The machine-mode `Console` layer. Building it clears the capture buffer so
879
- * every run starts empty; the terminating bootstrap reads the buffer back
880
- * through `readCapturedConsole` at teardown. A `Layer` is already lazy, so
881
- * the layer is a value: the reset effect runs when the layer is built.
882
- *
883
- * The layer must replace the `Console` reference the module-level
884
- * `Console.log`/`Console.error` wrappers read through their fiber context;
885
- * v4 reads the override the same way it reads any provided service, so a
886
- * plain provide is sufficient — no special `setConsole`-style primitive
887
- * exists any more. The reference's identifier is `never` (it carries no
888
- * requirement), so the layer's type is too.
889
- *
890
- * Human mode provides no Console binding at all: effect's own default
891
- * console delegates every method to the global console, which is exactly the
892
- * prose rendering a human-mode run uses. Mirroring it here would reimplement
893
- * the library default (V.7).
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.
894
692
  */
895
- const machineConsoleLayer = Layer.effect(Console.Console, Effect.sync(() => {
896
- resetCapturedConsole();
897
- return capturingConsole;
898
- }));
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
+ }) {};
899
701
  /**
900
- * The text captured so far by the machine console layer, joined into one
901
- * document the way a terminal would have rendered it (one console call per
902
- * line). Empty in human mode.
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.
903
706
  */
904
- function readCapturedConsole() {
905
- return capturedConsoleChunks.join("\n");
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
+ });
906
765
  }
766
+ function modeDecision(command) {
767
+ return r4(command);
768
+ }
769
+ const resolveModeWorkflow = Workflow.make(ResolveModeCommand, modeDecision);
770
+ //#endregion
771
+ //#region src/Output.ts
772
+ /**
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.
778
+ */
779
+ /**
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.
783
+ */
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;
862
+ return {
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);
884
+ }
885
+ }),
886
+ closeAndDrain: Effect.gen(function* () {
887
+ state.terminalWritten = true;
888
+ yield* Queue.end(queue);
889
+ if (drainFiber !== null) yield* Fiber.join(drainFiber);
890
+ })
891
+ };
892
+ });
893
+ Layer.effect(RunEventStreamPort, Effect.map(Stdio.Stdio, (stdio) => RunEventStreamPort.of({ createRunEventStream: (resolved) => makeRunEventStream(stdio, resolved) })));
894
+ const TOOL_VARIABLES = ["CLAUDECODE", "CODEX_SANDBOX"];
907
895
  /**
908
- * Clears the capture buffer and the count/time tables. Called when the
909
- * machine layer is constructed so every run starts empty.
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.
910
900
  */
911
- function resetCapturedConsole() {
912
- capturedConsoleChunks.length = 0;
913
- countByLabel.clear();
914
- timeByLabel.clear();
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;
915
906
  }
916
- //#endregion
917
- //#region src/cli-machine-output.ts
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({}) }));
918
977
  /**
919
978
  * Machine mode emits the U4 verdict envelope for a run that produced no
920
979
  * mutants and no report file: a `--survivors` run with zero survivors (AE3)
@@ -935,11 +994,18 @@ function emitNullScoreVerdict(stream, mode, thresholds, config, basePath, pathSe
935
994
  name: "StrykerJS",
936
995
  version: strykerVersion
937
996
  }
938
- }, mode.mode, mode.signal, stream.runId, basePath, [], pathService);
939
- stream.sink({
940
- kind: "verdict",
941
- ...envelope
942
- });
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
+ }));
943
1009
  }
944
1010
  /**
945
1011
  * Emits the machine-mode output from the run's finalizer — it runs on
@@ -954,50 +1020,99 @@ function emitNullScoreVerdict(stream, mode, thresholds, config, basePath, pathSe
954
1020
  * `--dryRunOnly` early return): then a null-score `verdict` closes the
955
1021
  * stream so the last stdout line is always a terminal event (R5).
956
1022
  */
957
- function emitMachineModeOutput(stream, mode, exit, code, argv, basePath, pathService) {
1023
+ function emitMachineModeOutput(stream, mode, outcome, basePath, pathService) {
958
1024
  return Effect.gen(function* () {
959
1025
  const captured = readCapturedConsole();
960
- const value = failureValue(exit);
961
- if (Exit.isFailure(exit) && S.is(CliError.ShowHelp)(value) && value.errors.length === 0) {
962
- const document = {
963
- kind: "help",
964
- schemaVersion: "1.0",
965
- code: 0,
966
- help: captured
967
- };
968
- stream.sink(document);
969
- return;
970
- }
971
- if (Exit.isFailure(exit)) {
972
- stream.sink({
973
- kind: "error",
974
- ...buildErrorEnvelope(exit, code, captured, argv)
975
- });
976
- return;
977
- }
978
- if (captured.length > 0) {
979
- const document = {
980
- kind: "help",
981
- schemaVersion: "1.0",
982
- code: 0,
983
- help: captured
984
- };
985
- stream.sink(document);
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);
986
1044
  return;
987
1045
  }
988
- if (stream.isOpen()) emitNullScoreVerdict(stream, mode, (yield* defaultOptions).thresholds, {}, basePath, pathService);
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
+ }));
989
1053
  });
990
1054
  }
991
1055
  //#endregion
992
- //#region src/survivors-bookkeeping.ts
993
- /** The path a `--survivors` run reads when no `survivorsPriorReport` is configured. */
994
- const DEFAULT_SURVIVORS_PRIOR_REPORT = "reports/mutation-report.json";
1056
+ //#region src/StreamFile.ts
1057
+ const DEFAULT_PROGRESS_STREAM_FILE = "reports/mutation-stream.jsonl";
1058
+ const encodeUtf8 = (line) => new TextEncoder().encode(line);
1059
+ const drainStreamFile = (fileName, framed) => Effect.gen(function* () {
1060
+ const fs = yield* FileSystem.FileSystem;
1061
+ const path = yield* Path.Path;
1062
+ yield* fs.makeDirectory(path.dirname(fileName), { recursive: true }).pipe(Effect.orDie);
1063
+ yield* Effect.scoped(Effect.gen(function* () {
1064
+ const handle = yield* fs.open(fileName, { flag: "w" });
1065
+ yield* Stream.runForEach(framed, (line) => handle.writeAll(encodeUtf8(line)).pipe(Effect.flatMap(() => handle.sync)));
1066
+ })).pipe(Effect.orDie);
1067
+ });
1068
+ const RunEventStreamFileLive = Layer.effect(RunEventStreamPort, Effect.gen(function* () {
1069
+ const stdio = yield* Stdio.Stdio;
1070
+ const fs = yield* FileSystem.FileSystem;
1071
+ const path = yield* Path.Path;
1072
+ const fileNameRef = yield* Ref.make(DEFAULT_PROGRESS_STREAM_FILE);
1073
+ const drainFramed = (framed) => Effect.gen(function* () {
1074
+ const fileName = yield* Ref.get(fileNameRef);
1075
+ yield* drainStreamFile(fileName, framed).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path));
1076
+ });
1077
+ return RunEventStreamPort.of({ createRunEventStream: (resolved) => Effect.gen(function* () {
1078
+ return {
1079
+ ...yield* makeRunEventStream(stdio, resolved, drainFramed),
1080
+ setProgressStreamFile: (fileName) => Ref.set(fileNameRef, fileName)
1081
+ };
1082
+ }) });
1083
+ }));
995
1084
  //#endregion
996
- //#region src/survivors-hashing.ts
997
- const { entries: objectEntries$1, fromEntries: objectFromEntries } = Object;
1085
+ //#region src/Survivors.ts
1086
+ /**
1087
+ * Survivors — the survivors-admission capability.
1088
+ *
1089
+ * The prior-report decoding, source hashing, mutant conversion, and admission
1090
+ * pipeline for --survivors runs. Pure admission decision lives in
1091
+ * Survivors.workflow.ts.
1092
+ */
1093
+ const DEFAULT_SURVIVORS_PRIOR_REPORT = "reports/mutation-report.json";
998
1094
  /**
999
- * The content hash of one source file.
1095
+ * The mutant shape the admission carries, named once because both the decision's
1096
+ * `Admitted` payload and the command's precomputed survivor list are the same shape.
1097
+ */
1098
+ /**
1099
+ * The prior report as a document, decoded at the boundary. Module-internal: consumers
1100
+ * get the decode function, not the schema, so the report's wire shape is not a
1101
+ * surface commitment and the codec has exactly one caller.
1000
1102
  *
1103
+ * `status` is a bare string rather than the closed status set on purpose: the decide only
1104
+ * compares it to `'Survived'`, so a report written by a newer engine that added a status
1105
+ * must not be refused for carrying one.
1106
+ */
1107
+ /**
1108
+ * Decodes a prior report read from disk. Pure, so it runs in the decode phase, whose
1109
+ * `Left` is fatal by construction — it reaches the derived error channel and no write
1110
+ * runs. A malformed report therefore never reaches the decider, and nothing here casts
1111
+ * a third-party report type.
1112
+ */
1113
+ const decodePriorReport = S.decodeUnknownResult(PriorReportDocument);
1114
+ const { entries: objectEntries, fromEntries: objectFromEntries } = Object;
1115
+ /**
1001
1116
  * Thin by design: the digest is the caller's capability, and naming the call
1002
1117
  * keeps every hashing site in the admission path reading the same way.
1003
1118
  */
@@ -1011,11 +1126,8 @@ function sourceContentHash(content, hash) {
1011
1126
  * this is the recorded side, read back out of the report.
1012
1127
  */
1013
1128
  function priorSourceHashes(priorReport, hashContent) {
1014
- return objectFromEntries(objectEntries$1(priorReport.files).map(([file, fileResult]) => [file, sourceContentHash(fileResult.source, hashContent)]));
1129
+ return objectFromEntries(objectEntries(priorReport.files).map(([file, fileResult]) => [file, sourceContentHash(fileResult.source, hashContent)]));
1015
1130
  }
1016
- //#endregion
1017
- //#region src/survivors-mutants.ts
1018
- const { entries: objectEntries } = Object;
1019
1131
  /**
1020
1132
  * Converts a report mutant (1-based schema location) into the internal mutant
1021
1133
  * shape a run consumes (0-based positions, absolute file name) — the exact
@@ -1025,7 +1137,7 @@ const { entries: objectEntries } = Object;
1025
1137
  * incremental differ uses.
1026
1138
  */
1027
1139
  function reportMutantToMutant(file, mutant, resolveAbsolutePath) {
1028
- return {
1140
+ return Mutant.make({
1029
1141
  id: mutant.id,
1030
1142
  fileName: resolveAbsolutePath(file),
1031
1143
  mutatorName: mutant.mutatorName,
@@ -1040,7 +1152,7 @@ function reportMutantToMutant(file, mutant, resolveAbsolutePath) {
1040
1152
  column: mutant.location.end.column - 1
1041
1153
  }
1042
1154
  }
1043
- };
1155
+ });
1044
1156
  }
1045
1157
  /**
1046
1158
  * The survivors of the prior report: exactly the mutants whose status is
@@ -1057,11 +1169,11 @@ function extractSurvivors(priorReport, resolveAbsolutePath) {
1057
1169
  * ranges: the report's 1-based lines with the internal 0-based columns,
1058
1170
  * relative file names, deduplicated in first-seen order.
1059
1171
  */
1060
- function survivorMutateSpans(survivors, basePath, pathService) {
1172
+ function survivorMutateSpans(survivors, basePath) {
1061
1173
  const spans = [];
1062
1174
  const seen = /* @__PURE__ */ new Set();
1063
1175
  for (const survivor of survivors) {
1064
- const file = toRelativeNormalizedFileName(survivor.fileName, basePath, pathService);
1176
+ const file = toRelativeNormalizedFileName(survivor.fileName, basePath);
1065
1177
  const { start, end } = survivor.location;
1066
1178
  const span = `${file}:${start.line + 1}:${start.column}-${end.line + 1}:${end.column}`;
1067
1179
  if (!seen.has(span)) {
@@ -1071,18 +1183,7 @@ function survivorMutateSpans(survivors, basePath, pathService) {
1071
1183
  }
1072
1184
  return spans;
1073
1185
  }
1074
- //#endregion
1075
- //#region src/survivors-report.ts
1076
- /**
1077
- * Decodes a prior report read from disk. Pure, so it runs in the decode phase, whose
1078
- * `Left` is fatal by construction — it reaches the derived error channel and no write
1079
- * runs. A malformed report therefore never reaches the decider, and nothing here casts
1080
- * a third-party report type.
1081
- */
1082
- const decodePriorReport = S.decodeUnknownResult(PriorReportDocument);
1083
- //#endregion
1084
- //#region src/cli-survivors-admission.ts
1085
- const hashContent = (content) => createHash("sha256").update(content, "utf-8").digest("hex");
1186
+ const hashContent = (content) => bytesToHex(sha256(utf8ToBytes(content)));
1086
1187
  const resolveAbsolutePath = (file) => resolve(file);
1087
1188
  /**
1088
1189
  * The survivors admission, as a description whose phases chain by type and
@@ -1095,10 +1196,9 @@ const resolveAbsolutePath = (file) => resolve(file);
1095
1196
  * stashed context back and dispatches the decision to the verdict/run,
1096
1197
  * failing the run with a rejection.
1097
1198
  */
1098
- const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext, basePath) => pipe(Cell.read((cliOptions) => Effect.flatMap(Path.Path, (pathService) => resolveSurvivorsRunOptions(cliOptions, basePath).pipe(Effect.flatMap((resolvedOptions) => {
1199
+ 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) => {
1099
1200
  const priorReportPath = priorReportPathOf(resolvedOptions);
1100
- const read = readPriorReport(priorReportPath);
1101
- return Ref.set(runContext, {
1201
+ return Effect.flatMap(readPriorReport(priorReportPath), (read) => Effect.flatMap(currentSourceHashesFor(priorReportFileKeys(read.raw)), (sourceContentHashes) => Ref.set(runContext, {
1102
1202
  resolvedOptions,
1103
1203
  priorReportPath,
1104
1204
  pathService
@@ -1107,9 +1207,9 @@ const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext
1107
1207
  priorReportRaw: read.raw,
1108
1208
  priorReportFound: read.found,
1109
1209
  priorReportPath,
1110
- sourceContentHashes: currentSourceHashesFor(priorReportFileKeys(read.raw))
1111
- }));
1112
- })))), Cell.decode(({ resolvedOptions, priorReportRaw, priorReportFound, sourceContentHashes }) => {
1210
+ sourceContentHashes
1211
+ }))));
1212
+ }))), services)), Cell.decode(({ resolvedOptions, priorReportRaw, priorReportFound, sourceContentHashes }) => {
1113
1213
  if (!priorReportFound) return Result.succeed(AdmitSurvivorsRunCommand.make({
1114
1214
  priorReport: void 0,
1115
1215
  currentConfig: resolvedOptions,
@@ -1133,11 +1233,12 @@ const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext
1133
1233
  if (context === void 0) return Effect.die("the survivors admission read must run before its write");
1134
1234
  const { resolvedOptions, priorReportPath, pathService } = context;
1135
1235
  return Result.match(outcome, {
1136
- onSuccess: (decision) => Match.value(decision).pipe(Match.tag("NoSurvivors", () => Effect.sync(() => emitNullScoreVerdict(stream, mode, resolvedOptions.thresholds, resolvedOptions, basePath, pathService))), Match.tag("Admitted", (admitted) => {
1236
+ onSuccess: (decision) => Match.value(decision).pipe(Match.tag("NoSurvivors", () => emitNullScoreVerdict(stream, mode, resolvedOptions.thresholds, resolvedOptions, basePath, pathService)), Match.tag("Admitted", (admitted) => {
1237
+ const admittedMutants = admitted.survivors.map((s) => Mutant.make(s));
1137
1238
  return runMutationTest({
1138
1239
  ...resolvedOptions,
1139
- survivors: admitted.survivors,
1140
- mutate: survivorMutateSpans(admitted.survivors, basePath, pathService),
1240
+ survivors: admittedMutants,
1241
+ mutate: survivorMutateSpans(admittedMutants, basePath),
1141
1242
  survivorsPriorReport: priorReportPath,
1142
1243
  incremental: false
1143
1244
  }).pipe(Effect.orDie);
@@ -1160,38 +1261,35 @@ const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext
1160
1261
  */
1161
1262
  function runSurvivorsAdmission(runMutationTest, stream, mode, cliOptions, basePath) {
1162
1263
  return Effect.gen(function* () {
1264
+ const services = yield* Effect.context();
1163
1265
  const admissionContext = yield* Ref.make(void 0);
1164
- return yield* Cell.apply(survivorsAdmissionDescription(runMutationTest, stream, mode, admissionContext, basePath), cliOptions);
1266
+ return yield* Cell.apply(survivorsAdmissionDescription(runMutationTest, stream, mode, admissionContext, basePath, services), cliOptions);
1165
1267
  });
1166
1268
  }
1167
1269
  function resolveSurvivorsRunOptions(cliOptions, basePath) {
1168
- return readConfig(cliOptions, noopLogger, forkCoreSchema, basePath);
1270
+ return readConfig(cliOptions, basePath);
1169
1271
  }
1170
1272
  function priorReportPathOf(resolved) {
1171
1273
  const configured = resolved["survivorsPriorReport"];
1172
- return typeof configured === "string" ? configured : DEFAULT_SURVIVORS_PRIOR_REPORT;
1274
+ if (typeof configured === "string") return configured;
1275
+ return DEFAULT_SURVIVORS_PRIOR_REPORT;
1173
1276
  }
1174
1277
  function readPriorReport(priorReportPath) {
1175
- let text;
1176
- try {
1177
- text = readFileSync(priorReportPath, "utf-8");
1178
- } catch {
1179
- return {
1278
+ return Effect.gen(function* () {
1279
+ return yield* (yield* FileSystem.FileSystem).readFileString(priorReportPath).pipe(Effect.map((text) => ({
1280
+ found: true,
1281
+ raw: Result.match(S.decodeResult(S.fromJsonString(S.Unknown))(text), {
1282
+ onFailure: () => text,
1283
+ onSuccess: (value) => value
1284
+ })
1285
+ })), Effect.catchTag("PlatformError", (cause) => Match.value(cause.reason).pipe(Match.tag("NotFound", () => Effect.succeed({
1180
1286
  found: false,
1181
1287
  raw: void 0
1182
- };
1183
- }
1184
- try {
1185
- return {
1186
- found: true,
1187
- raw: JSON.parse(text)
1188
- };
1189
- } catch {
1190
- return {
1191
- found: true,
1192
- raw: text
1193
- };
1194
- }
1288
+ })), Match.orElse(() => Effect.fail(ConfigFileUnreadableError.make({
1289
+ file: priorReportPath,
1290
+ cause
1291
+ }))))));
1292
+ });
1195
1293
  }
1196
1294
  function priorReportFileKeys(raw) {
1197
1295
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return [];
@@ -1201,116 +1299,43 @@ function priorReportFileKeys(raw) {
1201
1299
  return Object.keys(files);
1202
1300
  }
1203
1301
  function readSourceFile(file) {
1204
- try {
1205
- return readFileSync(file, "utf-8");
1206
- } catch {
1207
- return "";
1208
- }
1302
+ return Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString(file).pipe(Effect.mapError((cause) => ConfigFileUnreadableError.make({
1303
+ file,
1304
+ cause
1305
+ }))));
1209
1306
  }
1210
1307
  function currentSourceHashesFor(files) {
1211
- const hashes = {};
1212
- for (const file of files) hashes[file] = sourceContentHash(readSourceFile(file), hashContent);
1213
- return hashes;
1308
+ return Effect.map(Effect.forEach(files, (file) => Effect.map(readSourceFile(file), (content) => [file, sourceContentHash(content, hashContent)]), { concurrency: 24 }), (pairs) => Object.fromEntries(pairs));
1214
1309
  }
1215
1310
  //#endregion
1216
- //#region src/cli-run.ts
1217
- const defaultRunMutationTest = (hostOptions) => (options) => Effect.scoped(runMutationTest(defaultStages, options)).pipe(Effect.provide(makeRunLayer(hostOptions)));
1311
+ //#region src/Cli.ts
1312
+ const SIGNAL_NUMBERS = Object.freeze({
1313
+ SIGINT: 2,
1314
+ SIGTERM: 15
1315
+ });
1218
1316
  /**
1219
- * The host options a run is bound to: the sink, the mode, the timing and the
1220
- * log descriptor chosen by the mode — machine mode keeps stdout exclusively
1221
- * for the NDJSON stream, so the logging backend is pointed at stderr; human
1222
- * mode keeps the stdout sink. The fix is the descriptor, never the log level.
1317
+ * Installs the listeners and returns the reader.
1318
+ *
1319
+ * The listener records and returns: interrupting the run is the runtime's job,
1320
+ * and doing it from here would race the run's own finalizer for the stream.
1321
+ * `once` per signal, because a second delivery of the same signal cannot
1322
+ * change the answer.
1223
1323
  */
1224
- function hostOptionsOf(mode, stream) {
1225
- return {
1226
- runEventSink: stream.sink,
1227
- runId: stream.runId,
1228
- resolvedMode: mode,
1229
- runStartedAt: stream.startedAt,
1230
- basePath: resolve(process.cwd()),
1231
- reporterPluginModules: [import.meta.resolve("@systemfsoftware/stryker-js-mutation-report/stryker-plugins")],
1232
- logSink: (line) => {
1233
- if (mode.mode === "human") process.stdout.write(line);
1234
- else process.stderr.write(line);
1235
- },
1236
- allowConsoleColors: isColorEnabled(mode, process.env["NO_COLOR"])
1237
- };
1324
+ function observeTerminatingSignal() {
1325
+ let observed = null;
1326
+ for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {
1327
+ observed = SIGNAL_NUMBERS[signal] ?? null;
1328
+ });
1329
+ return () => observed;
1238
1330
  }
1239
- /**
1240
- * The single operation of the CLI's run cell: the impure shell that
1241
- * wraps the transport's command effect with the run bootstrap. It creates the
1242
- * run's stream from the resolved mode, binds the host options a run is
1243
- * executed with, opens the stream, runs the command effect, dispatches the
1244
- * request the handlers left, and on every outcome — success, failure and
1245
- * interruption alike — emits the machine-mode terminal event (error/help/
1246
- * null verdict) and drains the stream, returning the classed exit code as its
1247
- * value. SIGINT/SIGTERM interrupt the current fiber so the finalizer runs
1248
- * before the process exits; the code is resolved exactly once (R6), in the
1249
- * finalizer, where the terminal event's `code` is chosen from the same inputs
1250
- * the teardown used before.
1251
- */
1252
- const runStrykerCli = (input, createRunEventStream) => Effect.gen(function* () {
1253
- const stream = yield* createRunEventStream(input.mode);
1254
- const hostOptions = hostOptionsOf(input.mode, stream);
1255
- const runMutationTestImpl = input.runMutationTest ?? defaultRunMutationTest(hostOptions);
1256
- const basePath = hostOptions.basePath;
1257
- const pathService = yield* Path.Path.pipe(Effect.provide(NodePath.layer));
1258
- let currentFiber = null;
1259
- const verdictOf = (value) => {
1260
- if (!Predicate.hasProperty(value, "verdict")) return [];
1261
- const candidate = value.verdict;
1262
- if (typeof candidate !== "number" || !isExitClass(candidate)) return [];
1263
- return [candidate];
1264
- };
1265
- const resolveClassedExitCode = (exit) => {
1266
- const signal = input.lastSignal();
1267
- if (signal !== null) return 128 + signal;
1268
- if (Exit.isFailure(exit)) return resolveCliExitCode(exit);
1269
- return resolveExitCode(verdictOf(exit.value), null);
1270
- };
1271
- const onSignal = () => {
1272
- process.removeListener("SIGINT", onSignal);
1273
- process.removeListener("SIGTERM", onSignal);
1274
- if (currentFiber !== null) currentFiber.interruptUnsafe(currentFiber.id);
1275
- };
1276
- const dispatch = (request) => Match.value(request).pipe(Match.tag("run", (runRequest) => runRequest.survivors ? runSurvivorsAdmission(runMutationTestImpl, stream, input.mode, runRequest.options, basePath).pipe(Effect.provide(makeRunLayer(hostOptions))) : runMutationTestImpl(runRequest.options).pipe(Effect.orDie)), Match.tag("llms", (llmsRequest) => Effect.sync(() => {
1277
- stream.ensureOpen({
1278
- mode: "machine",
1279
- signal: "flag",
1280
- stdoutIsTTY: process.stdout.isTTY === true
1281
- });
1282
- stream.sink(llmsRequest.document);
1283
- })), Match.orElse(() => Effect.die("unreachable cli request variant")));
1284
- const program = Effect.acquireUseRelease(Effect.sync(() => {
1285
- currentFiber = Fiber.getCurrent() ?? null;
1286
- process.on("SIGINT", onSignal);
1287
- process.on("SIGTERM", onSignal);
1288
- }), () => Effect.gen(function* () {
1289
- yield* stream.open;
1290
- yield* input.program;
1291
- const request = yield* Ref.get(input.requestRef);
1292
- return yield* Option.match(request, {
1293
- onNone: () => Effect.void,
1294
- onSome: (cliRequest) => dispatch(cliRequest)
1295
- });
1296
- }), () => Effect.sync(() => {
1297
- process.removeListener("SIGINT", onSignal);
1298
- process.removeListener("SIGTERM", onSignal);
1299
- }));
1300
- return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
1301
- const exit = yield* Effect.exit(restore(program));
1302
- const code = resolveClassedExitCode(exit);
1303
- if (input.mode.mode === "machine") yield* emitMachineModeOutput(stream, input.mode, exit, code, input.argv, basePath, pathService);
1304
- yield* stream.closeAndDrain;
1305
- return code;
1306
- }));
1307
- });
1308
1331
  function isObject(value) {
1309
1332
  return typeof value === "object" && value !== null && !Array.isArray(value);
1310
1333
  }
1311
1334
  function stringField(node, key) {
1312
1335
  const value = node[key];
1313
- return typeof value === "string" ? value : void 0;
1336
+ return (() => {
1337
+ if (typeof value === "string") return value;
1338
+ })();
1314
1339
  }
1315
1340
  function stringArrayField(node, key) {
1316
1341
  const value = node[key];
@@ -1359,7 +1384,10 @@ const PRIMITIVE_KIND = {
1359
1384
  };
1360
1385
  function kindOf(primitive) {
1361
1386
  const tag = stringField(primitive, "_tag");
1362
- return tag === void 0 ? "unknown" : PRIMITIVE_KIND[tag] ?? tag;
1387
+ return (() => {
1388
+ if (tag === void 0) return "unknown";
1389
+ return PRIMITIVE_KIND[tag] ?? tag;
1390
+ })();
1363
1391
  }
1364
1392
  function choiceValues(primitive) {
1365
1393
  const keys = primitive["choiceKeys"];
@@ -1368,8 +1396,13 @@ function choiceValues(primitive) {
1368
1396
  for (const key of keys) if (typeof key === "string") values.push(key);
1369
1397
  return values;
1370
1398
  }
1371
- /** The allowed reporter names, read from the U9 registry — the same list the plugin loader accepts. */
1372
- const REPORTER_NAMES = strykerPlugins.filter((plugin) => plugin.kind === PluginKind.Reporter).map((plugin) => plugin.name);
1399
+ const REPORTER_NAMES = [
1400
+ "clear-text",
1401
+ "progress",
1402
+ "html",
1403
+ "json",
1404
+ "progress-stream"
1405
+ ];
1373
1406
  /**
1374
1407
  * v4 option descriptions are stored as `Option.some(string)` on the compiled
1375
1408
  * `Single`; the walker unwraps the option.
@@ -1380,16 +1413,25 @@ function descriptionOf(single) {
1380
1413
  switch (tagOf(description)) {
1381
1414
  case "Some": {
1382
1415
  const value = description["value"];
1383
- return typeof value === "string" ? value : "";
1416
+ return (() => {
1417
+ if (typeof value === "string") return value;
1418
+ return "";
1419
+ })();
1384
1420
  }
1385
1421
  default: return "";
1386
1422
  }
1387
1423
  }
1388
1424
  function describeSingle(single, isOptional, out) {
1389
1425
  const name = stringField(single, "name") ?? "";
1390
- const primitive = isObject(single["primitiveType"]) ? single["primitiveType"] : {};
1426
+ const primitive = (() => {
1427
+ if (isObject(single["primitiveType"])) return single["primitiveType"];
1428
+ return {};
1429
+ })();
1391
1430
  const kind = kindOf(primitive);
1392
- const choices = name === "reporters" ? REPORTER_NAMES : kind === "choice" ? choiceValues(primitive) : void 0;
1431
+ const choices = (() => {
1432
+ if (name === "reporters") return REPORTER_NAMES;
1433
+ if (kind === "choice") return choiceValues(primitive);
1434
+ })();
1393
1435
  const description = descriptionOf(single);
1394
1436
  const required = kind !== "boolean" && !isOptional;
1395
1437
  const described = {
@@ -1397,7 +1439,10 @@ function describeSingle(single, isOptional, out) {
1397
1439
  aliases: stringArrayField(single, "aliases"),
1398
1440
  kind,
1399
1441
  required,
1400
- ...choices !== void 0 ? { choices } : {},
1442
+ ...(() => {
1443
+ if (choices !== void 0) return { choices };
1444
+ return {};
1445
+ })(),
1401
1446
  description
1402
1447
  };
1403
1448
  if (single["kind"] === "argument") {
@@ -1416,7 +1461,9 @@ function walkConfigNode(node, orderedParams, out) {
1416
1461
  switch (tagOf(node)) {
1417
1462
  case "Param": {
1418
1463
  const index = node["index"];
1419
- const param = typeof index === "number" ? orderedParams[index] : void 0;
1464
+ const param = (() => {
1465
+ if (typeof index === "number") return orderedParams[index];
1466
+ })();
1420
1467
  if (param !== void 0) walkParam(param, false, out);
1421
1468
  return;
1422
1469
  }
@@ -1440,7 +1487,9 @@ function describeCommandNode(node) {
1440
1487
  };
1441
1488
  const config = node["config"];
1442
1489
  if (isObject(config) && isObject(config["tree"])) {
1443
- const orderedParams = Array.isArray(config["orderedParams"]) ? config["orderedParams"] : [];
1490
+ let orderedParams = [];
1491
+ const maybeOrdered = config["orderedParams"];
1492
+ if (Array.isArray(maybeOrdered)) orderedParams = maybeOrdered;
1444
1493
  walkConfigTree(config["tree"], orderedParams, out);
1445
1494
  }
1446
1495
  const subcommands = [];
@@ -1454,17 +1503,28 @@ function describeCommandNode(node) {
1454
1503
  }
1455
1504
  return {
1456
1505
  name: stringField(node, "name") ?? "",
1457
- description: typeof node["description"] === "string" ? node["description"] : "",
1506
+ description: (() => {
1507
+ if (typeof node["description"] === "string") return node["description"];
1508
+ return "";
1509
+ })(),
1458
1510
  options: out.flags,
1459
1511
  args: out.args,
1460
1512
  subcommands
1461
1513
  };
1462
1514
  }
1463
- /**
1464
- * Builds the manifest document for a command, walking its compiled form (the
1465
- * same structure the parser matches against). `version` is the tool version,
1466
- * passed in so this module stays free of package state.
1467
- */
1515
+ function readCoreEntries() {
1516
+ try {
1517
+ const manifestPath = createRequire(import.meta.url).resolve("@systemfsoftware/stryker-js-platform-node/package.json");
1518
+ const raw = readFileSync(manifestPath, "utf-8");
1519
+ const parsed = JSON.parse(raw);
1520
+ const ExportsSchema = S.Struct({ exports: S.Record(S.String, S.Unknown) });
1521
+ const decoded = S.decodeUnknownResult(ExportsSchema)(parsed);
1522
+ if (Result.isSuccess(decoded)) return Object.keys(decoded.success.exports).filter((key) => key !== "./package.json");
1523
+ return [];
1524
+ } catch {
1525
+ return [];
1526
+ }
1527
+ }
1468
1528
  function buildLLMSManifest(command, version) {
1469
1529
  const root = describeCommandNode(command) ?? {
1470
1530
  name: "",
@@ -1477,15 +1537,14 @@ function buildLLMSManifest(command, version) {
1477
1537
  schemaVersion: "1.0",
1478
1538
  tool: root.name,
1479
1539
  version,
1480
- commands: [root]
1540
+ commands: [root],
1541
+ entries: readCoreEntries()
1481
1542
  };
1482
1543
  }
1483
1544
  /** The manifest as one JSON document, ready for stdout — the U4 convention. */
1484
1545
  function emitLLMSManifest(command, version) {
1485
1546
  return JSON.stringify(buildLLMSManifest(command, version));
1486
1547
  }
1487
- //#endregion
1488
- //#region src/stryker-cli.ts
1489
1548
  function createSplitter(separator) {
1490
1549
  return (value) => value.split(separator).filter(Boolean);
1491
1550
  }
@@ -1498,7 +1557,10 @@ const splitOnSpace = createSplitter(" ");
1498
1557
  */
1499
1558
  function parseCleanDirOption(value) {
1500
1559
  const v = value.toLocaleLowerCase();
1501
- return v === "always" ? v : v !== "false" && v !== "0";
1560
+ return (() => {
1561
+ if (v === "always") return v;
1562
+ return v !== "false" && v !== "0";
1563
+ })();
1502
1564
  }
1503
1565
  /**
1504
1566
  * Commander characterization: a pure integer is parsed as a number, anything
@@ -1516,31 +1578,12 @@ const optional = (option) => Flag.optional(option);
1516
1578
  * `Option.some(false)` for an explicit `--no-x`, so both map back to
1517
1579
  * `undefined` and leave the config-file default in force (KTD4).
1518
1580
  */
1519
- const absentWhenFalse = (value) => Option.isSome(value) && value.value ? true : void 0;
1520
- const LOG_LEVELS = [
1521
- "fatal",
1522
- "error",
1523
- "warn",
1524
- "info",
1525
- "debug",
1526
- "trace",
1527
- "off"
1528
- ];
1529
- const LOG_LEVEL_LOOKUP = {
1530
- fatal: true,
1531
- error: true,
1532
- warn: true,
1533
- info: true,
1534
- debug: true,
1535
- trace: true,
1536
- off: true
1581
+ const absentWhenFalse = (value) => {
1582
+ if (Option.isSome(value) && value.value) return true;
1537
1583
  };
1538
- function isLogLevel(value) {
1539
- return LOG_LEVEL_LOOKUP[value] === true;
1540
- }
1541
1584
  function setLogLevel(target, key, value) {
1542
1585
  const unwrapped = unwrap(value);
1543
- if (unwrapped !== void 0 && isLogLevel(unwrapped)) target[key] = unwrapped;
1586
+ if (unwrapped !== void 0) target[key] = unwrapped;
1544
1587
  }
1545
1588
  const runOptions = {
1546
1589
  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),
@@ -1548,6 +1591,7 @@ const runOptions = {
1548
1591
  incremental: Flag.map(optional(Flag.boolean("incremental")), absentWhenFalse).pipe(Flag.withDescription("Enable 'incremental mode'. Stryker will store results in a file and use that file to speed up the next --incremental run")),
1549
1592
  allowEmpty: Flag.map(optional(Flag.boolean("allowEmpty")), absentWhenFalse).pipe(Flag.withDescription("Allows stryker to exit without any errors in cases where no tests are found")),
1550
1593
  incrementalFile: Flag.string("incrementalFile").pipe(Flag.withDescription("Specify the file to use for incremental mode."), optional),
1594
+ progressStreamFile: Flag.string("progressStreamFile").pipe(Flag.withDescription("Specify the file for the machine-mode progress stream."), optional),
1551
1595
  force: Flag.map(optional(Flag.boolean("force")), absentWhenFalse).pipe(Flag.withDescription("Run all mutants, even if --incremental is provided and an incremental file exists. Can be used to force a rebuild of the incremental file.")),
1552
1596
  mutate: Flag.string("mutate").pipe(Flag.withAlias("m"), Flag.withDescription("With `mutate` you configure the subset of files or just one specific file to be mutated. These should be your _production code files_, and definitely not your test files. (Whereas with `ignorePatterns` you prevent non-relevant files from being copied to the sandbox directory in the first place)\nThe default will try to guess your production code files based on sane defaults. It reads like this:\n- Include all js-like files inside the `src` or `lib` dir\n- Except files inside `__tests__` directories and file names ending with `test` or `spec`.\nIf the defaults are not sufficient for you, for example in a angular project you might want to **exclude** not only the `*.spec.ts` files but other files too, just like the default already does.\nIt is possible to override the defaults by: - supplying one or more [glob patterns](https://github.com/isaacs/minimatch) to include (e.g. `src/**/*.js`) - or one or more comma separated glob patterns preceded with `!` to exclude (e.g. `!src/**/*.spec.js`) - or both (e.g. `src/**/*.js,!src/**/*.spec.js`).\nNote: Stryker will use [minimatch](https://github.com/isaacs/minimatch) for parsing these patterns, see minimatch for the exact syntax."), Flag.map(splitOnComma), optional),
1553
1597
  testFiles: Flag.string("testFiles").pipe(Flag.withAlias("t"), Flag.withDescription("With `testFiles` you can limit which test files are executed during mutation testing. When specified, only tests from these files will be run. This allows you to verify that a module's dedicated unit tests can kill all its mutants independently."), Flag.map(splitOnComma), optional),
@@ -1572,8 +1616,24 @@ const runOptions = {
1572
1616
  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),
1573
1617
  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.")),
1574
1618
  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),
1575
- 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 "${RENDERED_OPTION_DEFAULTS.logLevel}"`), optional),
1576
- 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 "${RENDERED_OPTION_DEFAULTS.fileLogLevel}"`), optional),
1619
+ logLevel: Flag.choice("logLevel", [
1620
+ "fatal",
1621
+ "error",
1622
+ "warn",
1623
+ "info",
1624
+ "debug",
1625
+ "trace",
1626
+ "off"
1627
+ ]).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),
1628
+ fileLogLevel: Flag.choice("fileLogLevel", [
1629
+ "fatal",
1630
+ "error",
1631
+ "warn",
1632
+ "info",
1633
+ "debug",
1634
+ "trace",
1635
+ "off"
1636
+ ]).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),
1577
1637
  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.")),
1578
1638
  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),
1579
1639
  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),
@@ -1627,6 +1687,7 @@ function makeStrykerCommand(requestRef) {
1627
1687
  setIfPresent(options, "incremental", config.incremental);
1628
1688
  setIfPresent(options, "allowEmpty", config.allowEmpty);
1629
1689
  setIfPresent(options, "incrementalFile", config.incrementalFile);
1690
+ setIfPresent(options, "progressStreamFile", config.progressStreamFile);
1630
1691
  setIfPresent(options, "force", config.force);
1631
1692
  setIfPresent(options, "mutate", config.mutate);
1632
1693
  setIfPresent(options, "testFiles", config.testFiles);
@@ -1658,7 +1719,7 @@ function makeStrykerCommand(requestRef) {
1658
1719
  const strykerCommand = Command.make("stryker", rootConfig, (config) => {
1659
1720
  if (config.llms === true) {
1660
1721
  const document = {
1661
- kind: "manifest",
1722
+ _tag: "manifest",
1662
1723
  schemaVersion: "1.0",
1663
1724
  code: 0,
1664
1725
  manifest: emitLLMSManifest(strykerCommand, strykerVersion)
@@ -1706,21 +1767,18 @@ const cliLayer = Layer.mergeAll(CliConfig.layer({ builtIns: [
1706
1767
  GlobalFlag.Wizard,
1707
1768
  GlobalFlag.Completions,
1708
1769
  GlobalFlag.LogLevel
1709
- ] }), Path.layer, FileSystem.layerNoop({}), terminalLayer, NodeStdio.layer, NodeChildProcessSpawner.layer.pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath$1.layer))));
1710
- /**
1711
- * The transport entry: builds the command tree, resolves the mode once at
1712
- * the edge (never a second probe), provides the CLI and Console layers the
1713
- * framework renders through, delegates the whole run to the executor cell and
1714
- * returns the classed exit code it computes. The executor is the I/O
1715
- * sandwich; this function only frames it.
1716
- */
1770
+ ] }), Path.layer, FileSystem.layerNoop({}), terminalLayer, NodeStdio.layer, NodeChildProcessSpawner.layer.pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))));
1717
1771
  function strykerCliEffect(argv, runMutationTest, detectMode, createRunEventStream, lastSignal) {
1718
1772
  return Effect.gen(function* () {
1719
- const mode = detectMode();
1773
+ const mode = yield* detectMode;
1720
1774
  const requestRef = yield* Ref.make(Option.none());
1721
1775
  const command = makeStrykerCommand(requestRef);
1722
- const cliEffect = Command.runWith(command, { version: strykerVersion })(argv).pipe(Effect.provide(Layer.mergeAll(mode.mode === "machine" ? machineConsoleLayer : Layer.empty, cliLayer)));
1723
- const outcome = yield* Effect.result(runStrykerCli({
1776
+ const consoleLayer = (() => {
1777
+ if (mode.mode === "machine") return machineConsoleLayer;
1778
+ return Layer.empty;
1779
+ })();
1780
+ const cliEffect = Command.runWith(command, { version: strykerVersion })(argv).pipe(Effect.provide(Layer.mergeAll(consoleLayer, cliLayer)));
1781
+ const result = yield* Effect.result(runStrykerCli({
1724
1782
  program: cliEffect,
1725
1783
  requestRef,
1726
1784
  mode,
@@ -1728,18 +1786,97 @@ function strykerCliEffect(argv, runMutationTest, detectMode, createRunEventStrea
1728
1786
  argv,
1729
1787
  lastSignal
1730
1788
  }, createRunEventStream));
1731
- return Result.isFailure(outcome) ? outcome.failure : outcome.success;
1732
- });
1789
+ if (Result.isFailure(result)) return result.failure;
1790
+ return result.success;
1791
+ }).pipe(Effect.orElseSucceed(() => 2));
1733
1792
  }
1793
+ const defaultRunMutationTest = (hostOptions, queue) => (options) => Effect.scoped(runMutationTest(options)).pipe(Effect.provideService(RunEvents, queue), Effect.provide(makeRunLayer(hostOptions)));
1794
+ function hostOptionsOf(mode, stream) {
1795
+ return {
1796
+ runId: stream.runId,
1797
+ resolvedMode: mode,
1798
+ runStartedAt: stream.startedAt,
1799
+ basePath: resolve(process.cwd()),
1800
+ reporterPluginModules: [import.meta.resolve("@systemfsoftware/stryker-js-html-reporter"), import.meta.resolve("@systemfsoftware/stryker-js-platform-node/builtin-reporters")],
1801
+ allowConsoleColors: isColorEnabled(mode, process.env["NO_COLOR"])
1802
+ };
1803
+ }
1804
+ const runStrykerCli = (input, createRunEventStream) => Effect.gen(function* () {
1805
+ const stream = yield* createRunEventStream(input.mode);
1806
+ const hostOptions = hostOptionsOf(input.mode, stream);
1807
+ const runMutationTestImpl = input.runMutationTest ?? defaultRunMutationTest(hostOptions, stream.queue);
1808
+ const basePath = hostOptions.basePath;
1809
+ const pathService = yield* Path.Path.pipe(Effect.provide(NodePath.layer));
1810
+ let currentFiber = null;
1811
+ const onSignal = () => {
1812
+ process.removeListener("SIGINT", onSignal);
1813
+ process.removeListener("SIGTERM", onSignal);
1814
+ if (currentFiber !== null) currentFiber.interruptUnsafe(currentFiber.id);
1815
+ };
1816
+ const dispatch = (request) => Match.value(request).pipe(Match.tag("run", (runRequest) => (() => {
1817
+ if (runRequest.survivors) return runSurvivorsAdmission(runMutationTestImpl, stream, input.mode, runRequest.options, basePath).pipe(Effect.provide(makeRunLayer(hostOptions)));
1818
+ return runMutationTestImpl(runRequest.options).pipe(Effect.orDie);
1819
+ })()), Match.tag("llms", (llmsRequest) => Effect.gen(function* () {
1820
+ stream.ensureOpen({
1821
+ mode: "machine",
1822
+ signal: "flag",
1823
+ stdoutIsTTY: process.stdout.isTTY === true
1824
+ });
1825
+ yield* Queue.offer(stream.queue, ManifestRendered.make({
1826
+ schemaVersion: "1.0",
1827
+ code: 0,
1828
+ manifest: llmsRequest.document.manifest
1829
+ }));
1830
+ })), Match.orElse(() => Effect.die("unreachable cli request variant")));
1831
+ const program = Effect.acquireUseRelease(Effect.sync(() => {
1832
+ currentFiber = Fiber.getCurrent() ?? null;
1833
+ process.on("SIGINT", onSignal);
1834
+ process.on("SIGTERM", onSignal);
1835
+ }), () => Effect.gen(function* () {
1836
+ const parsed = yield* Effect.result(input.program);
1837
+ const request = yield* Ref.get(input.requestRef);
1838
+ const fileName = Option.match(request, {
1839
+ onNone: () => DEFAULT_PROGRESS_STREAM_FILE,
1840
+ onSome: (cliRequest) => Match.value(cliRequest).pipe(Match.tag("run", (runRequest) => {
1841
+ const fromCli = runRequest.options["progressStreamFile"];
1842
+ if (typeof fromCli === "string" && fromCli.length > 0) return fromCli;
1843
+ return DEFAULT_PROGRESS_STREAM_FILE;
1844
+ }), Match.orElse(() => DEFAULT_PROGRESS_STREAM_FILE))
1845
+ });
1846
+ if (stream.setProgressStreamFile !== void 0) yield* stream.setProgressStreamFile(fileName);
1847
+ yield* stream.open;
1848
+ if (Result.isFailure(parsed)) return yield* Effect.fail(parsed.failure);
1849
+ return yield* Option.match(request, {
1850
+ onNone: () => Effect.void,
1851
+ onSome: (cliRequest) => dispatch(cliRequest)
1852
+ });
1853
+ }), () => Effect.sync(() => {
1854
+ process.removeListener("SIGINT", onSignal);
1855
+ process.removeListener("SIGTERM", onSignal);
1856
+ }));
1857
+ return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
1858
+ const outcome = classifyRunOutcome(yield* Effect.exit(restore(program)), input.lastSignal(), input.argv);
1859
+ const code = runOutcomeCode(outcome);
1860
+ if (input.mode.mode === "machine") yield* emitMachineModeOutput(stream, input.mode, outcome, basePath, pathService);
1861
+ yield* stream.closeAndDrain;
1862
+ return code;
1863
+ }));
1864
+ });
1734
1865
  //#endregion
1735
1866
  //#region src/main.ts
1736
1867
  const EXIT_CODE_RUN_NEVER_REACHED_ITS_FINALIZER = 1;
1737
1868
  process.title = "stryker";
1738
1869
  const lastSignal = observeTerminatingSignal();
1739
1870
  function isSupportedNodeVersion(version) {
1740
- const withoutV = version.startsWith("v") ? version.slice(1) : version;
1741
- const dashBase = withoutV.split("-")[0] ?? withoutV;
1742
- const parts = (dashBase.split("+")[0] ?? dashBase).split(".").map((p) => Number.parseInt(p, 10));
1871
+ let withoutV = version;
1872
+ if (version.startsWith("v")) withoutV = version.slice(1);
1873
+ const dashBaseRaw = withoutV.split("-")[0];
1874
+ let dashBase = withoutV;
1875
+ if (dashBaseRaw !== void 0) dashBase = dashBaseRaw;
1876
+ const baseRaw = dashBase.split("+")[0];
1877
+ let base = dashBase;
1878
+ if (baseRaw !== void 0) base = baseRaw;
1879
+ const parts = base.split(".").map((p) => Number.parseInt(p, 10));
1743
1880
  const major = parts[0] ?? 0;
1744
1881
  const minor = parts[1] ?? 0;
1745
1882
  const patch = parts[2] ?? 0;
@@ -1753,7 +1890,7 @@ const program = Effect.gen(function* () {
1753
1890
  const outputMode = yield* OutputModeProbe;
1754
1891
  const runEvents = yield* RunEventStreamPort;
1755
1892
  return yield* strykerCliEffect(process.argv.slice(2), void 0, outputMode.detectMode, runEvents.createRunEventStream, lastSignal);
1756
- }).pipe(Effect.provideService(Logger.LogToStderr, true), Effect.provide(Layer.merge(OutputModeProbeLive, RunEventStreamLive).pipe(Layer.provide(NodeStdio.layer))));
1893
+ }).pipe(Effect.provideService(Logger.LogToStderr, true), Effect.provide(Layer.merge(OutputModeProbeLive, RunEventStreamFileLive).pipe(Layer.provide(Layer.mergeAll(NodeStdio.layer, NodeFileSystem.layer, NodePath.layer)))));
1757
1894
  NodeRuntime.runMain(program, {
1758
1895
  disableErrorReporting: true,
1759
1896
  teardown: (exit, onExit) => {
@@ -1762,7 +1899,8 @@ NodeRuntime.runMain(program, {
1762
1899
  return;
1763
1900
  }
1764
1901
  const signal = lastSignal();
1765
- onExit(signal === null ? EXIT_CODE_RUN_NEVER_REACHED_ITS_FINALIZER : 128 + signal);
1902
+ if (signal === null) onExit(EXIT_CODE_RUN_NEVER_REACHED_ITS_FINALIZER);
1903
+ else onExit(128 + signal);
1766
1904
  }
1767
1905
  });
1768
1906
  //#endregion