@systemfsoftware/stryker-js-cli 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/main.mjs +254 -103
  3. package/package.json +15 -15
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @systemfsoftware/stryker-js-cli
2
2
 
3
+ ## 3.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - A `--survivors` run given a prior report that exists but does not parse now stops and names the parse failure. It previously reported that no prior report was found — the same message as an absent report — so a corrupted or truncated report looked like a missing one.
8
+
9
+ The exit code is unchanged, and so is every admission verdict: a report whose recorded options, framework version or source content disagree with the current run still reports a mismatch, a report a `--survivors` run produced itself is still refused, an absent report still reports that none was found, and a report with no surviving mutants still ends the run without re-testing.
10
+
11
+ - Fix the CLI refusing to start with a missing-flag error when optional boolean flags were omitted. Starting a mutation run, printing help, or emitting the agent manifest now works without explicitly passing `incremental`, `force`, `ignoreStatic`, or the other boolean flags.
12
+
13
+ - Updated dependencies:
14
+ - @systemfsoftware/effect-cell-types@4.0.0
15
+ - @systemfsoftware/stryker-js-mutation-run@4.0.0
16
+
3
17
  ## 3.0.0
4
18
 
5
19
  ### Major Changes
package/dist/main.mjs CHANGED
@@ -34,9 +34,9 @@ import { strykerPlugins } from "@systemfsoftware/stryker-js-mutation-report/stry
34
34
  import { createHash } from "node:crypto";
35
35
  import { readFileSync } from "node:fs";
36
36
  import { resolve } from "node:path";
37
- import { noopLogger } from "@stryker-mutator/util";
38
37
  import { Cell, Workflow } from "@systemfsoftware/effect-cell-types";
39
38
  import "@systemfsoftware/stryker-js-plugin-api/core";
39
+ import { noopLogger } from "@systemfsoftware/stryker-js-util";
40
40
  import * as Exit from "effect/Exit";
41
41
  import { pipe } from "effect/Function";
42
42
  import * as Match from "effect/Match";
@@ -670,10 +670,6 @@ function sortKeys(value) {
670
670
  if (isRecord(value)) return objectFromEntries(objectKeys(value).sort().map((key) => [key, sortKeys(value[key])]));
671
671
  return value;
672
672
  }
673
- /** The single structural hash the admission compares (KTD6). */
674
- function structuralHash(input, hash) {
675
- return hash(serializeSurvivorsHashInput(input));
676
- }
677
673
  /**
678
674
  * Converts a report mutant (1-based schema location) into the internal mutant
679
675
  * shape a run consumes (0-based positions, absolute file name) — the exact
@@ -729,6 +725,106 @@ function survivorMutateSpans(survivors) {
729
725
  }
730
726
  return spans;
731
727
  }
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
+ });
780
+ /**
781
+ * Decodes a prior report read from disk. Pure, so it runs in the decode phase, whose
782
+ * `Left` is fatal by construction — it reaches the derived error channel and no write
783
+ * runs. A malformed report therefore never reaches the decider, and nothing here casts
784
+ * a third-party report type.
785
+ */
786
+ 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
+ }) {};
732
828
  const NO_REPORT_DETAIL = "No prior mutation report found — a --survivors run needs the report of a previous run.";
733
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.";
734
830
  const MISMATCH_DETAIL = "The prior mutation report does not match the current run (resolved options, framework version, or source content differ).";
@@ -737,19 +833,23 @@ function priorSourceHashes(priorReport, hashContent) {
737
833
  return objectFromEntries(objectEntries(priorReport.files).map(([file, fileResult]) => [file, sourceContentHash(fileResult.source, hashContent)]));
738
834
  }
739
835
  /**
740
- * Whether the admission hashes agree: the prior report's embedded resolved
741
- * options, framework version and source content against the current run's.
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.
742
842
  */
743
843
  function hashesMatch(priorReport, input) {
744
- return structuralHash({
844
+ return serializeSurvivorsHashInput({
745
845
  resolvedOptions: stripSurvivorsKeys(priorReport.config),
746
- frameworkVersion: priorReport.framework?.version,
747
- sourceContentHashes: priorSourceHashes(priorReport, input.hashContent)
748
- }, input.hashContent) === structuralHash({
846
+ frameworkVersion: priorReport.frameworkVersion,
847
+ sourceContentHashes: input.priorSourceHashes
848
+ }) === serializeSurvivorsHashInput({
749
849
  resolvedOptions: stripSurvivorsKeys(input.currentConfig),
750
850
  frameworkVersion: input.frameworkVersion,
751
851
  sourceContentHashes: input.sourceContentHashes
752
- }, input.hashContent);
852
+ });
753
853
  }
754
854
  const rejection = (reason, detail) => ({
755
855
  kind: "reject",
@@ -760,31 +860,15 @@ function admissionVerdict(input) {
760
860
  const priorReport = input.priorReport;
761
861
  if (priorReport === void 0) return rejection("no-report", NO_REPORT_DETAIL);
762
862
  if (wasProducedBySurvivorsRun(priorReport)) return rejection("mismatch", SURVIVORS_RUN_SOURCE_DETAIL);
763
- const survivors = extractSurvivors(priorReport, input.resolveAbsolutePath);
764
- if (survivors.length === 0) return { kind: "no-survivors" };
863
+ if (input.priorSurvivors.length === 0) return { kind: "no-survivors" };
765
864
  if (!hashesMatch(priorReport, input)) return rejection("mismatch", MISMATCH_DETAIL);
766
865
  return {
767
866
  kind: "admit",
768
- survivors
867
+ survivors: input.priorSurvivors
769
868
  };
770
869
  }
771
870
  const SurvivorsAdmissionTypeId = Symbol.for("@systemfsoftware/stryker-js-cli/SurvivorsAdmission");
772
- var Admitted = class extends S.TaggedClass()("Admitted", { survivors: S.Array(S.Struct({
773
- id: S.String,
774
- fileName: S.String,
775
- mutatorName: S.String,
776
- replacement: S.String,
777
- location: S.Struct({
778
- start: S.Struct({
779
- line: S.Finite,
780
- column: S.Finite
781
- }),
782
- end: S.Struct({
783
- line: S.Finite,
784
- column: S.Finite
785
- })
786
- })
787
- })) }) {
871
+ var Admitted = class extends S.TaggedClass()("Admitted", { survivors: S.Array(MutantShape) }) {
788
872
  [SurvivorsAdmissionTypeId] = SurvivorsAdmissionTypeId;
789
873
  };
790
874
  var NoSurvivors = class extends S.TaggedClass()("NoSurvivors", {}) {
@@ -804,7 +888,7 @@ var SurvivorsRejection = class extends S.TaggedError()("SurvivorsRejection", {
804
888
  * the same reject outcome with different reasons; only the rejection's
805
889
  * remediation names the full run to do first (R10).
806
890
  */
807
- const admitSurvivorsRun = Workflow.make((command) => Match.value(admissionVerdict(command)).pipe(Match.discriminator("kind")("reject", (verdict) => Result.fail(SurvivorsRejection.make({
891
+ const admitSurvivorsRun = Workflow.make(AdmitSurvivorsRunCommand, (command) => Match.value(admissionVerdict(command)).pipe(Match.discriminator("kind")("reject", (verdict) => Result.fail(SurvivorsRejection.make({
808
892
  reason: verdict.reason,
809
893
  remediation: verdict.remediation
810
894
  }))), Match.discriminator("kind")("no-survivors", () => Result.succeed(NoSurvivors.make())), Match.discriminator("kind")("admit", (verdict) => Result.succeed(Admitted.make({ survivors: verdict.survivors }))), Match.exhaustive));
@@ -812,10 +896,14 @@ const admitSurvivorsRun = Workflow.make((command) => Match.value(admissionVerdic
812
896
  //#region src/SurvivorsExit.ts
813
897
  /** The exit class a rejected survivors run exits with (R6: exit 2). */
814
898
  const SURVIVORS_REJECT_EXIT_CLASS = ExitClass.ConfigError;
815
- S.Union([S.TaggedStruct("run", {
899
+ //#endregion
900
+ //#region src/cli-request.schema.ts
901
+ const RunRequestSchema = S.TaggedStruct("run", {
816
902
  options: S.Any,
817
903
  survivors: S.Boolean
818
- }), S.TaggedStruct("llms", { document: S.Any })]);
904
+ });
905
+ const LlmsRequestSchema = S.TaggedStruct("llms", { document: S.Any });
906
+ S.Union([RunRequestSchema, LlmsRequestSchema]);
819
907
  //#endregion
820
908
  //#region src/StrykerCliExecutor.ts
821
909
  /**
@@ -866,48 +954,80 @@ function hostOptionsOf(mode, stream) {
866
954
  * stashed context back and dispatches the decision to the verdict/run,
867
955
  * failing the run with a rejection.
868
956
  */
869
- const survivorsAdmissionDescription = (runMutationTest, stream, mode, runContext) => pipe(Cell.read((cliOptions) => Effect.promise(() => resolveSurvivorsRunOptions(cliOptions)).pipe(Effect.flatMap((resolvedOptions) => {
870
- const priorReportPath = priorReportPathOf(resolvedOptions);
871
- const priorReport = readPriorReport(priorReportPath);
872
- return Ref.set(runContext, {
873
- resolvedOptions,
874
- priorReportPath
875
- }).pipe(Effect.as({
876
- resolvedOptions,
877
- priorReport,
878
- priorReportPath,
879
- sourceContentHashes: sourceContentHashesOf(priorReport)
880
- }));
881
- }))), Cell.decode(({ resolvedOptions, priorReport, sourceContentHashes }) => Result.succeed({
882
- priorReport,
883
- currentConfig: resolvedOptions,
884
- frameworkVersion: strykerVersion,
885
- sourceContentHashes,
886
- hashContent,
887
- resolveAbsolutePath
888
- })), Cell.decide(admitSurvivorsRun), Cell.encode((outcome) => outcome), Cell.write((outcome) => Effect.flatMap(Ref.get(runContext), (context) => {
889
- if (context === void 0) return Effect.die("the survivors admission read must run before its write");
890
- const { resolvedOptions, priorReportPath } = context;
891
- return Result.match(outcome, {
892
- onSuccess: (decision) => Match.value(decision).pipe(Match.tag("NoSurvivors", () => Effect.sync(() => emitEmptySurvivorsVerdict(stream, mode, resolvedOptions))), Match.tag("Admitted", (admitted) => {
893
- const restricted = {
894
- ...resolvedOptions,
895
- survivors: admitted.survivors,
896
- mutate: survivorMutateSpans(admitted.survivors),
897
- survivorsPriorReport: priorReportPath,
898
- incremental: false
899
- };
900
- return Effect.promise(() => runMutationTest(restricted));
901
- }), Match.orElse(() => Effect.die("unreachable admission decision variant"))),
902
- onFailure: (rejection) => Effect.fail(rejection)
903
- });
904
- })));
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
+ );
905
1019
  /**
906
1020
  * The `--survivors` request: re-test exactly the prior report's survivor set.
907
1021
  * The survivors flag was parsed as a boolean; the admission decides between
908
1022
  * running the survivors and the plain pipeline. The chain's order is carried by
909
1023
  * the description's phase types; the run's resolved context cell is created
910
1024
  * here, beside the description it feeds.
1025
+ *
1026
+ * Two failures reach the caller, and they are not the same thing. A rejection is the
1027
+ * decision's own outcome — the run was inspected and refused. A `SchemaError` is a prior
1028
+ * report that was present and did not decode, which stops the chain before any decision
1029
+ * is made; it is in this signature because the phase types put it there, not because the
1030
+ * admission chose it.
911
1031
  */
912
1032
  function runSurvivorsAdmission(runMutationTest, stream, mode, cliOptions) {
913
1033
  return Effect.gen(function* () {
@@ -934,22 +1054,47 @@ function priorReportPathOf(resolved) {
934
1054
  return typeof configured === "string" ? configured : DEFAULT_SURVIVORS_PRIOR_REPORT;
935
1055
  }
936
1056
  /**
937
- * A report is usable only when it parses and carries a `files` dictionary; a
938
- * missing, unreadable or misshapen report is the `no-report` rejection.
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.
939
1062
  */
940
- function isMutationTestResultShape(value) {
941
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
942
- return "files" in value && typeof value.files === "object" && value.files !== null && !Array.isArray(value.files);
943
- }
944
1063
  function readPriorReport(priorReportPath) {
1064
+ let text;
945
1065
  try {
946
- const raw = readFileSync(priorReportPath, "utf-8");
947
- const parsed = JSON.parse(raw);
948
- return isMutationTestResultShape(parsed) ? parsed : void 0;
1066
+ text = readFileSync(priorReportPath, "utf-8");
949
1067
  } catch {
950
- return;
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
+ };
951
1083
  }
952
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
+ }
953
1098
  function readSourceFile(file) {
954
1099
  try {
955
1100
  return readFileSync(file, "utf-8");
@@ -958,15 +1103,13 @@ function readSourceFile(file) {
958
1103
  }
959
1104
  }
960
1105
  /**
961
- * The per-file content hashes of the current sources, keyed by the relative
962
- * file names the prior report uses — the current side of the admission
963
- * comparison (`admitSurvivorsRun` hashes the prior side from the sources the
964
- * report embeds).
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.
965
1109
  */
966
- function sourceContentHashesOf(priorReport) {
1110
+ function currentSourceHashesFor(files) {
967
1111
  const hashes = {};
968
- if (priorReport === void 0) return hashes;
969
- for (const file of Object.keys(priorReport.files)) hashes[file] = sourceContentHash(readSourceFile(file), hashContent);
1112
+ for (const file of files) hashes[file] = sourceContentHash(readSourceFile(file), hashContent);
970
1113
  return hashes;
971
1114
  }
972
1115
  /**
@@ -1145,10 +1288,16 @@ function carriesConfigError(cause) {
1145
1288
  /**
1146
1289
  * Classifies a failed run for the finalizer: usage/parse failures
1147
1290
  * (`CliError` — except a bare help request, which exits 0), rejected
1148
- * survivors runs (`SurvivorsRejection`) and a rejected config (`ConfigError`)
1149
- * all exit 2, all other failures exit 1 (the framework's default). A
1150
- * successful run exits 0; the verdict gates (U5) then resolve the final
1151
- * classed code.
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.
1152
1301
  */
1153
1302
  function resolveCliExitCode(exit) {
1154
1303
  if (Exit.isSuccess(exit)) return 0;
@@ -1159,6 +1308,7 @@ function resolveCliExitCode(exit) {
1159
1308
  if (S.is(CliError.ShowHelp)(value)) return value.errors.length > 0 ? 2 : 0;
1160
1309
  if (CliError.isCliError(value)) return 2;
1161
1310
  if (S.is(SurvivorsRejection)(value)) return SURVIVORS_REJECT_EXIT_CLASS;
1311
+ if (value instanceof S.SchemaError) return SURVIVORS_REJECT_EXIT_CLASS;
1162
1312
  }
1163
1313
  if (carriesConfigError(exit.cause)) return ExitClass.ConfigError;
1164
1314
  return 1;
@@ -1254,10 +1404,11 @@ const optional = (option) => Flag.optional(option);
1254
1404
  /**
1255
1405
  * Commander left an omitted flag out of the parsed options; `deepMerge` treats
1256
1406
  * `undefined` as absent but an explicit `false` would override a config-file
1257
- * `true`. The framework's boolean defaults to `false` when absent, so map that
1258
- * back to `undefined` (KTD4).
1407
+ * `true`. `Flag.optional` yields `Option.none` when the flag is absent and
1408
+ * `Option.some(false)` for an explicit `--no-x`, so both map back to
1409
+ * `undefined` and leave the config-file default in force (KTD4).
1259
1410
  */
1260
- const absentWhenFalse = (value) => value ? true : void 0;
1411
+ const absentWhenFalse = (value) => Option.isSome(value) && value.value ? true : void 0;
1261
1412
  const LOG_LEVELS = [
1262
1413
  "fatal",
1263
1414
  "error",
@@ -1285,15 +1436,15 @@ function setLogLevel(target, key, value) {
1285
1436
  }
1286
1437
  const runOptions = {
1287
1438
  ignorePatterns: Flag.string("ignorePatterns").pipe(Flag.withDescription("A comma separated list of patterns used for specifying which files need to be ignored. This should only be used in cases where you experience a slow Stryker startup, because too many (or too large) files are copied to the sandbox that are not needed to run the tests. For example, image or movie directories. Note: This option will have NO effect when using the `--inPlace` option. The directories `node_modules`, `.git` and some others are always ignored. Example: `--ignorePatterns dist`. These patterns are ALWAYS ignored: [`node_modules`, `.git`, `/reports`, `*.tsbuildinfo`, `/stryker.log`, `.stryker-tmp`]. Because Stryker always ignores these, you should rarely have to adjust the `ignorePatterns` setting at all. This is useful to speed up Stryker by reducing the size of the sandbox directory which has a positive effect on performance."), Flag.map(splitOnComma), optional),
1288
- ignoreStatic: Flag.map(Flag.boolean("ignoreStatic"), absentWhenFalse).pipe(Flag.withDescription("Ignore static mutants. Static mutants are mutants which are only executed during the loading of a file.")),
1289
- incremental: Flag.map(Flag.boolean("incremental"), absentWhenFalse).pipe(Flag.withDescription("Enable 'incremental mode'. Stryker will store results in a file and use that file to speed up the next --incremental run")),
1290
- allowEmpty: Flag.map(Flag.boolean("allowEmpty"), absentWhenFalse).pipe(Flag.withDescription("Allows stryker to exit without any errors in cases where no tests are found")),
1439
+ ignoreStatic: Flag.map(optional(Flag.boolean("ignoreStatic")), absentWhenFalse).pipe(Flag.withDescription("Ignore static mutants. Static mutants are mutants which are only executed during the loading of a file.")),
1440
+ incremental: Flag.map(optional(Flag.boolean("incremental")), absentWhenFalse).pipe(Flag.withDescription("Enable 'incremental mode'. Stryker will store results in a file and use that file to speed up the next --incremental run")),
1441
+ allowEmpty: Flag.map(optional(Flag.boolean("allowEmpty")), absentWhenFalse).pipe(Flag.withDescription("Allows stryker to exit without any errors in cases where no tests are found")),
1291
1442
  incrementalFile: Flag.string("incrementalFile").pipe(Flag.withDescription("Specify the file to use for incremental mode."), optional),
1292
- force: Flag.map(Flag.boolean("force"), absentWhenFalse).pipe(Flag.withDescription("Run all mutants, even if --incremental is provided and an incremental file exists. Can be used to force a rebuild of the incremental file.")),
1443
+ force: Flag.map(optional(Flag.boolean("force")), absentWhenFalse).pipe(Flag.withDescription("Run all mutants, even if --incremental is provided and an incremental file exists. Can be used to force a rebuild of the incremental file.")),
1293
1444
  mutate: Flag.string("mutate").pipe(Flag.withAlias("m"), Flag.withDescription("With `mutate` you configure the subset of files or just one specific file to be mutated. These should be your _production code files_, and definitely not your test files. (Whereas with `ignorePatterns` you prevent non-relevant files from being copied to the sandbox directory in the first place)\nThe default will try to guess your production code files based on sane defaults. It reads like this:\n- Include all js-like files inside the `src` or `lib` dir\n- Except files inside `__tests__` directories and file names ending with `test` or `spec`.\nIf the defaults are not sufficient for you, for example in a angular project you might want to **exclude** not only the `*.spec.ts` files but other files too, just like the default already does.\nIt is possible to override the defaults by: - supplying one or more [glob patterns](https://github.com/isaacs/minimatch) to include (e.g. `src/**/*.js`) - or one or more comma separated glob patterns preceded with `!` to exclude (e.g. `!src/**/*.spec.js`) - or both (e.g. `src/**/*.js,!src/**/*.spec.js`).\nNote: Stryker will use [minimatch](https://github.com/isaacs/minimatch) for parsing these patterns, see minimatch for the exact syntax."), Flag.map(splitOnComma), optional),
1294
1445
  testFiles: Flag.string("testFiles").pipe(Flag.withAlias("t"), Flag.withDescription("With `testFiles` you can limit which test files are executed during mutation testing. When specified, only tests from these files will be run. This allows you to verify that a module's dedicated unit tests can kill all its mutants independently."), Flag.map(splitOnComma), optional),
1295
1446
  buildCommand: Flag.string("buildCommand").pipe(Flag.withAlias("b"), Flag.withDescription("Configure a build command to run after mutating the code, but before mutants are tested. This is generally used to transpile your code before testing. Only configure this if your test runner doesn't take care of this already and you're not using just-in-time transpiler like `babel/register` or `ts-node`."), optional),
1296
- dryRunOnly: Flag.map(Flag.boolean("dryRunOnly"), absentWhenFalse).pipe(Flag.withDescription("Execute the initial test run only, without doing actual mutation testing. Doing a dry run only can be used to test that StrykerJS can run your test setup, for example, in CI pipelines.")),
1447
+ dryRunOnly: Flag.map(optional(Flag.boolean("dryRunOnly")), absentWhenFalse).pipe(Flag.withDescription("Execute the initial test run only, without doing actual mutation testing. Doing a dry run only can be used to test that StrykerJS can run your test setup, for example, in CI pipelines.")),
1297
1448
  checkers: Flag.string("checkers").pipe(Flag.withDescription("A comma separated list of checkers to use, for example --checkers typescript"), Flag.map(splitOnComma), optional),
1298
1449
  checkerNodeArgs: Flag.string("checkerNodeArgs").pipe(Flag.withDescription("A list of node args to be passed to checker child processes. Split on spaces (commander characterization): `--checkerNodeArgs \"--inspect-brk --trace-warnings\"`."), Flag.map(splitOnSpace), optional),
1299
1450
  coverageAnalysis: Flag.choice("coverageAnalysis", [
@@ -1311,21 +1462,21 @@ const runOptions = {
1311
1462
  dryRunTimeoutMinutes: Flag.float("dryRunTimeoutMinutes").pipe(Flag.withDescription("Configure an absolute timeout for the initial test run. (It can take a while.)"), optional),
1312
1463
  maxConcurrentTestRunners: Flag.integer("maxConcurrentTestRunners").pipe(Flag.withDescription("Set the number of max concurrent test runner to spawn (default: cpuCount)"), optional),
1313
1464
  concurrency: Flag.string("concurrency").pipe(Flag.withAlias("c"), Flag.withDescription("Set the concurrency of workers. Stryker will always run checkers and test runners in parallel by creating worker processes (default: cpuCount - 1)"), Flag.map(parseConcurrency), optional),
1314
- disableBail: Flag.map(Flag.boolean("disableBail"), absentWhenFalse).pipe(Flag.withDescription("Force the test runner to keep running tests, even when a mutant is already killed.")),
1465
+ 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.")),
1315
1466
  maxTestRunnerReuse: Flag.integer("maxTestRunnerReuse").pipe(Flag.withDescription("Restart each test runner worker process after `n` runs. Not recommended unless you are experiencing memory leaks that you are unable to resolve. Configuring `0` here means infinite reuse."), optional),
1316
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),
1317
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),
1318
- inPlace: Flag.map(Flag.boolean("inPlace"), absentWhenFalse).pipe(Flag.withDescription("Determines whether or not Stryker should mutate your files in place. Note: mutating your files in place is generally not needed for mutation testing, unless you have a dependency in your project that is really dependent on the file locations (like \"app-root-path\" for example).\nWhen `true`, Stryker will override your files, but it will keep a copy of the originals in the temp directory (using `tempDirName`) and it will place the originals back after it is done. Also with `true` the `ignorePatterns` has no effect any more.\nWhen `false` (default) Stryker will work in the copy of your code inside the temp directory.")),
1469
+ 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.")),
1319
1470
  tempDirName: Flag.string("tempDirName").pipe(Flag.withDescription("Set the name of the directory that is used by Stryker as a working directory. This directory will be cleaned after a successful run"), optional),
1320
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),
1321
- survivors: Flag.map(Flag.boolean("survivors"), absentWhenFalse).pipe(Flag.withDescription("Re-run only the mutants that survived a previous run. Admits against the previous run's mutation report (the `survivorsPriorReport` config option, default `reports/mutation-report.json`) and re-tests exactly the survivor set. Exits 2 with a remediation naming a full run when the report is missing, drifted, or the configuration changed; exits 0 with a null score when the report has no survivors."))
1472
+ 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."))
1322
1473
  };
1323
1474
  const runArgs = { configFile: Argument.optional(Argument.string("configFile")) };
1324
1475
  const runConfig = {
1325
1476
  ...runOptions,
1326
1477
  ...runArgs
1327
1478
  };
1328
- const rootConfig = { llms: Flag.map(Flag.boolean("llms"), absentWhenFalse).pipe(Flag.withDescription("Print the agent-facing command manifest as one JSON object on stdout: every option, alias, kind, default, allowed value set and description, plus the subcommands and positional arguments, walked from the command descriptors.")) };
1479
+ const rootConfig = { llms: Flag.map(optional(Flag.boolean("llms")), absentWhenFalse).pipe(Flag.withDescription("Print the agent-facing command manifest as one JSON object on stdout: every option, alias, kind, default, allowed value set and description, plus the subcommands and positional arguments, walked from the command descriptors.")) };
1329
1480
  function unwrap(value) {
1330
1481
  if (Option.isOption(value)) return Option.match(value, {
1331
1482
  onNone: () => void 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@systemfsoftware/stryker-js-cli",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/systemfsoftware/systemfsoftware.git",
@@ -14,15 +14,15 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@effect/platform-node": "^4.0.0-rc.108",
18
- "@effect/platform-node-shared": "^4.0.0-rc.108",
19
- "@stryker-mutator/util": "^9.6.1",
20
- "effect": "4.0.0-rc.108",
17
+ "@effect/platform-node": "^4.0.0-rc.111",
18
+ "@effect/platform-node-shared": "^4.0.0-rc.111",
19
+ "effect": "4.0.0-rc.111",
21
20
  "semver": "^7.7.0",
22
- "@systemfsoftware/stryker-js-mutation-report": "^1.2.7",
23
- "@systemfsoftware/effect-cell-types": "^3.0.0",
24
- "@systemfsoftware/stryker-js-mutation-run": "^3.0.0",
25
- "@systemfsoftware/stryker-js-plugin-api": "^2.0.0"
21
+ "@systemfsoftware/stryker-js-plugin-api": "^2.1.0",
22
+ "@systemfsoftware/stryker-js-mutation-run": "^4.0.0",
23
+ "@systemfsoftware/stryker-js-mutation-report": "^1.2.8",
24
+ "@systemfsoftware/stryker-js-util": "^0.1.0",
25
+ "@systemfsoftware/effect-cell-types": "^4.0.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@systemfsoftware/arethetypeswrong-cli": "^1.1.1",
@@ -35,15 +35,15 @@
35
35
  "tsdown": "^0.22.14",
36
36
  "vite-tsconfig-paths": "^6.1.1",
37
37
  "vitest": "^4.1.10",
38
- "@systemfsoftware/effect-gherkin-spec": "^2.0.0",
39
- "@systemfsoftware/effect-schema-law": "^0.8.0",
40
- "@systemfsoftware/oxlint-plugin-cell-vocabulary": "^1.2.0",
41
- "@systemfsoftware/effect-schema-vite": "^1.5.3",
38
+ "@systemfsoftware/effect-gherkin-spec": "^2.0.1",
39
+ "@systemfsoftware/effect-schema-law": "^0.9.0",
42
40
  "@systemfsoftware/oxlint-config": "^0.1.0",
41
+ "@systemfsoftware/effect-schema-vite": "^1.5.4",
43
42
  "@systemfsoftware/tsconfig": "^1.3.3",
44
- "@systemfsoftware/oxlint-plugin-effect-entrypoint": "^1.0.4",
43
+ "@systemfsoftware/oxlint-plugin-effect-entrypoint": "^1.0.5",
45
44
  "@systemfsoftware/vitest-config": "^0.1.0",
46
- "@systemfsoftware/oxlint-plugin-test-placement": "^3.0.0"
45
+ "@systemfsoftware/oxlint-plugin-test-placement": "^3.1.0",
46
+ "@systemfsoftware/oxlint-plugin-cell-vocabulary": "^1.2.1"
47
47
  },
48
48
  "engines": {
49
49
  "node": ">=20.0.0"