@systemfsoftware/stryker-js-cli 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/main.mjs +957 -834
  3. package/package.json +13 -14
package/dist/main.mjs CHANGED
@@ -2,9 +2,10 @@
2
2
  import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
3
3
  import * as NodeStdio from "@effect/platform-node/NodeStdio";
4
4
  import * as Effect from "effect/Effect";
5
+ import * as Exit from "effect/Exit";
5
6
  import * as Layer from "effect/Layer";
6
- import semver from "semver";
7
- import { strykerEngines, strykerVersion } from "@systemfsoftware/stryker-js-mutation-run/stryker-package";
7
+ 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";
8
9
  import * as Context from "effect/Context";
9
10
  import * as Result from "effect/Result";
10
11
  import * as CliError from "effect/unstable/cli/CliError";
@@ -15,7 +16,10 @@ import * as Fiber from "effect/Fiber";
15
16
  import * as Queue from "effect/Queue";
16
17
  import * as Stdio from "effect/Stdio";
17
18
  import * as Stream from "effect/Stream";
18
- import { buildVerdictEnvelope, generateRunId } from "@systemfsoftware/stryker-js-mutation-run/verdict-envelope";
19
+ 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";
19
23
  import * as Console from "effect/Console";
20
24
  import * as FileSystem from "effect/FileSystem";
21
25
  import * as Option from "effect/Option";
@@ -27,28 +31,21 @@ import * as CliConfig from "effect/unstable/cli/CliConfig";
27
31
  import * as Command from "effect/unstable/cli/Command";
28
32
  import * as Flag from "effect/unstable/cli/Flag";
29
33
  import * as GlobalFlag from "effect/unstable/cli/GlobalFlag";
30
- import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
31
- import { ConfigReader, OptionsValidator, defaultOptions } from "@systemfsoftware/stryker-js-mutation-run/config/config-resolution";
32
- import { PluginKind } from "@systemfsoftware/stryker-js-plugin-api/plugin";
33
- import { strykerPlugins } from "@systemfsoftware/stryker-js-mutation-report/stryker-plugins";
34
- import { createHash } from "node:crypto";
35
- import { readFileSync } from "node:fs";
36
34
  import { resolve } from "node:path";
37
- import { Cell, Workflow } from "@systemfsoftware/effect-cell-types";
38
- import "@systemfsoftware/stryker-js-plugin-api/core";
39
- import { noopLogger } from "@systemfsoftware/stryker-js-util";
40
- import * as Exit from "effect/Exit";
41
- import { pipe } from "effect/Function";
35
+ import { NodePath } from "@effect/platform-node";
42
36
  import * as Match from "effect/Match";
37
+ import * as Predicate from "effect/Predicate";
43
38
  import * as S from "effect/Schema";
44
- import { Stryker } from "@systemfsoftware/stryker-js-mutation-run";
45
- import { forkCoreSchema } from "@systemfsoftware/stryker-js-mutation-run/config/fork-schema";
46
- import { ConfigError, retrieveCause } from "@systemfsoftware/stryker-js-mutation-run/errors";
47
- import { ExitClass, getPendingExitClasses, resolveExitCode } from "@systemfsoftware/stryker-js-mutation-run/exit-classification";
39
+ import { Cell, Workflow } from "@systemfsoftware/effect-cell-types";
48
40
  import { performance } from "node:perf_hooks";
49
41
  import { format, inspect } from "node:util";
50
- import { toRelativeNormalizedFileName } from "@systemfsoftware/stryker-js-mutation-run/mutants/incremental-differ";
51
- //#region src/OutputMode.ts
42
+ import { createHash } from "node:crypto";
43
+ import { readFileSync } from "node:fs";
44
+ import { noopLogger } from "@systemfsoftware/stryker-js-plugin-api/logging";
45
+ 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
52
49
  /**
53
50
  * The known tool variables. Narrow per the plan — exactly
54
51
  * `['CLAUDECODE', 'CODEX_SANDBOX']` — and load-bearing rather than a
@@ -109,15 +106,6 @@ function resolveMode(input) {
109
106
  });
110
107
  }
111
108
  /**
112
- * The progress bar's gate. Human mode on a non-TTY stdout (AE1) must not leak
113
- * its control sequences into a pipe, and machine mode keeps stdout clean for
114
- * the verdict envelope (R5). Decided from the resolved mode's own detection
115
- * data — never a second `isTTY` probe.
116
- */
117
- function isProgressEnabled(resolved) {
118
- return resolved.mode === "human" && resolved.stdoutIsTTY;
119
- }
120
- /**
121
109
  * The log colouriser's gate (R8). Machine mode never emits colour, so a
122
110
  * harness merging `2>&1` is not handed escape sequences it must strip, and
123
111
  * `NO_COLOR` is honoured for the human path per the convention: any value
@@ -127,8 +115,8 @@ function isColorEnabled(resolved, noColor) {
127
115
  return resolved.mode === "human" && (noColor === void 0 || noColor.length === 0);
128
116
  }
129
117
  //#endregion
130
- //#region src/OutputModeAdapter.ts
131
- var OutputModeProbeTag = class extends Context.Service()("@systemfsoftware/stryker-js-cli/OutputModeAdapter/OutputModeProbeTag") {};
118
+ //#region src/output-mode-probe.ts
119
+ var OutputModeProbeTag = class extends Context.Service()("@systemfsoftware/stryker-js-cli/output-mode-probe/OutputModeProbeTag") {};
132
120
  const OutputModeProbe = OutputModeProbeTag;
133
121
  const OutputModeProbeLive = Layer.succeed(OutputModeProbe, OutputModeProbe.of({ detectMode: () => {
134
122
  const envMode = process.env["STRYKER_MODE"];
@@ -141,7 +129,7 @@ const OutputModeProbeLive = Layer.succeed(OutputModeProbe, OutputModeProbe.of({
141
129
  }));
142
130
  } }));
143
131
  //#endregion
144
- //#region src/StreamProtocol.ts
132
+ //#region src/stream-protocol.ts
145
133
  /**
146
134
  * The heartbeat interval (R19), matching Terraform's `apply_progress`
147
135
  * cadence: long enough that a slow phase is not noisy, short enough that a
@@ -149,23 +137,24 @@ const OutputModeProbeLive = Layer.succeed(OutputModeProbe, OutputModeProbe.of({
149
137
  */
150
138
  const TICK_INTERVAL_MS = 1e4;
151
139
  //#endregion
152
- //#region src/RunEventStreamAdapter.ts
153
- var RunEventStreamPortTag = class extends Context.Service()("@systemfsoftware/stryker-js-cli/RunEventStreamAdapter/RunEventStreamPortTag") {};
140
+ //#region src/run-event-stream.ts
141
+ var RunEventStreamPortTag = class extends Context.Service()("@systemfsoftware/stryker-js-cli/run-event-stream/RunEventStreamPortTag") {};
154
142
  const RunEventStreamPort = RunEventStreamPortTag;
155
143
  const isTerminalEvent = (event) => event.kind === "verdict" || event.kind === "error" || event.kind === "help" || event.kind === "manifest";
156
144
  /**
157
145
  * The push adapter from the run's synchronous sink to the callback mailbox.
158
146
  * `Queue.offer`/`Queue.end` on an unbounded queue never block or fail, so the
159
- * sync sink can drive them with `Effect.runSync`. Defined at module scope,
160
- * outside any Effect expression.
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.
161
150
  */
162
- function queueEmit(queue) {
151
+ function queueEmit(queue, ctx) {
163
152
  return {
164
153
  single: (event) => {
165
- Effect.runSync(Queue.offer(queue, event));
154
+ Effect.runSyncWith(ctx)(Queue.offer(queue, event));
166
155
  },
167
156
  end: () => {
168
- Effect.runSync(Queue.end(queue));
157
+ Effect.runSyncWith(ctx)(Queue.end(queue));
169
158
  }
170
159
  };
171
160
  }
@@ -190,6 +179,7 @@ const drainOf = (stdio, framed) => Stream.run(framed, stdio.stdout({ endOnDone:
190
179
  const makeRunEventStream = (stdio, resolved) => Effect.gen(function* () {
191
180
  const runId = generateRunId();
192
181
  const startedAt = yield* Clock.currentTimeMillis;
182
+ const ctx = yield* Effect.context();
193
183
  const state = {
194
184
  mode: resolved.mode,
195
185
  signal: resolved.signal,
@@ -203,7 +193,7 @@ const makeRunEventStream = (stdio, resolved) => Effect.gen(function* () {
203
193
  };
204
194
  const registered = yield* Deferred.make();
205
195
  const eventStream = Stream.callback((queue) => Effect.sync(() => {
206
- state.emit = queueEmit(queue);
196
+ state.emit = queueEmit(queue, ctx);
207
197
  }).pipe(Effect.andThen(Deferred.succeed(registered, void 0))));
208
198
  const tickStream = Stream.tick(TICK_INTERVAL_MS).pipe(Stream.filter(() => state.mode === "machine" && state.headerWritten && !state.terminalWritten), Stream.mapEffect(() => Effect.gen(function* () {
209
199
  return {
@@ -285,187 +275,531 @@ const makeRunEventStream = (stdio, resolved) => Effect.gen(function* () {
285
275
  };
286
276
  });
287
277
  const RunEventStreamLive = Layer.effect(RunEventStreamPort, Effect.map(Stdio.Stdio, (stdio) => RunEventStreamPort.of({ createRunEventStream: (resolved) => makeRunEventStream(stdio, resolved) })));
288
- function isObject(value) {
289
- return typeof value === "object" && value !== null && !Array.isArray(value);
290
- }
291
- function stringField(node, key) {
292
- const value = node[key];
293
- return typeof value === "string" ? value : void 0;
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;
294
298
  }
295
- function stringArrayField(node, key) {
296
- const value = node[key];
297
- if (!Array.isArray(value)) return [];
298
- const strings = [];
299
- for (const item of value) if (typeof item === "string") strings.push(item);
300
- return strings;
299
+ //#endregion
300
+ //#region src/survivors-report.schema.ts
301
+ /**
302
+ * The mutant shape the admission carries, named once because both the decision's
303
+ * `Admitted` payload and the command's precomputed survivor list are the same shape.
304
+ */
305
+ const MutantShape = S.Struct({
306
+ id: S.String,
307
+ fileName: S.String,
308
+ mutatorName: S.String,
309
+ replacement: S.String,
310
+ location: S.Struct({
311
+ start: S.Struct({
312
+ line: S.Finite,
313
+ column: S.Finite
314
+ }),
315
+ end: S.Struct({
316
+ line: S.Finite,
317
+ column: S.Finite
318
+ })
319
+ })
320
+ });
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
+ const PriorReportDocument = S.Struct({
331
+ config: S.optional(S.Record(S.String, S.Unknown)),
332
+ framework: S.optional(S.Struct({ version: S.optional(S.String) })),
333
+ files: S.Record(S.String, S.Struct({
334
+ source: S.String,
335
+ mutants: S.Array(S.Struct({
336
+ id: S.String,
337
+ mutatorName: S.String,
338
+ replacement: S.optional(S.String),
339
+ status: S.String,
340
+ location: S.Struct({
341
+ start: S.Struct({
342
+ line: S.Finite,
343
+ column: S.Finite
344
+ }),
345
+ end: S.Struct({
346
+ line: S.Finite,
347
+ column: S.Finite
348
+ })
349
+ })
350
+ }))
351
+ }))
352
+ });
353
+ //#endregion
354
+ //#region src/survivors-admission.workflow.ts
355
+ /**
356
+ * U8 — survivor re-run admission (R10, R11, KTD6, KTD7).
357
+ *
358
+ * The `--survivors` run re-tests exactly the mutants that survived a previous
359
+ * run. Its input is the previous run's mutation report, and the run is
360
+ * admitted only when a single structural hash of the resolved options, the
361
+ * recorded framework version, and the per-file source content all match the
362
+ * current run (KTD6).
363
+ */
364
+ /**
365
+ * The decision's helpers reach three language built-ins the purity gate cannot
366
+ * resolve as globals, so each is bound at module scope.
367
+ *
368
+ * These live here, beside the decision, because `make-body-purity` follows the
369
+ * decision's reachable set: a helper `admissionVerdict` calls is checked as part
370
+ * of the body even though it is declared outside it. That is why the properties
371
+ * covering them are in this file's in-source block rather than beside a pure helper -
372
+ * testing a copy the decision does not run is worse than not testing it, because
373
+ * the suite goes green either way.
374
+ */
375
+ const isArray = Array.isArray;
376
+ const { fromEntries: objectFromEntries$1, keys: objectKeys } = Object;
377
+ const stringify = JSON.stringify;
378
+ function isRecord(value) {
379
+ return typeof value === "object" && value !== null && !isArray(value);
301
380
  }
302
- /** The compiled shapes discriminate on `_tag`; read it once, off the record. */
303
- function tagOf(node) {
304
- return node["_tag"];
381
+ const SURVIVORS_RUN_FIRST_REMEDIATION = "run a full `stryker run` first, then re-run with --survivors";
382
+ const SURVIVORS_BOOKKEEPING_KEYS = ["survivorsPriorReport"];
383
+ /**
384
+ * The resolved options without the survivors-run bookkeeping keys, so both
385
+ * sides of the admission comparison describe the same configuration.
386
+ */
387
+ function stripSurvivorsKeys(config) {
388
+ if (!isRecord(config)) return {};
389
+ const rest = { ...config };
390
+ for (const key of SURVIVORS_BOOKKEEPING_KEYS) delete rest[key];
391
+ return rest;
305
392
  }
306
- function walkParam(param, isOptional, out) {
307
- if (!isObject(param)) return;
308
- switch (tagOf(param)) {
309
- case "Single":
310
- describeSingle(param, isOptional, out);
311
- return;
312
- case "Map":
313
- case "Transform":
314
- walkParam(param["param"], isOptional, out);
315
- return;
316
- case "Optional":
317
- walkParam(param["param"], true, out);
318
- return;
319
- case "Variadic":
320
- walkParam(param["param"], isOptional, out);
321
- return;
322
- default: return;
323
- }
393
+ /**
394
+ * A report written by a survivors run embeds the bookkeeping key in its
395
+ * `config`. Such a report is never a valid input for another survivors run
396
+ * (KTD7): without this check the second run would either re-read a shrunken set
397
+ * or re-test a stale one.
398
+ */
399
+ function wasProducedBySurvivorsRun(priorReport) {
400
+ const config = priorReport.config;
401
+ return isRecord(config) && "survivorsPriorReport" in config;
324
402
  }
325
- const PRIMITIVE_KIND = {
326
- Boolean: "boolean",
327
- Choice: "choice",
328
- Date: "date",
329
- FileParse: "file",
330
- FileSchema: "file",
331
- FileText: "file",
332
- Float: "float",
333
- Integer: "integer",
334
- KeyValuePair: "key=value",
335
- None: "none",
336
- Path: "path",
337
- Redacted: "redacted",
338
- String: "text"
339
- };
340
- function kindOf(primitive) {
341
- const tag = stringField(primitive, "_tag");
342
- return tag === void 0 ? "unknown" : PRIMITIVE_KIND[tag] ?? tag;
403
+ /**
404
+ * Serializes the comparison input with keys sorted at every level, so the result
405
+ * is a function of the data and not of key insertion order.
406
+ */
407
+ function serializeSurvivorsHashInput(input) {
408
+ return stringify(sortKeys(input));
343
409
  }
344
- function choiceValues(primitive) {
345
- const keys = primitive["choiceKeys"];
346
- if (!Array.isArray(keys)) return;
347
- const values = [];
348
- for (const key of keys) if (typeof key === "string") values.push(key);
349
- return values;
410
+ function sortKeys(value) {
411
+ if (isArray(value)) return value.map(sortKeys);
412
+ if (isRecord(value)) return objectFromEntries$1(objectKeys(value).sort().map((key) => [key, sortKeys(value[key])]));
413
+ return value;
350
414
  }
351
- /** The allowed reporter names, read from the U9 registry — the same list the plugin loader accepts. */
352
- const REPORTER_NAMES = strykerPlugins.filter((plugin) => plugin.kind === PluginKind.Reporter).map((plugin) => plugin.name);
353
415
  /**
354
- * v4 option descriptions are stored as `Option.some(string)` on the compiled
355
- * `Single`; the walker unwraps the option.
416
+ * The prior report's facts the decision reads: its embedded configuration, which carries
417
+ * both the compared options and the survivors-run provenance marker, and the engine
418
+ * version it recorded. The report's files are not here — the survivors and the per-file
419
+ * source hashes derived from them need capabilities the command cannot hold, so they
420
+ * arrive already computed.
356
421
  */
357
- function descriptionOf(single) {
358
- const description = single["description"];
359
- if (!isObject(description)) return "";
360
- switch (tagOf(description)) {
361
- case "Some": {
362
- const value = description["value"];
363
- return typeof value === "string" ? value : "";
364
- }
365
- default: return "";
366
- }
422
+ var PriorReportFacts = class extends S.Class("PriorReportFacts")({
423
+ config: S.Record(S.String, S.Unknown),
424
+ frameworkVersion: S.UndefinedOr(S.String)
425
+ }) {};
426
+ /**
427
+ * The command of the admission workflow: a schema class, because `Workflow.make`
428
+ * constrains its first argument on the class value and a declared interface produces no
429
+ * value to pass. Every field is pure data — the two capabilities the previous shape
430
+ * carried, a digest function and a path resolver, can never be schema fields, so their
431
+ * results arrive precomputed from the decode phase instead.
432
+ */
433
+ var AdmitSurvivorsRunCommand = class extends S.Class("AdmitSurvivorsRunCommand")({
434
+ /**
435
+ * The prior run's report facts, `undefined` when no report exists — the run cannot be
436
+ * admitted without one ('no-report'). Explicitly nullable rather than key-optional: a
437
+ * missing report is a state the edge determined and states, not a key it forgot.
438
+ */
439
+ priorReport: S.UndefinedOr(PriorReportFacts),
440
+ /** The current run's resolved options (defaults + config file + CLI). */
441
+ currentConfig: S.Record(S.String, S.Unknown),
442
+ /** The current CLI/framework version (`strykerVersion`). */
443
+ frameworkVersion: S.String,
444
+ /**
445
+ * Per-file content hashes of the current source, keyed by the prior report's relative
446
+ * file keys. The prior side is hashed from the sources the report embeds, so an editor
447
+ * save that shifts line ranges — which would silently re-test a different mutant than
448
+ * the one that survived — is caught here.
449
+ */
450
+ sourceContentHashes: S.Record(S.String, S.String),
451
+ /** The same hashes for the sources the prior report embeds, computed at the edge. */
452
+ priorSourceHashes: S.Record(S.String, S.String),
453
+ /** The prior report's survivors, already converted to the internal mutant shape. */
454
+ priorSurvivors: S.Array(MutantShape)
455
+ }) {};
456
+ const NO_REPORT_DETAIL = "No prior mutation report found — a --survivors run needs the report of a previous run.";
457
+ const SURVIVORS_RUN_SOURCE_DETAIL = "The prior mutation report was itself produced by a --survivors run, so it is not a valid input for another one.";
458
+ const MISMATCH_DETAIL = "The prior mutation report does not match the current run (resolved options, framework version, or source content differ).";
459
+ /**
460
+ * Whether the admission inputs agree: the prior report's embedded resolved options,
461
+ * framework version and source content against the current run's.
462
+ *
463
+ * The comparison is on the canonical serializations rather than digests of them. Equal
464
+ * serializations are equal runs, so the digest was a lossy restatement of the check that
465
+ * also demanded a capability no command can carry.
466
+ */
467
+ function hashesMatch(priorReport, input) {
468
+ return serializeSurvivorsHashInput({
469
+ resolvedOptions: stripSurvivorsKeys(priorReport.config),
470
+ frameworkVersion: priorReport.frameworkVersion,
471
+ sourceContentHashes: input.priorSourceHashes
472
+ }) === serializeSurvivorsHashInput({
473
+ resolvedOptions: stripSurvivorsKeys(input.currentConfig),
474
+ frameworkVersion: input.frameworkVersion,
475
+ sourceContentHashes: input.sourceContentHashes
476
+ });
367
477
  }
368
- function describeSingle(single, isOptional, out) {
369
- const name = stringField(single, "name") ?? "";
370
- const primitive = isObject(single["primitiveType"]) ? single["primitiveType"] : {};
371
- const kind = kindOf(primitive);
372
- const choices = name === "reporters" ? REPORTER_NAMES : kind === "choice" ? choiceValues(primitive) : void 0;
373
- const description = descriptionOf(single);
374
- const required = kind !== "boolean" && !isOptional;
375
- const described = {
376
- name,
377
- aliases: stringArrayField(single, "aliases"),
378
- kind,
379
- required,
380
- ...choices !== void 0 ? { choices } : {},
381
- description
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
382
492
  };
383
- if (single["kind"] === "argument") {
384
- out.args.push({
385
- name,
386
- kind,
387
- required,
388
- description
389
- });
390
- return;
493
+ }
494
+ const SurvivorsAdmissionTypeId = Symbol.for("@systemfsoftware/stryker-js-cli/SurvivorsAdmission");
495
+ var Admitted = class extends S.TaggedClass()("Admitted", { survivors: S.Array(MutantShape) }) {
496
+ [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
497
+ };
498
+ var NoSurvivors = class extends S.TaggedClass()("NoSurvivors", {}) {
499
+ [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
500
+ };
501
+ S.Union([Admitted, NoSurvivors]);
502
+ var SurvivorsRejection = class extends S.TaggedError()("SurvivorsRejection", {
503
+ reason: S.Literals(["no-report", "mismatch"]),
504
+ remediation: S.String
505
+ }) {
506
+ [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
507
+ };
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;
527
+ }
528
+ /**
529
+ * The `exitClass` of a tagged error, when it carries one.
530
+ *
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`.
535
+ */
536
+ function exitClassOf(value) {
537
+ if (typeof value !== "object" || value === null) return;
538
+ if (!("exitClass" in value)) return;
539
+ const raw = Reflect.get(value, "exitClass");
540
+ if (typeof raw !== "number" || !isExitClass(raw)) return;
541
+ return raw;
542
+ }
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
+ function collectExitClassesFromValue(value, out, seen, depth) {
551
+ if (depth > 10 || value === null || value === void 0) return;
552
+ if (typeof value !== "object") return;
553
+ if (seen.has(value)) return;
554
+ seen.add(value);
555
+ const ec = exitClassOf(value);
556
+ if (ec !== void 0) out.push(ec);
557
+ if ("cause" in value) {
558
+ const causeVal = Reflect.get(value, "cause");
559
+ if (Array.isArray(causeVal)) for (const entry of causeVal) collectExitClassesFromValue(entry, out, seen, depth + 1);
560
+ else collectExitClassesFromValue(causeVal, out, seen, depth + 1);
391
561
  }
392
- out.flags.push(described);
393
562
  }
394
- function walkConfigNode(node, orderedParams, out) {
395
- if (!isObject(node)) return;
396
- switch (tagOf(node)) {
397
- case "Param": {
398
- const index = node["index"];
399
- const param = typeof index === "number" ? orderedParams[index] : void 0;
400
- if (param !== void 0) walkParam(param, false, out);
401
- return;
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
+ function collectExitClasses(exit) {
576
+ const out = [];
577
+ const seen = /* @__PURE__ */ new WeakSet();
578
+ 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;
580
+ if (candidate !== void 0) collectExitClassesFromValue(candidate, out, seen, 0);
581
+ }
582
+ return out;
583
+ }
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
+ function reasonOf(value) {
633
+ if (!("reason" in value)) return;
634
+ const reason = Reflect.get(value, "reason");
635
+ if (typeof reason !== "string" || reason.length === 0) return;
636
+ const detail = causeTextOf(value);
637
+ return detail === void 0 ? reason : `${reason}: ${detail}`;
638
+ }
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
+ function causeTextOf(value, depth = 0) {
649
+ if (depth > 4 || !("cause" in value)) return;
650
+ const cause = Reflect.get(value, "cause");
651
+ return causeText(cause, depth + 1);
652
+ }
653
+ 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
+ }
662
+ }
663
+ function shouldVisitConfigValue(value, depth, seen) {
664
+ if (depth > 10) return false;
665
+ if (value === null || value === void 0) return false;
666
+ if (typeof value !== "object") return false;
667
+ if (seen.has(value)) return false;
668
+ return true;
669
+ }
670
+ function pushConfigCauses(value, depth, stack) {
671
+ if (!("cause" in value)) return;
672
+ const causeVal = Reflect.get(value, "cause");
673
+ if (Array.isArray(causeVal)) for (let index = causeVal.length - 1; index >= 0; index--) stack.push({
674
+ value: causeVal[index],
675
+ depth: depth + 1
676
+ });
677
+ else stack.push({
678
+ value: causeVal,
679
+ depth: depth + 1
680
+ });
681
+ }
682
+ /**
683
+ * The first config-class error's detail in cause-chain order, for the
684
+ * config remediation.
685
+ */
686
+ function firstConfigErrorDetail(exit) {
687
+ if (!Exit.isFailure(exit)) return;
688
+ const seen = /* @__PURE__ */ new WeakSet();
689
+ const stack = [];
690
+ for (const reason of exit.cause.reasons) {
691
+ const candidate = Cause.isFailReason(reason) ? reason.error : Cause.isDieReason(reason) ? reason.defect : void 0;
692
+ if (candidate !== void 0) stack.push({
693
+ value: candidate,
694
+ depth: 0
695
+ });
696
+ }
697
+ while (stack.length > 0) {
698
+ const entry = stack.pop();
699
+ if (entry === void 0) continue;
700
+ const { value, depth } = entry;
701
+ if (!shouldVisitConfigValue(value, depth, seen)) continue;
702
+ seen.add(value);
703
+ if (exitClassOf(value) === ExitClass.ConfigError) {
704
+ const detail = configDetailOf(value);
705
+ if (detail !== void 0) return detail;
402
706
  }
403
- case "Array":
404
- if (Array.isArray(node["children"])) for (const child of node["children"]) walkConfigNode(child, orderedParams, out);
405
- return;
406
- case "Nested":
407
- if (isObject(node["tree"])) walkConfigTree(node["tree"], orderedParams, out);
408
- return;
409
- default: return;
707
+ pushConfigCauses(value, depth, stack);
410
708
  }
411
709
  }
412
- function walkConfigTree(tree, orderedParams, out) {
413
- for (const key of Object.keys(tree)) walkConfigNode(tree[key], orderedParams, out);
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";
721
+ const value = failureValue(exit);
722
+ if (value !== void 0) {
723
+ if (CliError.isCliError(value)) return "re-run with --help to see the full usage";
724
+ if (S.is(SurvivorsRejection)(value)) return value.remediation;
725
+ }
726
+ if (collectExitClasses(exit).includes(ExitClass.ConfigError)) {
727
+ const detail = firstConfigErrorDetail(exit);
728
+ return detail !== void 0 ? `check the config file: ${detail}` : "check the config file";
729
+ }
730
+ return "see --reportFile or the verdict envelope on stdout";
414
731
  }
415
- function describeCommandNode(node) {
416
- if (!isObject(node)) return;
417
- const out = {
418
- flags: [],
419
- args: []
420
- };
421
- const config = node["config"];
422
- if (isObject(config) && isObject(config["tree"])) {
423
- const orderedParams = Array.isArray(config["orderedParams"]) ? config["orderedParams"] : [];
424
- walkConfigTree(config["tree"], orderedParams, out);
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);
425
751
  }
426
- const subcommands = [];
427
- const grouped = node["subcommands"];
428
- if (Array.isArray(grouped)) for (const group of grouped) {
429
- if (!isObject(group) || !Array.isArray(group["commands"])) continue;
430
- for (const child of group["commands"]) {
431
- const described = describeCommandNode(child);
432
- if (described !== void 0) subcommands.push(described);
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
+ function unrecognizedArgumentOf(exit, argv) {
762
+ if (!Exit.isFailure(exit)) return;
763
+ const value = failureValue(exit);
764
+ if (value === void 0 || !CliError.isCliError(value)) return;
765
+ const errors = S.is(CliError.ShowHelp)(value) ? value.errors : [value];
766
+ for (const error of errors) {
767
+ if (S.is(CliError.UnrecognizedOption)(error)) {
768
+ 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;
433
771
  }
772
+ if (S.is(CliError.UnexpectedArgument)(error)) return error.arguments[0];
773
+ if (S.is(CliError.UnknownSubcommand)(error)) return error.subcommand;
434
774
  }
435
- return {
436
- name: stringField(node, "name") ?? "",
437
- description: typeof node["description"] === "string" ? node["description"] : "",
438
- options: out.flags,
439
- args: out.args,
440
- subcommands
441
- };
442
775
  }
443
776
  /**
444
- * Builds the manifest document for a command, walking its compiled form (the
445
- * same structure the parser matches against). `version` is the tool version,
446
- * passed in so this module stays free of package state.
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.
447
782
  */
448
- function buildLLMSManifest(command, version) {
449
- const root = describeCommandNode(command) ?? {
450
- name: "",
451
- description: "",
452
- options: [],
453
- args: [],
454
- subcommands: []
455
- };
783
+ function failureValue(exit) {
784
+ if (!Exit.isFailure(exit)) return;
785
+ const failure = Cause.findErrorOption(exit.cause);
786
+ 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
+ }
790
+ //#endregion
791
+ //#region src/cli-error-envelope.ts
792
+ function buildErrorEnvelope(exit, code, captured, argv) {
793
+ const unrecognized = unrecognizedArgumentOf(exit, argv);
456
794
  return {
457
795
  schemaVersion: "1.0",
458
- tool: root.name,
459
- version,
460
- commands: [root]
796
+ code,
797
+ error: unrecognized !== void 0 ? `Received unknown argument: '${unrecognized}'` : captured.length > 0 ? captured : describeFailure(exit),
798
+ remediation: remediationFor(exit, code)
461
799
  };
462
800
  }
463
- /** The manifest as one JSON document, ready for stdout — the U4 convention. */
464
- function emitLLMSManifest(command, version) {
465
- return JSON.stringify(buildLLMSManifest(command, version));
466
- }
467
801
  //#endregion
468
- //#region src/OutputModeConsoleState.ts
802
+ //#region src/console-capture.ts
469
803
  /**
470
804
  * U6 — the machine-mode `Console` layer (KTD3, R7).
471
805
  *
@@ -580,96 +914,108 @@ function resetCapturedConsole() {
580
914
  timeByLabel.clear();
581
915
  }
582
916
  //#endregion
583
- //#region src/Survivors.workflow.ts
917
+ //#region src/cli-machine-output.ts
584
918
  /**
585
- * The decision's helper closure reaches three language built-ins the purity
586
- * gate cannot resolve as globals, so each is bound at module scope and the
587
- * helpers reference the bindings: `isArray` keeps `Array.isArray`'s narrow,
588
- * `objectEntries`/`objectFromEntries`/`objectKeys` keep `Object`'s trio, and
589
- * `stringify` keeps `JSON.stringify`'s exact text.
919
+ * Machine mode emits the U4 verdict envelope for a run that produced no
920
+ * mutants and no report file: a `--survivors` run with zero survivors (AE3)
921
+ * or a successful `--dryRunOnly` run that ended before the mutation
922
+ * pipeline. The envelope carries a null score and an empty mutant list and is
923
+ * written as the terminal `verdict` line of the stdout stream (U6), carrying
924
+ * the run id the stream header already opened with (KTD11 — never a fresh
925
+ * id). Human mode prints nothing (the sink drops in human mode).
590
926
  */
591
- const isArray = Array.isArray;
592
- const { entries: objectEntries, fromEntries: objectFromEntries, keys: objectKeys } = Object;
593
- const stringify = JSON.stringify;
594
- /**
595
- * U8 — survivor re-run admission (R10, R11, KTD6, KTD7).
596
- *
597
- * The `--survivors` run re-tests exactly the mutants that survived a previous
598
- * run. Its input is the previous run's mutation report, and the run is
599
- * admitted only when a single structural hash of the resolved options, the
600
- * recorded framework version, and the per-file source content all match the
601
- * current run (KTD6). Because thresholds live inside the resolved options, a
602
- * threshold-only change is caught for free. Every rejection exits 2 with a
603
- * remediation naming the full run to do first; zero survivors exits 0 with a
604
- * null score and writes no new report (AE3); and a report written by a
605
- * survivors run is never admitted as the input of another survivors run
606
- * (KTD7), so chaining two survivors runs fails loudly instead of re-testing a
607
- * shrunken or stale set.
608
- *
609
- * All functions here are pure over their inputs — no file I/O — so the
610
- * admission logic is fixture-testable in seconds.
611
- */
612
- /** The path a `--survivors` run reads when no `survivorsPriorReport` is configured. */
613
- const DEFAULT_SURVIVORS_PRIOR_REPORT = "reports/mutation-report.json";
614
- /**
615
- * The remediation every rejection carries (R10): name the full run to do
616
- * first, never the survivors run itself.
617
- */
618
- const SURVIVORS_RUN_FIRST_REMEDIATION = "run a full `stryker run` first, then re-run with --survivors";
619
- /**
620
- * The survivors-run bookkeeping keys carried in the resolved options. They
621
- * are run mechanics, not configuration: a survivors run adds them, so without
622
- * stripping them the current run's hash would differ from the prior full
623
- * run's hash for the very same configuration. Their presence in a report's
624
- * embedded config is also the marker that the report was produced by a
625
- * survivors run (KTD7).
626
- */
627
- const SURVIVORS_BOOKKEEPING_KEYS = ["survivorsPriorReport"];
628
- function isRecord(value) {
629
- return typeof value === "object" && value !== null && !isArray(value);
630
- }
631
- /**
632
- * The resolved options without the survivors-run bookkeeping keys, so both
633
- * sides of the admission hash describe the same configuration.
634
- */
635
- function stripSurvivorsKeys(config) {
636
- if (!isRecord(config)) return {};
637
- const rest = { ...config };
638
- for (const key of SURVIVORS_BOOKKEEPING_KEYS) delete rest[key];
639
- return rest;
927
+ function emitNullScoreVerdict(stream, mode, thresholds, config, basePath, pathService) {
928
+ const envelope = buildVerdictEnvelope({
929
+ schemaVersion: "1.0",
930
+ files: {},
931
+ thresholds,
932
+ projectRoot: basePath,
933
+ config,
934
+ framework: {
935
+ name: "StrykerJS",
936
+ version: strykerVersion
937
+ }
938
+ }, mode.mode, mode.signal, stream.runId, basePath, [], pathService);
939
+ stream.sink({
940
+ kind: "verdict",
941
+ ...envelope
942
+ });
640
943
  }
641
944
  /**
642
- * A report written by a survivors run embeds the bookkeeping key in its
643
- * `config` (the report helper writes the resolved options). Such a report is
644
- * never a valid input for another survivors run (KTD7): without this check
645
- * the second run would either re-read a shrunken set or re-test a stale one.
945
+ * Emits the machine-mode output from the run's finalizer it runs on
946
+ * success, failure and interruption alike (R30): a failed run writes the
947
+ * `error` terminal event as the last line of the stdout stream; a successful
948
+ * run whose only console output was the framework's help/version rendering
949
+ * emits that captured document as the `help` terminal event, so `--help` in
950
+ * machine mode never leaks an ANSI document. A successful run with an empty
951
+ * buffer (the normal verdict path) emits nothing extra — the run already
952
+ * wrote its terminal `verdict` line through the same module — unless the
953
+ * stream is still open, which means the run never reached a verdict (the
954
+ * `--dryRunOnly` early return): then a null-score `verdict` closes the
955
+ * stream so the last stdout line is always a terminal event (R5).
646
956
  */
647
- function wasProducedBySurvivorsRun(priorReport) {
648
- const config = priorReport.config;
649
- return isRecord(config) && "survivorsPriorReport" in config;
957
+ function emitMachineModeOutput(stream, mode, exit, code, argv, basePath, pathService) {
958
+ return Effect.gen(function* () {
959
+ 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);
986
+ return;
987
+ }
988
+ if (stream.isOpen()) emitNullScoreVerdict(stream, mode, (yield* defaultOptions).thresholds, {}, basePath, pathService);
989
+ });
650
990
  }
991
+ //#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";
995
+ //#endregion
996
+ //#region src/survivors-hashing.ts
997
+ const { entries: objectEntries$1, fromEntries: objectFromEntries } = Object;
651
998
  /**
652
- * The sha256 content hash both sides of the admission comparison use: the
653
- * current run's source files and the sources the prior report embeds. The
654
- * digest capability is supplied by the caller.
999
+ * The content hash of one source file.
1000
+ *
1001
+ * Thin by design: the digest is the caller's capability, and naming the call
1002
+ * keeps every hashing site in the admission path reading the same way.
655
1003
  */
656
1004
  function sourceContentHash(content, hash) {
657
1005
  return hash(content);
658
1006
  }
659
1007
  /**
660
- * Serializes the hash input with keys sorted at every level, so the hash is a
661
- * pure function of the data and not of object key insertion order. The shape
662
- * is pinned by a golden snapshot test: a serialization change here fails
663
- * loudly instead of silently invalidating every prior report in the wild.
1008
+ * The per-file source hashes of the sources a prior report embeds.
1009
+ *
1010
+ * The current run's side of the comparison is gathered by the shell from disk;
1011
+ * this is the recorded side, read back out of the report.
664
1012
  */
665
- function serializeSurvivorsHashInput(input) {
666
- return stringify(sortKeys(input));
667
- }
668
- function sortKeys(value) {
669
- if (isArray(value)) return value.map(sortKeys);
670
- if (isRecord(value)) return objectFromEntries(objectKeys(value).sort().map((key) => [key, sortKeys(value[key])]));
671
- return value;
1013
+ function priorSourceHashes(priorReport, hashContent) {
1014
+ return objectFromEntries(objectEntries$1(priorReport.files).map(([file, fileResult]) => [file, sourceContentHash(fileResult.source, hashContent)]));
672
1015
  }
1016
+ //#endregion
1017
+ //#region src/survivors-mutants.ts
1018
+ const { entries: objectEntries } = Object;
673
1019
  /**
674
1020
  * Converts a report mutant (1-based schema location) into the internal mutant
675
1021
  * shape a run consumes (0-based positions, absolute file name) — the exact
@@ -711,11 +1057,11 @@ function extractSurvivors(priorReport, resolveAbsolutePath) {
711
1057
  * ranges: the report's 1-based lines with the internal 0-based columns,
712
1058
  * relative file names, deduplicated in first-seen order.
713
1059
  */
714
- function survivorMutateSpans(survivors) {
1060
+ function survivorMutateSpans(survivors, basePath, pathService) {
715
1061
  const spans = [];
716
1062
  const seen = /* @__PURE__ */ new Set();
717
1063
  for (const survivor of survivors) {
718
- const file = toRelativeNormalizedFileName(survivor.fileName);
1064
+ const file = toRelativeNormalizedFileName(survivor.fileName, basePath, pathService);
719
1065
  const { start, end } = survivor.location;
720
1066
  const span = `${file}:${start.line + 1}:${start.column}-${end.line + 1}:${end.column}`;
721
1067
  if (!seen.has(span)) {
@@ -725,58 +1071,8 @@ function survivorMutateSpans(survivors) {
725
1071
  }
726
1072
  return spans;
727
1073
  }
728
- /**
729
- * The mutant shape the admission carries, named once because both the decision's
730
- * `Admitted` payload and the command's precomputed survivor list are the same shape.
731
- */
732
- const MutantShape = S.Struct({
733
- id: S.String,
734
- fileName: S.String,
735
- mutatorName: S.String,
736
- replacement: S.String,
737
- location: S.Struct({
738
- start: S.Struct({
739
- line: S.Finite,
740
- column: S.Finite
741
- }),
742
- end: S.Struct({
743
- line: S.Finite,
744
- column: S.Finite
745
- })
746
- })
747
- });
748
- /**
749
- * The prior report as a document, decoded at the boundary. Module-internal: consumers
750
- * get {@link decodePriorReport}, not the schema, so the report's wire shape is not a
751
- * surface commitment and the codec has exactly one caller.
752
- *
753
- * `status` is a bare string rather than the closed status set on purpose: the decide only
754
- * compares it to `'Survived'`, so a report written by a newer engine that added a status
755
- * must not be refused for carrying one.
756
- */
757
- const PriorReportDocument = S.Struct({
758
- config: S.optional(S.Record(S.String, S.Unknown)),
759
- framework: S.optional(S.Struct({ version: S.optional(S.String) })),
760
- files: S.Record(S.String, S.Struct({
761
- source: S.String,
762
- mutants: S.Array(S.Struct({
763
- id: S.String,
764
- mutatorName: S.String,
765
- replacement: S.optional(S.String),
766
- status: S.String,
767
- location: S.Struct({
768
- start: S.Struct({
769
- line: S.Finite,
770
- column: S.Finite
771
- }),
772
- end: S.Struct({
773
- line: S.Finite,
774
- column: S.Finite
775
- })
776
- })
777
- }))
778
- }))
779
- });
1074
+ //#endregion
1075
+ //#region src/survivors-report.ts
780
1076
  /**
781
1077
  * Decodes a prior report read from disk. Pure, so it runs in the decode phase, whose
782
1078
  * `Left` is fatal by construction — it reaches the derived error channel and no write
@@ -784,166 +1080,11 @@ const PriorReportDocument = S.Struct({
784
1080
  * a third-party report type.
785
1081
  */
786
1082
  const decodePriorReport = S.decodeUnknownResult(PriorReportDocument);
787
- /**
788
- * The prior report's facts the decision reads: its embedded configuration, which carries
789
- * both the compared options and the survivors-run provenance marker, and the engine
790
- * version it recorded. The report's files are not here — the survivors and the per-file
791
- * source hashes derived from them need capabilities the command cannot hold, so they
792
- * arrive already computed.
793
- */
794
- var PriorReportFacts = class extends S.Class("PriorReportFacts")({
795
- config: S.Record(S.String, S.Unknown),
796
- frameworkVersion: S.UndefinedOr(S.String)
797
- }) {};
798
- /**
799
- * The command of the admission workflow: a schema class, because `Workflow.make`
800
- * constrains its first argument on the class value and a declared interface produces no
801
- * value to pass. Every field is pure data — the two capabilities the previous shape
802
- * carried, a digest function and a path resolver, can never be schema fields, so their
803
- * results arrive precomputed from the decode phase instead.
804
- */
805
- var AdmitSurvivorsRunCommand = class extends S.Class("AdmitSurvivorsRunCommand")({
806
- /**
807
- * The prior run's report facts, `undefined` when no report exists — the run cannot be
808
- * admitted without one ('no-report'). Explicitly nullable rather than key-optional: a
809
- * missing report is a state the edge determined and states, not a key it forgot.
810
- */
811
- priorReport: S.UndefinedOr(PriorReportFacts),
812
- /** The current run's resolved options (defaults + config file + CLI). */
813
- currentConfig: S.Record(S.String, S.Unknown),
814
- /** The current CLI/framework version (`strykerVersion`). */
815
- frameworkVersion: S.String,
816
- /**
817
- * Per-file content hashes of the current source, keyed by the prior report's relative
818
- * file keys. The prior side is hashed from the sources the report embeds, so an editor
819
- * save that shifts line ranges — which would silently re-test a different mutant than
820
- * the one that survived — is caught here.
821
- */
822
- sourceContentHashes: S.Record(S.String, S.String),
823
- /** The same hashes for the sources the prior report embeds, computed at the edge. */
824
- priorSourceHashes: S.Record(S.String, S.String),
825
- /** The prior report's survivors, already converted to the internal mutant shape. */
826
- priorSurvivors: S.Array(MutantShape)
827
- }) {};
828
- const NO_REPORT_DETAIL = "No prior mutation report found — a --survivors run needs the report of a previous run.";
829
- const SURVIVORS_RUN_SOURCE_DETAIL = "The prior mutation report was itself produced by a --survivors run, so it is not a valid input for another one.";
830
- const MISMATCH_DETAIL = "The prior mutation report does not match the current run (resolved options, framework version, or source content differ).";
831
- /** The per-file source hashes of the sources the prior report embeds. */
832
- function priorSourceHashes(priorReport, hashContent) {
833
- return objectFromEntries(objectEntries(priorReport.files).map(([file, fileResult]) => [file, sourceContentHash(fileResult.source, hashContent)]));
834
- }
835
- /**
836
- * Whether the admission inputs agree: the prior report's embedded resolved options,
837
- * framework version and source content against the current run's.
838
- *
839
- * The comparison is on the canonical serializations rather than digests of them. Equal
840
- * serializations are equal runs, so the digest was a lossy restatement of the check that
841
- * also demanded a capability no command can carry.
842
- */
843
- function hashesMatch(priorReport, input) {
844
- return serializeSurvivorsHashInput({
845
- resolvedOptions: stripSurvivorsKeys(priorReport.config),
846
- frameworkVersion: priorReport.frameworkVersion,
847
- sourceContentHashes: input.priorSourceHashes
848
- }) === serializeSurvivorsHashInput({
849
- resolvedOptions: stripSurvivorsKeys(input.currentConfig),
850
- frameworkVersion: input.frameworkVersion,
851
- sourceContentHashes: input.sourceContentHashes
852
- });
853
- }
854
- const rejection = (reason, detail) => ({
855
- kind: "reject",
856
- reason,
857
- remediation: `${detail} ${SURVIVORS_RUN_FIRST_REMEDIATION}`
858
- });
859
- function admissionVerdict(input) {
860
- const priorReport = input.priorReport;
861
- if (priorReport === void 0) return rejection("no-report", NO_REPORT_DETAIL);
862
- if (wasProducedBySurvivorsRun(priorReport)) return rejection("mismatch", SURVIVORS_RUN_SOURCE_DETAIL);
863
- if (input.priorSurvivors.length === 0) return { kind: "no-survivors" };
864
- if (!hashesMatch(priorReport, input)) return rejection("mismatch", MISMATCH_DETAIL);
865
- return {
866
- kind: "admit",
867
- survivors: input.priorSurvivors
868
- };
869
- }
870
- const SurvivorsAdmissionTypeId = Symbol.for("@systemfsoftware/stryker-js-cli/SurvivorsAdmission");
871
- var Admitted = class extends S.TaggedClass()("Admitted", { survivors: S.Array(MutantShape) }) {
872
- [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
873
- };
874
- var NoSurvivors = class extends S.TaggedClass()("NoSurvivors", {}) {
875
- [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
876
- };
877
- S.Union([Admitted, NoSurvivors]);
878
- var SurvivorsRejection = class extends S.TaggedError()("SurvivorsRejection", {
879
- reason: S.Literals(["no-report", "mismatch"]),
880
- remediation: S.String
881
- }) {
882
- [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
883
- };
884
- /**
885
- * The survivors admission decision: the classification `admissionVerdict`
886
- * produces, assigned to the workflow channels — one arm per kind, no guard
887
- * chain. A missing report, a survivors-sourced report and a hash mismatch are
888
- * the same reject outcome with different reasons; only the rejection's
889
- * remediation names the full run to do first (R10).
890
- */
891
- const admitSurvivorsRun = Workflow.make(AdmitSurvivorsRunCommand, (command) => Match.value(admissionVerdict(command)).pipe(Match.discriminator("kind")("reject", (verdict) => Result.fail(SurvivorsRejection.make({
892
- reason: verdict.reason,
893
- remediation: verdict.remediation
894
- }))), Match.discriminator("kind")("no-survivors", () => Result.succeed(NoSurvivors.make())), Match.discriminator("kind")("admit", (verdict) => Result.succeed(Admitted.make({ survivors: verdict.survivors }))), Match.exhaustive));
895
- //#endregion
896
- //#region src/SurvivorsExit.ts
897
- /** The exit class a rejected survivors run exits with (R6: exit 2). */
898
- const SURVIVORS_REJECT_EXIT_CLASS = ExitClass.ConfigError;
899
- //#endregion
900
- //#region src/cli-request.schema.ts
901
- const RunRequestSchema = S.TaggedStruct("run", {
902
- options: S.Any,
903
- survivors: S.Boolean
904
- });
905
- const LlmsRequestSchema = S.TaggedStruct("llms", { document: S.Any });
906
- S.Union([RunRequestSchema, LlmsRequestSchema]);
907
1083
  //#endregion
908
- //#region src/StrykerCliExecutor.ts
909
- /**
910
- * The default run: binds the host-resolved run options (the sink, the mode,
911
- * the timing) to a fresh `Stryker` and runs mutation testing.
912
- */
913
- const defaultRunMutationTest = (hostOptions) => (options) => new Stryker(options, hostOptions).runMutationTest();
914
- /**
915
- * The machine-mode `Console` layer, bundled so the transport (which resolves
916
- * the mode) can provide it without importing the state cell. Human mode
917
- * provides no layer — effect's own default console is the prose rendering
918
- * (OutputModeConsoleState.ts).
919
- */
920
- const strykerCliConsoleLayers = { machine: machineConsoleLayer };
921
- const SIGNAL_NUMBERS = Object.freeze({
922
- SIGINT: 2,
923
- SIGTERM: 15
924
- });
1084
+ //#region src/cli-survivors-admission.ts
925
1085
  const hashContent = (content) => createHash("sha256").update(content, "utf-8").digest("hex");
926
1086
  const resolveAbsolutePath = (file) => resolve(file);
927
1087
  /**
928
- * The host options a run is bound to: the sink, the mode, the timing and the
929
- * log descriptor chosen by the mode — machine mode keeps stdout exclusively
930
- * for the NDJSON stream, so the logging backend is pointed at stderr; human
931
- * mode keeps the stdout sink. The fix is the descriptor, never the log level.
932
- */
933
- function hostOptionsOf(mode, stream) {
934
- return {
935
- loggerConsoleOut: mode.mode === "machine" ? process.stderr : process.stdout,
936
- showColors: isColorEnabled(mode, process.env["NO_COLOR"]),
937
- runEventSink: stream.sink,
938
- runId: stream.runId,
939
- resolvedMode: mode,
940
- progressEnabled: isProgressEnabled(mode),
941
- clearTextEnabled: mode.mode === "human",
942
- runStartedAt: stream.startedAt,
943
- reporterPluginModules: [import.meta.resolve("@systemfsoftware/stryker-js-mutation-report/stryker-plugins")]
944
- };
945
- }
946
- /**
947
1088
  * The survivors admission, as a description whose phases chain by type and
948
1089
  * read in the order they run. The read gathers the admission's whole input
949
1090
  * product — resolved options, prior report and the current source hashes —
@@ -954,68 +1095,56 @@ function hostOptionsOf(mode, stream) {
954
1095
  * stashed context back and dispatches the decision to the verdict/run,
955
1096
  * failing the run with a rejection.
956
1097
  */
957
- const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext) => pipe(
958
- Cell.read((cliOptions) => Effect.promise(() => resolveSurvivorsRunOptions(cliOptions)).pipe(Effect.flatMap((resolvedOptions) => {
959
- const priorReportPath = priorReportPathOf(resolvedOptions);
960
- const read = readPriorReport(priorReportPath);
961
- return Ref.set(runContext, {
962
- resolvedOptions,
963
- priorReportPath
964
- }).pipe(Effect.as({
965
- resolvedOptions,
966
- priorReportRaw: read.raw,
967
- priorReportFound: read.found,
968
- priorReportPath,
969
- sourceContentHashes: currentSourceHashesFor(priorReportFileKeys(read.raw))
970
- }));
971
- }))),
972
- /**
973
- * The one place the prior report is decoded, and the one place the two capabilities
974
- * are applied. A report that was never there yields a command with no facts, which
975
- * the decider rejects as `no-report`; a report that was there and does not decode
976
- * yields a `Left`, which stops the run before the decider sees it.
977
- */
978
- Cell.decode(({ resolvedOptions, priorReportRaw, priorReportFound, sourceContentHashes }) => {
979
- if (!priorReportFound) return Result.succeed(AdmitSurvivorsRunCommand.make({
980
- priorReport: void 0,
981
- currentConfig: resolvedOptions,
982
- frameworkVersion: strykerVersion,
983
- sourceContentHashes,
984
- priorSourceHashes: {},
985
- priorSurvivors: []
986
- }));
987
- return Result.map(decodePriorReport(priorReportRaw), (document) => AdmitSurvivorsRunCommand.make({
988
- priorReport: PriorReportFacts.make({
989
- config: document.config ?? {},
990
- frameworkVersion: document.framework?.version
991
- }),
992
- currentConfig: resolvedOptions,
993
- frameworkVersion: strykerVersion,
994
- sourceContentHashes,
995
- priorSourceHashes: priorSourceHashes(document, hashContent),
996
- priorSurvivors: extractSurvivors(document, resolveAbsolutePath)
997
- }));
998
- }),
999
- Cell.decide(admitSurvivorsRun),
1000
- Cell.encode((outcome) => outcome),
1001
- Cell.write((outcome) => Effect.flatMap(Ref.get(runContext), (context) => {
1002
- if (context === void 0) return Effect.die("the survivors admission read must run before its write");
1003
- const { resolvedOptions, priorReportPath } = context;
1004
- return Result.match(outcome, {
1005
- onSuccess: (decision) => Match.value(decision).pipe(Match.tag("NoSurvivors", () => Effect.sync(() => emitEmptySurvivorsVerdict(stream, mode, resolvedOptions))), Match.tag("Admitted", (admitted) => {
1006
- const restricted = {
1007
- ...resolvedOptions,
1008
- survivors: admitted.survivors,
1009
- mutate: survivorMutateSpans(admitted.survivors),
1010
- survivorsPriorReport: priorReportPath,
1011
- incremental: false
1012
- };
1013
- return Effect.promise(() => runMutationTest(restricted));
1014
- }), Match.orElse(() => Effect.die("unreachable admission decision variant"))),
1015
- onFailure: (rejection) => Effect.fail(rejection)
1016
- });
1017
- }))
1018
- );
1098
+ const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext, basePath) => pipe(Cell.read((cliOptions) => Effect.flatMap(Path.Path, (pathService) => resolveSurvivorsRunOptions(cliOptions, basePath).pipe(Effect.flatMap((resolvedOptions) => {
1099
+ const priorReportPath = priorReportPathOf(resolvedOptions);
1100
+ const read = readPriorReport(priorReportPath);
1101
+ return Ref.set(runContext, {
1102
+ resolvedOptions,
1103
+ priorReportPath,
1104
+ pathService
1105
+ }).pipe(Effect.as({
1106
+ resolvedOptions,
1107
+ priorReportRaw: read.raw,
1108
+ priorReportFound: read.found,
1109
+ priorReportPath,
1110
+ sourceContentHashes: currentSourceHashesFor(priorReportFileKeys(read.raw))
1111
+ }));
1112
+ })))), Cell.decode(({ resolvedOptions, priorReportRaw, priorReportFound, sourceContentHashes }) => {
1113
+ if (!priorReportFound) return Result.succeed(AdmitSurvivorsRunCommand.make({
1114
+ priorReport: void 0,
1115
+ currentConfig: resolvedOptions,
1116
+ frameworkVersion: strykerVersion,
1117
+ sourceContentHashes,
1118
+ priorSourceHashes: {},
1119
+ priorSurvivors: []
1120
+ }));
1121
+ return Result.map(decodePriorReport(priorReportRaw), (document) => AdmitSurvivorsRunCommand.make({
1122
+ priorReport: PriorReportFacts.make({
1123
+ config: document.config ?? {},
1124
+ frameworkVersion: document.framework?.version
1125
+ }),
1126
+ currentConfig: resolvedOptions,
1127
+ frameworkVersion: strykerVersion,
1128
+ sourceContentHashes,
1129
+ priorSourceHashes: priorSourceHashes(document, hashContent),
1130
+ priorSurvivors: extractSurvivors(document, resolveAbsolutePath)
1131
+ }));
1132
+ }), Cell.decide(admitSurvivorsRun), Cell.encode((outcome) => outcome), Cell.write((outcome) => Effect.flatMap(Ref.get(runContext), (context) => {
1133
+ if (context === void 0) return Effect.die("the survivors admission read must run before its write");
1134
+ const { resolvedOptions, priorReportPath, pathService } = context;
1135
+ 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) => {
1137
+ return runMutationTest({
1138
+ ...resolvedOptions,
1139
+ survivors: admitted.survivors,
1140
+ mutate: survivorMutateSpans(admitted.survivors, basePath, pathService),
1141
+ survivorsPriorReport: priorReportPath,
1142
+ incremental: false
1143
+ }).pipe(Effect.orDie);
1144
+ }), Match.orElse(() => Effect.die("unreachable admission decision variant"))),
1145
+ onFailure: (rejection) => Effect.fail(rejection)
1146
+ });
1147
+ })));
1019
1148
  /**
1020
1149
  * The `--survivors` request: re-test exactly the prior report's survivor set.
1021
1150
  * The survivors flag was parsed as a boolean; the admission decides between
@@ -1029,292 +1158,86 @@ const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext
1029
1158
  * is made; it is in this signature because the phase types put it there, not because the
1030
1159
  * admission chose it.
1031
1160
  */
1032
- function runSurvivorsAdmission(runMutationTest, stream, mode, cliOptions) {
1161
+ function runSurvivorsAdmission(runMutationTest, stream, mode, cliOptions, basePath) {
1033
1162
  return Effect.gen(function* () {
1034
1163
  const admissionContext = yield* Ref.make(void 0);
1035
- return yield* Cell.apply(survivorsAdmissionDescription(runMutationTest, stream, mode, admissionContext), cliOptions);
1164
+ return yield* Cell.apply(survivorsAdmissionDescription(runMutationTest, stream, mode, admissionContext, basePath), cliOptions);
1036
1165
  });
1037
1166
  }
1038
- /**
1039
- * Resolves the current options the same way the pipeline does — defaults +
1040
- * config file + CLI, validated against the fork schema (which carries the
1041
- * survivors-run properties). The admission hash compares these resolved
1042
- * options against the prior report's embedded config.
1043
- */
1044
- function resolveSurvivorsRunOptions(cliOptions) {
1045
- return new ConfigReader(noopLogger, new OptionsValidator(forkCoreSchema, noopLogger)).readConfig(cliOptions);
1167
+ function resolveSurvivorsRunOptions(cliOptions, basePath) {
1168
+ return readConfig(cliOptions, noopLogger, forkCoreSchema, basePath);
1046
1169
  }
1047
- /**
1048
- * The prior report a `--survivors` run reads: the `survivorsPriorReport`
1049
- * config option when set, else the default path. The report path is run
1050
- * bookkeeping, never a CLI flag.
1051
- */
1052
1170
  function priorReportPathOf(resolved) {
1053
1171
  const configured = resolved["survivorsPriorReport"];
1054
1172
  return typeof configured === "string" ? configured : DEFAULT_SURVIVORS_PRIOR_REPORT;
1055
1173
  }
1056
- /**
1057
- * Reads the prior report without validating it. Absence and malformation are different
1058
- * outcomes and the caller must be able to tell them apart: an absent report is the
1059
- * `no-report` rejection the decider states, while a present-but-malformed one is a decode
1060
- * failure that stops the run. Text that is not JSON is reported as found, carrying the
1061
- * text itself, so the codec refuses it and names what it got.
1062
- */
1063
1174
  function readPriorReport(priorReportPath) {
1064
1175
  let text;
1065
- try {
1066
- text = readFileSync(priorReportPath, "utf-8");
1067
- } catch {
1068
- return {
1069
- found: false,
1070
- raw: void 0
1071
- };
1072
- }
1073
- try {
1074
- return {
1075
- found: true,
1076
- raw: JSON.parse(text)
1077
- };
1078
- } catch {
1079
- return {
1080
- found: true,
1081
- raw: text
1082
- };
1083
- }
1084
- }
1085
- /**
1086
- * The relative file names a report claims, read structurally rather than through the
1087
- * codec because the current sources must be hashed before the report is decoded — the
1088
- * read phase does the disk I/O, and the keys are what tell it which files to read. An
1089
- * unrecognisable report yields no keys and is refused a phase later by the codec.
1090
- */
1091
- function priorReportFileKeys(raw) {
1092
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return [];
1093
- if (!("files" in raw)) return [];
1094
- const files = raw.files;
1095
- if (typeof files !== "object" || files === null || Array.isArray(files)) return [];
1096
- return Object.keys(files);
1097
- }
1098
- function readSourceFile(file) {
1099
- try {
1100
- return readFileSync(file, "utf-8");
1101
- } catch {
1102
- return "";
1103
- }
1104
- }
1105
- /**
1106
- * The per-file content hashes of the current sources, keyed by the relative file names
1107
- * the prior report uses — the current side of the admission comparison. The prior side is
1108
- * hashed from the sources the report embeds, in the decode phase.
1109
- */
1110
- function currentSourceHashesFor(files) {
1111
- const hashes = {};
1112
- for (const file of files) hashes[file] = sourceContentHash(readSourceFile(file), hashContent);
1113
- return hashes;
1114
- }
1115
- /**
1116
- * Machine mode emits the U4 verdict envelope for a run that produced no
1117
- * mutants and no report file: a `--survivors` run with zero survivors (AE3)
1118
- * or a successful `--dryRunOnly` run that ended before the mutation
1119
- * pipeline. The envelope carries a null score and an empty mutant list and is
1120
- * written as the terminal `verdict` line of the stdout stream (U6), carrying
1121
- * the run id the stream header already opened with (KTD11 — never a fresh
1122
- * id). Human mode prints nothing (the sink drops in human mode).
1123
- */
1124
- function emitNullScoreVerdict(stream, mode, thresholds, config) {
1125
- const report = {
1126
- schemaVersion: "1.0",
1127
- files: {},
1128
- thresholds,
1129
- projectRoot: process.cwd(),
1130
- config,
1131
- framework: {
1132
- name: "StrykerJS",
1133
- version: strykerVersion
1134
- }
1135
- };
1136
- const envelope = buildVerdictEnvelope(report, mode.mode, mode.signal, stream.runId);
1137
- stream.sink({
1138
- kind: "verdict",
1139
- ...envelope
1140
- });
1141
- }
1142
- /**
1143
- * The `--survivors` zero-survivor path: the prior report held no survivors,
1144
- * so the run emits the null-score verdict without starting the pipeline. The
1145
- * full resolved options ride along as the report's embedded config (KTD7).
1146
- */
1147
- function emitEmptySurvivorsVerdict(stream, mode, resolved) {
1148
- emitNullScoreVerdict(stream, mode, resolved.thresholds, resolved);
1149
- }
1150
- /**
1151
- * The contextual remediation for a failure, picked from the cause's shape:
1152
- * signal terminations (POSIX `128 + n`) are called out as interruptions,
1153
- * usage/parse errors point at `--help`, config errors name the offending file
1154
- * (ConfigError messages carry it), and rejected survivors runs name the full
1155
- * run to do first. Everything else points at the report file and the verdict
1156
- * envelope, which is where a runtime failure's detail already is.
1157
- */
1158
- function remediationFor(exit, code) {
1159
- if (code > 128) return "the run was interrupted by a signal; re-run it to continue";
1160
- const value = failureValue(exit);
1161
- if (value !== void 0) {
1162
- if (CliError.isCliError(value)) return "re-run with --help to see the full usage";
1163
- if (value instanceof ConfigError) return `check the config file: ${value.message}`;
1164
- if (S.is(SurvivorsRejection)(value)) return value.remediation;
1165
- }
1166
- return "see --reportFile or the verdict envelope on stdout";
1167
- }
1168
- /**
1169
- * The failure's own text, used when the capture buffer is empty — a failure
1170
- * stryker reported through its own logger rather than the framework's
1171
- * `Console`. Falls back to a rendered cause.
1172
- */
1173
- function describeFailure(exit) {
1174
- if (Exit.isFailure(exit)) {
1175
- const value = failureValue(exit);
1176
- if (value !== void 0) {
1177
- if (S.is(SurvivorsRejection)(value)) return value.remediation;
1178
- if (value instanceof Error) return value.message;
1179
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") return String(value);
1180
- return Object.prototype.toString.call(value);
1181
- }
1182
- return Cause.pretty(exit.cause);
1183
- }
1184
- return "";
1185
- }
1186
- /**
1187
- * The argument the framework reports it does not know, named the way the wire
1188
- * contract spells it. The v4 parser fails wrapped in a ShowHelp whose errors
1189
- * carry the offending flag or operand; when the unrecognized flag was given a
1190
- * separate value (`--format text`), the value is the token the old parser
1191
- * reported, so the token after the flag is named when one was given.
1192
- */
1193
- function unrecognizedArgumentOf(exit, argv) {
1194
- if (!Exit.isFailure(exit)) return;
1195
- const value = failureValue(exit);
1196
- if (value === void 0 || !CliError.isCliError(value)) return;
1197
- const errors = S.is(CliError.ShowHelp)(value) ? value.errors : [value];
1198
- for (const error of errors) {
1199
- if (S.is(CliError.UnrecognizedOption)(error)) {
1200
- const at = argv.indexOf(error.option);
1201
- const next = at >= 0 ? argv[at + 1] : void 0;
1202
- return next !== void 0 && !next.startsWith("-") ? next : error.option;
1203
- }
1204
- if (S.is(CliError.UnexpectedArgument)(error)) return error.arguments[0];
1205
- if (S.is(CliError.UnknownSubcommand)(error)) return error.subcommand;
1206
- }
1207
- }
1208
- /**
1209
- * The first typed error in the exit's cause. The framework fails with
1210
- * `Cause.fail` (usage errors); the run handler is `Effect.promise`, whose
1211
- * rejected promises surface as *defects* (`Die` reasons) rather than
1212
- * failures — so stryker's own ConfigError/StrykerError values arrive there
1213
- * and must be read from the cause's `Die` reasons.
1214
- */
1215
- function failureValue(exit) {
1216
- if (!Exit.isFailure(exit)) return;
1217
- const failure = Cause.findErrorOption(exit.cause);
1218
- if (Option.isSome(failure)) return failure.value;
1219
- const dieReason = exit.cause.reasons.find(Cause.isDieReason);
1220
- return dieReason === void 0 ? void 0 : dieReason.defect;
1221
- }
1222
- function buildErrorEnvelope(exit, code, captured, argv) {
1223
- const unrecognized = unrecognizedArgumentOf(exit, argv);
1224
- return {
1225
- schemaVersion: "1.0",
1226
- code,
1227
- error: unrecognized !== void 0 ? `Received unknown argument: '${unrecognized}'` : captured.length > 0 ? captured : describeFailure(exit),
1228
- remediation: remediationFor(exit, code)
1229
- };
1230
- }
1231
- /**
1232
- * Emits the machine-mode output from the run's finalizer — it runs on
1233
- * success, failure and interruption alike (R30): a failed run writes the
1234
- * `error` terminal event as the last line of the stdout stream; a successful
1235
- * run whose only console output was the framework's help/version rendering
1236
- * emits that captured document as the `help` terminal event, so `--help` in
1237
- * machine mode never leaks an ANSI document. A successful run with an empty
1238
- * buffer (the normal verdict path) emits nothing extra — the run already
1239
- * wrote its terminal `verdict` line through the same module — unless the
1240
- * stream is still open, which means the run never reached a verdict (the
1241
- * `--dryRunOnly` early return): then a null-score `verdict` closes the
1242
- * stream so the last stdout line is always a terminal event (R5).
1243
- */
1244
- function emitMachineModeOutput(stream, mode, exit, code, argv) {
1245
- const captured = readCapturedConsole();
1246
- const value = failureValue(exit);
1247
- if (Exit.isFailure(exit) && S.is(CliError.ShowHelp)(value) && value.errors.length === 0) {
1248
- const document = {
1249
- kind: "help",
1250
- schemaVersion: "1.0",
1251
- code: 0,
1252
- help: captured
1176
+ try {
1177
+ text = readFileSync(priorReportPath, "utf-8");
1178
+ } catch {
1179
+ return {
1180
+ found: false,
1181
+ raw: void 0
1253
1182
  };
1254
- stream.sink(document);
1255
- return;
1256
- }
1257
- if (Exit.isFailure(exit)) {
1258
- stream.sink({
1259
- kind: "error",
1260
- ...buildErrorEnvelope(exit, code, captured, argv)
1261
- });
1262
- return;
1263
1183
  }
1264
- if (captured.length > 0) {
1265
- const document = {
1266
- kind: "help",
1267
- schemaVersion: "1.0",
1268
- code: 0,
1269
- help: captured
1184
+ try {
1185
+ return {
1186
+ found: true,
1187
+ raw: JSON.parse(text)
1188
+ };
1189
+ } catch {
1190
+ return {
1191
+ found: true,
1192
+ raw: text
1270
1193
  };
1271
- stream.sink(document);
1272
- return;
1273
1194
  }
1274
- if (stream.isOpen()) emitNullScoreVerdict(stream, mode, defaultOptions.thresholds, {});
1275
1195
  }
1276
- /**
1277
- * A rejected config reaches the finalizer as a typed failure or as a defect
1278
- * depending on where the validator threw, and typed-inject may have wrapped
1279
- * it, so both channels are searched and each candidate is unwrapped.
1280
- */
1281
- function carriesConfigError(cause) {
1282
- for (const reason of cause.reasons) {
1283
- const candidate = Cause.isFailReason(reason) ? reason.error : Cause.isDieReason(reason) ? reason.defect : void 0;
1284
- if (candidate !== void 0 && (candidate instanceof ConfigError || retrieveCause(candidate) instanceof ConfigError)) return true;
1196
+ function priorReportFileKeys(raw) {
1197
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return [];
1198
+ if (!("files" in raw)) return [];
1199
+ const files = raw.files;
1200
+ if (typeof files !== "object" || files === null || Array.isArray(files)) return [];
1201
+ return Object.keys(files);
1202
+ }
1203
+ function readSourceFile(file) {
1204
+ try {
1205
+ return readFileSync(file, "utf-8");
1206
+ } catch {
1207
+ return "";
1285
1208
  }
1286
- return false;
1287
1209
  }
1210
+ function currentSourceHashesFor(files) {
1211
+ const hashes = {};
1212
+ for (const file of files) hashes[file] = sourceContentHash(readSourceFile(file), hashContent);
1213
+ return hashes;
1214
+ }
1215
+ //#endregion
1216
+ //#region src/cli-run.ts
1217
+ const defaultRunMutationTest = (hostOptions) => (options) => Effect.scoped(runMutationTest(defaultStages, options)).pipe(Effect.provide(makeRunLayer(hostOptions)));
1288
1218
  /**
1289
- * Classifies a failed run for the finalizer: usage/parse failures
1290
- * (`CliError` except a bare help request, which exits 0), rejected
1291
- * survivors runs (`SurvivorsRejection`), an unreadable prior report
1292
- * (`S.SchemaError`) and a rejected config (`ConfigError`) all exit 2, all
1293
- * other failures exit 1 (the framework's default). A successful run exits 0;
1294
- * the verdict gates (U5) then resolve the final classed code.
1295
- *
1296
- * The report parse failure shares the survivors class deliberately. It is not a
1297
- * verdict — the decider never sees the report — but the operator's answer is the
1298
- * same class of answer as a rejection: the input you named cannot be used. Letting
1299
- * it fall through to 1 would make an unusable `--survivors` input indistinguishable
1300
- * from a crash.
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.
1301
1223
  */
1302
- function resolveCliExitCode(exit) {
1303
- if (Exit.isSuccess(exit)) return 0;
1304
- if (Cause.hasInterruptsOnly(exit.cause)) return 1;
1305
- const failure = Cause.findErrorOption(exit.cause);
1306
- if (Option.isSome(failure)) {
1307
- const value = failure.value;
1308
- if (S.is(CliError.ShowHelp)(value)) return value.errors.length > 0 ? 2 : 0;
1309
- if (CliError.isCliError(value)) return 2;
1310
- if (S.is(SurvivorsRejection)(value)) return SURVIVORS_REJECT_EXIT_CLASS;
1311
- if (value instanceof S.SchemaError) return SURVIVORS_REJECT_EXIT_CLASS;
1312
- }
1313
- if (carriesConfigError(exit.cause)) return ExitClass.ConfigError;
1314
- return 1;
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
+ };
1315
1238
  }
1316
1239
  /**
1317
- * The single operation of the CLI's executor cell: the impure shell that
1240
+ * The single operation of the CLI's run cell: the impure shell that
1318
1241
  * wraps the transport's command effect with the run bootstrap. It creates the
1319
1242
  * run's stream from the resolved mode, binds the host options a run is
1320
1243
  * executed with, opens the stream, runs the command effect, dispatches the
@@ -1328,22 +1251,29 @@ function resolveCliExitCode(exit) {
1328
1251
  */
1329
1252
  const runStrykerCli = (input, createRunEventStream) => Effect.gen(function* () {
1330
1253
  const stream = yield* createRunEventStream(input.mode);
1331
- const runMutationTest = input.runMutationTest ?? defaultRunMutationTest(hostOptionsOf(input.mode, stream));
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));
1332
1258
  let currentFiber = null;
1333
- let lastSignal = 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
+ };
1334
1265
  const resolveClassedExitCode = (exit) => {
1335
- const signal = lastSignal;
1266
+ const signal = input.lastSignal();
1336
1267
  if (signal !== null) return 128 + signal;
1337
1268
  if (Exit.isFailure(exit)) return resolveCliExitCode(exit);
1338
- return resolveExitCode(getPendingExitClasses(), null);
1269
+ return resolveExitCode(verdictOf(exit.value), null);
1339
1270
  };
1340
- const onSignal = (signal) => {
1341
- lastSignal = SIGNAL_NUMBERS[signal] ?? null;
1271
+ const onSignal = () => {
1342
1272
  process.removeListener("SIGINT", onSignal);
1343
1273
  process.removeListener("SIGTERM", onSignal);
1344
1274
  if (currentFiber !== null) currentFiber.interruptUnsafe(currentFiber.id);
1345
1275
  };
1346
- const dispatch = (request) => Match.value(request).pipe(Match.tag("run", (runRequest) => runRequest.survivors ? runSurvivorsAdmission(runMutationTest, stream, input.mode, runRequest.options) : Effect.promise(() => runMutationTest(runRequest.options))), Match.tag("llms", (llmsRequest) => Effect.sync(() => {
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(() => {
1347
1277
  stream.ensureOpen({
1348
1278
  mode: "machine",
1349
1279
  signal: "flag",
@@ -1359,7 +1289,7 @@ const runStrykerCli = (input, createRunEventStream) => Effect.gen(function* () {
1359
1289
  yield* stream.open;
1360
1290
  yield* input.program;
1361
1291
  const request = yield* Ref.get(input.requestRef);
1362
- yield* Option.match(request, {
1292
+ return yield* Option.match(request, {
1363
1293
  onNone: () => Effect.void,
1364
1294
  onSome: (cliRequest) => dispatch(cliRequest)
1365
1295
  });
@@ -1370,14 +1300,192 @@ const runStrykerCli = (input, createRunEventStream) => Effect.gen(function* () {
1370
1300
  return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
1371
1301
  const exit = yield* Effect.exit(restore(program));
1372
1302
  const code = resolveClassedExitCode(exit);
1373
- input.recordExitCode(code);
1374
- if (input.mode.mode === "machine") emitMachineModeOutput(stream, input.mode, exit, code, input.argv);
1303
+ if (input.mode.mode === "machine") yield* emitMachineModeOutput(stream, input.mode, exit, code, input.argv, basePath, pathService);
1375
1304
  yield* stream.closeAndDrain;
1376
1305
  return code;
1377
1306
  }));
1378
1307
  });
1308
+ function isObject(value) {
1309
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1310
+ }
1311
+ function stringField(node, key) {
1312
+ const value = node[key];
1313
+ return typeof value === "string" ? value : void 0;
1314
+ }
1315
+ function stringArrayField(node, key) {
1316
+ const value = node[key];
1317
+ if (!Array.isArray(value)) return [];
1318
+ const strings = [];
1319
+ for (const item of value) if (typeof item === "string") strings.push(item);
1320
+ return strings;
1321
+ }
1322
+ /** The compiled shapes discriminate on `_tag`; read it once, off the record. */
1323
+ function tagOf(node) {
1324
+ return node["_tag"];
1325
+ }
1326
+ function walkParam(param, isOptional, out) {
1327
+ if (!isObject(param)) return;
1328
+ switch (tagOf(param)) {
1329
+ case "Single":
1330
+ describeSingle(param, isOptional, out);
1331
+ return;
1332
+ case "Map":
1333
+ case "Transform":
1334
+ walkParam(param["param"], isOptional, out);
1335
+ return;
1336
+ case "Optional":
1337
+ walkParam(param["param"], true, out);
1338
+ return;
1339
+ case "Variadic":
1340
+ walkParam(param["param"], isOptional, out);
1341
+ return;
1342
+ default: return;
1343
+ }
1344
+ }
1345
+ const PRIMITIVE_KIND = {
1346
+ Boolean: "boolean",
1347
+ Choice: "choice",
1348
+ Date: "date",
1349
+ FileParse: "file",
1350
+ FileSchema: "file",
1351
+ FileText: "file",
1352
+ Float: "float",
1353
+ Integer: "integer",
1354
+ KeyValuePair: "key=value",
1355
+ None: "none",
1356
+ Path: "path",
1357
+ Redacted: "redacted",
1358
+ String: "text"
1359
+ };
1360
+ function kindOf(primitive) {
1361
+ const tag = stringField(primitive, "_tag");
1362
+ return tag === void 0 ? "unknown" : PRIMITIVE_KIND[tag] ?? tag;
1363
+ }
1364
+ function choiceValues(primitive) {
1365
+ const keys = primitive["choiceKeys"];
1366
+ if (!Array.isArray(keys)) return;
1367
+ const values = [];
1368
+ for (const key of keys) if (typeof key === "string") values.push(key);
1369
+ return values;
1370
+ }
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);
1373
+ /**
1374
+ * v4 option descriptions are stored as `Option.some(string)` on the compiled
1375
+ * `Single`; the walker unwraps the option.
1376
+ */
1377
+ function descriptionOf(single) {
1378
+ const description = single["description"];
1379
+ if (!isObject(description)) return "";
1380
+ switch (tagOf(description)) {
1381
+ case "Some": {
1382
+ const value = description["value"];
1383
+ return typeof value === "string" ? value : "";
1384
+ }
1385
+ default: return "";
1386
+ }
1387
+ }
1388
+ function describeSingle(single, isOptional, out) {
1389
+ const name = stringField(single, "name") ?? "";
1390
+ const primitive = isObject(single["primitiveType"]) ? single["primitiveType"] : {};
1391
+ const kind = kindOf(primitive);
1392
+ const choices = name === "reporters" ? REPORTER_NAMES : kind === "choice" ? choiceValues(primitive) : void 0;
1393
+ const description = descriptionOf(single);
1394
+ const required = kind !== "boolean" && !isOptional;
1395
+ const described = {
1396
+ name,
1397
+ aliases: stringArrayField(single, "aliases"),
1398
+ kind,
1399
+ required,
1400
+ ...choices !== void 0 ? { choices } : {},
1401
+ description
1402
+ };
1403
+ if (single["kind"] === "argument") {
1404
+ out.args.push({
1405
+ name,
1406
+ kind,
1407
+ required,
1408
+ description
1409
+ });
1410
+ return;
1411
+ }
1412
+ out.flags.push(described);
1413
+ }
1414
+ function walkConfigNode(node, orderedParams, out) {
1415
+ if (!isObject(node)) return;
1416
+ switch (tagOf(node)) {
1417
+ case "Param": {
1418
+ const index = node["index"];
1419
+ const param = typeof index === "number" ? orderedParams[index] : void 0;
1420
+ if (param !== void 0) walkParam(param, false, out);
1421
+ return;
1422
+ }
1423
+ case "Array":
1424
+ if (Array.isArray(node["children"])) for (const child of node["children"]) walkConfigNode(child, orderedParams, out);
1425
+ return;
1426
+ case "Nested":
1427
+ if (isObject(node["tree"])) walkConfigTree(node["tree"], orderedParams, out);
1428
+ return;
1429
+ default: return;
1430
+ }
1431
+ }
1432
+ function walkConfigTree(tree, orderedParams, out) {
1433
+ for (const key of Object.keys(tree)) walkConfigNode(tree[key], orderedParams, out);
1434
+ }
1435
+ function describeCommandNode(node) {
1436
+ if (!isObject(node)) return;
1437
+ const out = {
1438
+ flags: [],
1439
+ args: []
1440
+ };
1441
+ const config = node["config"];
1442
+ if (isObject(config) && isObject(config["tree"])) {
1443
+ const orderedParams = Array.isArray(config["orderedParams"]) ? config["orderedParams"] : [];
1444
+ walkConfigTree(config["tree"], orderedParams, out);
1445
+ }
1446
+ const subcommands = [];
1447
+ const grouped = node["subcommands"];
1448
+ if (Array.isArray(grouped)) for (const group of grouped) {
1449
+ if (!isObject(group) || !Array.isArray(group["commands"])) continue;
1450
+ for (const child of group["commands"]) {
1451
+ const described = describeCommandNode(child);
1452
+ if (described !== void 0) subcommands.push(described);
1453
+ }
1454
+ }
1455
+ return {
1456
+ name: stringField(node, "name") ?? "",
1457
+ description: typeof node["description"] === "string" ? node["description"] : "",
1458
+ options: out.flags,
1459
+ args: out.args,
1460
+ subcommands
1461
+ };
1462
+ }
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
+ */
1468
+ function buildLLMSManifest(command, version) {
1469
+ const root = describeCommandNode(command) ?? {
1470
+ name: "",
1471
+ description: "",
1472
+ options: [],
1473
+ args: [],
1474
+ subcommands: []
1475
+ };
1476
+ return {
1477
+ schemaVersion: "1.0",
1478
+ tool: root.name,
1479
+ version,
1480
+ commands: [root]
1481
+ };
1482
+ }
1483
+ /** The manifest as one JSON document, ready for stdout — the U4 convention. */
1484
+ function emitLLMSManifest(command, version) {
1485
+ return JSON.stringify(buildLLMSManifest(command, version));
1486
+ }
1379
1487
  //#endregion
1380
- //#region src/StrykerCliHandler.ts
1488
+ //#region src/stryker-cli.ts
1381
1489
  function createSplitter(separator) {
1382
1490
  return (value) => value.split(separator).filter(Boolean);
1383
1491
  }
@@ -1451,7 +1559,7 @@ const runOptions = {
1451
1559
  "perTest",
1452
1560
  "all",
1453
1561
  "off"
1454
- ]).pipe(Flag.withDescription(`The coverage analysis strategy you want to use. Default value: "${defaultOptions.coverageAnalysis}"`), optional),
1562
+ ]).pipe(Flag.withDescription(`The coverage analysis strategy you want to use. Default value: "${RENDERED_OPTION_DEFAULTS.coverageAnalysis}"`), optional),
1455
1563
  testRunner: Flag.string("testRunner").pipe(Flag.withDescription("The name of the test runner you want to use"), optional),
1456
1564
  testRunnerNodeArgs: Flag.string("testRunnerNodeArgs").pipe(Flag.withDescription("A list of node args to be passed to test runner child processes. Split on spaces (commander characterization): `--testRunnerNodeArgs \"--inspect-brk --trace-warnings\"`."), Flag.map(splitOnSpace), optional),
1457
1565
  reporters: Flag.string("reporters").pipe(Flag.withDescription("A comma separated list of the names of the reporter(s) you want to use"), Flag.map(splitOnComma), optional),
@@ -1464,11 +1572,11 @@ const runOptions = {
1464
1572
  concurrency: Flag.string("concurrency").pipe(Flag.withAlias("c"), Flag.withDescription("Set the concurrency of workers. Stryker will always run checkers and test runners in parallel by creating worker processes (default: cpuCount - 1)"), Flag.map(parseConcurrency), optional),
1465
1573
  disableBail: Flag.map(optional(Flag.boolean("disableBail")), absentWhenFalse).pipe(Flag.withDescription("Force the test runner to keep running tests, even when a mutant is already killed.")),
1466
1574
  maxTestRunnerReuse: Flag.integer("maxTestRunnerReuse").pipe(Flag.withDescription("Restart each test runner worker process after `n` runs. Not recommended unless you are experiencing memory leaks that you are unable to resolve. Configuring `0` here means infinite reuse."), optional),
1467
- logLevel: Flag.choice("logLevel", LOG_LEVELS).pipe(Flag.withDescription(`Set the log level for the console. Possible values: fatal, error, warn, info, debug, trace and off. Default is "${defaultOptions.logLevel}"`), optional),
1468
- fileLogLevel: Flag.choice("fileLogLevel", LOG_LEVELS).pipe(Flag.withDescription(`Set the log level for the "stryker.log" file. Possible values: fatal, error, warn, info, debug, trace and off. Default is "${defaultOptions.fileLogLevel}"`), optional),
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),
1469
1577
  inPlace: Flag.map(optional(Flag.boolean("inPlace")), absentWhenFalse).pipe(Flag.withDescription("Determines whether or not Stryker should mutate your files in place. Note: mutating your files in place is generally not needed for mutation testing, unless you have a dependency in your project that is really dependent on the file locations (like \"app-root-path\" for example).\nWhen `true`, Stryker will override your files, but it will keep a copy of the originals in the temp directory (using `tempDirName`) and it will place the originals back after it is done. Also with `true` the `ignorePatterns` has no effect any more.\nWhen `false` (default) Stryker will work in the copy of your code inside the temp directory.")),
1470
1578
  tempDirName: Flag.string("tempDirName").pipe(Flag.withDescription("Set the name of the directory that is used by Stryker as a working directory. This directory will be cleaned after a successful run"), optional),
1471
- cleanTempDir: Flag.string("cleanTempDir").pipe(Flag.withDescription(`Choose whether or not to clean the temp dir (which is "${defaultOptions.tempDirName}" inside the current working directory by default) after a run.\n- false: Never delete the temp dir;\n- true: Delete the tmp dir after a successful run;\n- always: Always delete the temp dir, regardless of whether the run was successful.`), Flag.map(parseCleanDirOption), optional),
1579
+ cleanTempDir: Flag.string("cleanTempDir").pipe(Flag.withDescription(`Choose whether or not to clean the temp dir (which is "${RENDERED_OPTION_DEFAULTS.tempDirName}" inside the current working directory by default) after a run.\n- false: Never delete the temp dir;\n- true: Delete the tmp dir after a successful run;\n- always: Always delete the temp dir, regardless of whether the run was successful.`), Flag.map(parseCleanDirOption), optional),
1472
1580
  survivors: Flag.map(optional(Flag.boolean("survivors")), absentWhenFalse).pipe(Flag.withDescription("Re-run only the mutants that survived a previous run. Admits against the previous run's mutation report (the `survivorsPriorReport` config option, default `reports/mutation-report.json`) and re-tests exactly the survivor set. Exits 2 with a remediation naming a full run when the report is missing, drifted, or the configuration changed; exits 0 with a null score when the report has no survivors."))
1473
1581
  };
1474
1582
  const runArgs = { configFile: Argument.optional(Argument.string("configFile")) };
@@ -1598,7 +1706,7 @@ const cliLayer = Layer.mergeAll(CliConfig.layer({ builtIns: [
1598
1706
  GlobalFlag.Wizard,
1599
1707
  GlobalFlag.Completions,
1600
1708
  GlobalFlag.LogLevel
1601
- ] }), Path.layer, FileSystem.layerNoop({}), terminalLayer, NodeStdio.layer, Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => Effect.die("no child processes"))));
1709
+ ] }), Path.layer, FileSystem.layerNoop({}), terminalLayer, NodeStdio.layer, NodeChildProcessSpawner.layer.pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath$1.layer))));
1602
1710
  /**
1603
1711
  * The transport entry: builds the command tree, resolves the mode once at
1604
1712
  * the edge (never a second probe), provides the CLI and Console layers the
@@ -1606,19 +1714,19 @@ const cliLayer = Layer.mergeAll(CliConfig.layer({ builtIns: [
1606
1714
  * returns the classed exit code it computes. The executor is the I/O
1607
1715
  * sandwich; this function only frames it.
1608
1716
  */
1609
- function strykerCliEffect(argv, runMutationTest, recordExitCode, detectMode, createRunEventStream) {
1717
+ function strykerCliEffect(argv, runMutationTest, detectMode, createRunEventStream, lastSignal) {
1610
1718
  return Effect.gen(function* () {
1611
1719
  const mode = detectMode();
1612
1720
  const requestRef = yield* Ref.make(Option.none());
1613
1721
  const command = makeStrykerCommand(requestRef);
1614
- const cliEffect = Command.runWith(command, { version: strykerVersion })(argv).pipe(Effect.provide(Layer.mergeAll(mode.mode === "machine" ? strykerCliConsoleLayers.machine : Layer.empty, cliLayer)));
1722
+ const cliEffect = Command.runWith(command, { version: strykerVersion })(argv).pipe(Effect.provide(Layer.mergeAll(mode.mode === "machine" ? machineConsoleLayer : Layer.empty, cliLayer)));
1615
1723
  const outcome = yield* Effect.result(runStrykerCli({
1616
1724
  program: cliEffect,
1617
1725
  requestRef,
1618
1726
  mode,
1619
1727
  runMutationTest,
1620
- recordExitCode,
1621
- argv
1728
+ argv,
1729
+ lastSignal
1622
1730
  }, createRunEventStream));
1623
1731
  return Result.isFailure(outcome) ? outcome.failure : outcome.success;
1624
1732
  });
@@ -1627,19 +1735,34 @@ function strykerCliEffect(argv, runMutationTest, recordExitCode, detectMode, cre
1627
1735
  //#region src/main.ts
1628
1736
  const EXIT_CODE_RUN_NEVER_REACHED_ITS_FINALIZER = 1;
1629
1737
  process.title = "stryker";
1630
- if (!semver.satisfies(process.version, strykerEngines.node)) throw new Error(`Node.js version ${process.version} detected. StrykerJS requires version to match ${strykerEngines.node}. Please update your Node.js version or visit https://nodejs.org/ for additional instructions`);
1631
- const resolvedExitCode = { current: EXIT_CODE_RUN_NEVER_REACHED_ITS_FINALIZER };
1738
+ const lastSignal = observeTerminatingSignal();
1739
+ 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));
1743
+ const major = parts[0] ?? 0;
1744
+ const minor = parts[1] ?? 0;
1745
+ const patch = parts[2] ?? 0;
1746
+ if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) return false;
1747
+ if (major !== 20) return major > 20;
1748
+ if (minor !== 0) return minor > 0;
1749
+ return patch >= 0;
1750
+ }
1751
+ if (!isSupportedNodeVersion(process.version)) throw new Error(`Node.js version ${process.version} detected. StrykerJS requires version to match ${strykerEngines.node}. Please update your Node.js version or visit https://nodejs.org/ for additional instructions`);
1632
1752
  const program = Effect.gen(function* () {
1633
1753
  const outputMode = yield* OutputModeProbe;
1634
1754
  const runEvents = yield* RunEventStreamPort;
1635
- return yield* strykerCliEffect(process.argv.slice(2), void 0, (code) => {
1636
- resolvedExitCode.current = code;
1637
- }, outputMode.detectMode, runEvents.createRunEventStream);
1638
- }).pipe(Effect.provide(Layer.merge(OutputModeProbeLive, RunEventStreamLive).pipe(Layer.provide(NodeStdio.layer))));
1755
+ 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))));
1639
1757
  NodeRuntime.runMain(program, {
1640
1758
  disableErrorReporting: true,
1641
- teardown: (_exit, onExit) => {
1642
- onExit(resolvedExitCode.current);
1759
+ teardown: (exit, onExit) => {
1760
+ if (Exit.isSuccess(exit) && typeof exit.value === "number") {
1761
+ onExit(exit.value);
1762
+ return;
1763
+ }
1764
+ const signal = lastSignal();
1765
+ onExit(signal === null ? EXIT_CODE_RUN_NEVER_REACHED_ITS_FINALIZER : 128 + signal);
1643
1766
  }
1644
1767
  });
1645
1768
  //#endregion