From 1ab117c44f8c64b253bae696e7a6a0490adc715a Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 13:02:20 +0000 Subject: [PATCH 1/4] feat(repo): publish a dry-run-only run's coverage into the incremental file (#155) A dry-run-only run with incremental mode on now writes its dry-run coverage into the incremental file, keeping any verdicts already there, so a later run that reads the file through incrementalSources skips its own dry run. The run-inputs digest no longer covers dryRunOnly, so a dry-run-only preflight and the runs that reuse its coverage share one digest; existing caches are refused once with runInputsChanged. (cherry picked from eafe8f3a9) Verdict-Semantics: unchanged --- .changeset/petite-insects-feel.md | 5 + packages/stryker-js/etc/stryker-js.api.md | 2 +- .../src/mutation-reporting.schema.ts | 2 + .../src/mutation-reporting.service.ts | 38 ++++++- .../stryker-js/src/run/mutation-test.cell.ts | 13 ++- packages/stryker-js/src/verdict-semantics.ts | 2 +- .../incremental-reuse.integration.test.ts | 100 ++++++++++++++++++ 7 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 .changeset/petite-insects-feel.md diff --git a/.changeset/petite-insects-feel.md b/.changeset/petite-insects-feel.md new file mode 100644 index 000000000..44356c3ab --- /dev/null +++ b/.changeset/petite-insects-feel.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js": minor +--- + +A dry-run-only run with incremental mode on publishes its dry-run coverage to the incremental file, keeping any verdicts already there, so a later run that reads the file through incrementalSources skips its own dry run. The run-inputs digest no longer covers dryRunOnly, so a preflight and the runs that reuse its coverage share one digest; existing caches are refused once with runInputsChanged. diff --git a/packages/stryker-js/etc/stryker-js.api.md b/packages/stryker-js/etc/stryker-js.api.md index e9d0a4e4c..348c3ac94 100644 --- a/packages/stryker-js/etc/stryker-js.api.md +++ b/packages/stryker-js/etc/stryker-js.api.md @@ -41,7 +41,7 @@ export namespace Mcp { // @public (undocumented) export namespace Plugin { - export { AnyPluginDescriptor, AnyWorkerPluginDescriptor, AnyWorkerPluginSource, EvaluatorPluginDescriptor, EvaluatorPluginSource, Framework, Ignorer, LoadedPlugins, Node_2 as Node, PluginDescriptor, PluginDescriptorOf, PluginKind, PluginSource, PooledTestRunner, PooledTestRunnerError, REPORTER_EVENT_BATCH_BOUND, ReporterStage, ReporterWorkerClient, SpawnReporterWorkerParams, TestRunnerBuildContext, WorkerPluginDescriptor, WorkerPluginSource, buildTestRunner, isCommandRunner, isVmRunner, makeChildProcessTestRunner, reporterWorkerFactory, spawnReporterWorker, testRunnerConfigOf, vmRunnerPluginUrl, vmTestRunnerConfig }; + export { AnyPluginDescriptor, AnyWorkerPluginDescriptor, AnyWorkerPluginSource, EvaluatorPluginDescriptor, EvaluatorPluginSource, Framework, Ignorer, LoadedPlugins, Node, PluginDescriptor, PluginDescriptorOf, PluginKind, PluginSource, PooledTestRunner, PooledTestRunnerError, REPORTER_EVENT_BATCH_BOUND, ReporterStage, ReporterWorkerClient, SpawnReporterWorkerParams, TestRunnerBuildContext, WorkerPluginDescriptor, WorkerPluginSource, buildTestRunner, isCommandRunner, isVmRunner, makeChildProcessTestRunner, reporterWorkerFactory, spawnReporterWorker, testRunnerConfigOf, vmRunnerPluginUrl, vmTestRunnerConfig }; } // @public (undocumented) diff --git a/packages/stryker-js/src/mutation-reporting.schema.ts b/packages/stryker-js/src/mutation-reporting.schema.ts index 229beec0c..a4ab614ab 100644 --- a/packages/stryker-js/src/mutation-reporting.schema.ts +++ b/packages/stryker-js/src/mutation-reporting.schema.ts @@ -10,3 +10,5 @@ export class ManifestUnreadable extends S.TaggedError()('Man } export const ManifestSchema = S.Struct({ version: S.optional(S.String) }) + +export const IncrementalReportObjectSchema = S.Record(S.String, S.Json) diff --git a/packages/stryker-js/src/mutation-reporting.service.ts b/packages/stryker-js/src/mutation-reporting.service.ts index 7a8c78a2e..870d32ebd 100644 --- a/packages/stryker-js/src/mutation-reporting.service.ts +++ b/packages/stryker-js/src/mutation-reporting.service.ts @@ -46,7 +46,7 @@ import { TimeoutEvidenceSchema } from './IncrementalDiff.schema.js' import { mutantCostsOf } from './mutant-cost-model.js' import { costTotalMsOf } from './mutant-cost.js' import type { MutantCost, MutantCostModel } from './MutantCost.schema.js' -import { ManifestSchema, ManifestUnreadable } from './mutation-reporting.schema.js' +import { IncrementalReportObjectSchema, ManifestSchema, ManifestUnreadable } from './mutation-reporting.schema.js' import type { ResolvedMode } from './output-mode.schema.js' import { ProjectFiles, type ProjectFilesShape } from './project-files.service.js' import type { Project, ProjectFile } from './Project.schema.js' @@ -149,6 +149,7 @@ export interface MutationReportingService { input: MutationReportingInput, plannedMutants: readonly Mutant.Mutant[], ) => Effect.Effect + readonly publishDryRunCoverage: (input: MutationReportingInput) => Effect.Effect } export class MutationReporting extends Context.Service()( @@ -174,6 +175,7 @@ export class MutationReporting extends Context.Service mapRunResult(mutant, result), reportAll: (input) => reportAll(deps, input), checkpoint: (input, plannedMutants) => checkpoint(deps, input, plannedMutants), + publishDryRunCoverage: (input) => publishDryRunCoverage(deps, input), }) }), ) @@ -996,6 +998,40 @@ const checkpoint = ( onFalse: () => Effect.void, }) +const priorIncrementalObjectOf = (text: string): Option.Option => + S.decodeOption(S.fromJsonString(IncrementalReportObjectSchema))(text) + +const dryRunCoverageReportOf = Effect.fnUntraced(function*( + deps: MutationReportingDeps, + input: MutationReportingInput, + coverage: DryRunCoverage, +) { + const prior = yield* deps.fs.readFileString(input.options.incrementalFile).pipe(Effect.option) + return yield* Option.match(Option.flatMap(prior, priorIncrementalObjectOf), { + onSome: (report) => Effect.succeed({ ...report, dryRunCoverage: coverage }), + onNone: () => slimIncrementalReport(deps, input, []), + }) +}) + +const writeDryRunCoverage = Effect.fnUntraced(function*( + deps: MutationReportingDeps, + input: MutationReportingInput, + coverage: DryRunCoverage, +) { + const report = yield* dryRunCoverageReportOf(deps, input, coverage) + const json = yield* S.encodeEffect(S.fromJsonString(S.Unknown))(report).pipe(Effect.orDie) + yield* writeFileAtomic(deps, input.options.incrementalFile, json) +}) + +const publishDryRunCoverage = (deps: MutationReportingDeps, input: MutationReportingInput) => + Option.match( + Option.filter(Option.fromUndefinedOr(input.testCoverage.dryRunCoverage), () => input.options.incremental), + { + onNone: () => Effect.void, + onSome: (coverage) => writeDryRunCoverage(deps, input, coverage), + }, + ) + if (import.meta.vitest !== void 0) { const { it } = await import('@systemfsoftware/vitest') const { diff --git a/packages/stryker-js/src/run/mutation-test.cell.ts b/packages/stryker-js/src/run/mutation-test.cell.ts index 2b9a17ba0..25f225585 100644 --- a/packages/stryker-js/src/run/mutation-test.cell.ts +++ b/packages/stryker-js/src/run/mutation-test.cell.ts @@ -88,7 +88,15 @@ const writeMutationTestNoTests = Effect.fn(SpanTaxonomy.Spans.mutationTestNoTest return { results: [], verdict: null } }) -const writeMutationTestDryRunOnly = Effect.fn(SpanTaxonomy.Spans.mutationTestDryRunOnly.name)(function*() { +const writeMutationTestDryRunOnly = Effect.fn(SpanTaxonomy.Spans.mutationTestDryRunOnly.name)(function*( + raw: MutationTestRaw, +) { + const reporting = yield* MutationReporting + const env = yield* RunEnvironment + yield* reporting.publishDryRunCoverage(reportingInputOf({ prev: raw.prev, env, results: [] })).pipe( + Effect.tapCause((cause) => Effect.logWarning('Failed to publish the dry-run coverage', cause)), + Effect.ignoreCause, + ) yield* phaseEntered('mutation-test') yield* Effect.logInfo('The dry-run has been completed successfully. No mutations have been executed.') return { results: [], verdict: null } @@ -311,7 +319,8 @@ export const mutationTestCell = Sandwich.named( return raw }) ).decide(admitMutationTest).write({ - MutationTestDryRunOnly: (_decision, raw) => writeMutationTestOutcome({ raw, outcome: writeMutationTestDryRunOnly() }), + MutationTestDryRunOnly: (_decision, raw) => + writeMutationTestOutcome({ raw, outcome: writeMutationTestDryRunOnly(raw) }), MutationTestNoTests: (_decision, raw) => writeMutationTestOutcome({ raw, outcome: writeMutationTestNoTests() }), MutationTestProceed: (_decision, raw) => writeMutationTestOutcome({ raw, outcome: writeMutationTestProceed(raw) }), MutationTestError: ({ stage, reason }) => diff --git a/packages/stryker-js/src/verdict-semantics.ts b/packages/stryker-js/src/verdict-semantics.ts index 057d818e5..e6982dec6 100644 --- a/packages/stryker-js/src/verdict-semantics.ts +++ b/packages/stryker-js/src/verdict-semantics.ts @@ -72,7 +72,7 @@ const withoutVersionOf = (value: Json): Json => onNone: () => value, }) -const scopeOptionKeys: readonly string[] = ['mutate', 'since', 'mutantIds'] +const scopeOptionKeys: readonly string[] = ['mutate', 'since', 'mutantIds', 'dryRunOnly'] const presentationOptionKeys: readonly string[] = [ 'reporters', diff --git a/packages/stryker-js/tests/incremental-reuse.integration.test.ts b/packages/stryker-js/tests/incremental-reuse.integration.test.ts index 142072904..fcf1b5e7d 100644 --- a/packages/stryker-js/tests/incremental-reuse.integration.test.ts +++ b/packages/stryker-js/tests/incremental-reuse.integration.test.ts @@ -658,6 +658,106 @@ Feature('Content-keyed reuse across incremental reports') ), ) + scenario( + 'A dry-run-only preflight publishes its coverage, and a run reading it through incrementalSources skips its own dry run', + Gherkin.Do.pipe( + Given('a workspace whose command runner appends every spawn to a log file')( + 'fixture', + () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const root = yield* writeFixture([['src/math.ts', SOURCE]]) + const spawnLog = yield* fs.makeTempFile({ prefix: 'runner-spawns', suffix: '.txt' }) + const commandRunner = { command: `echo spawned >> ${spawnLog}` } + return yield* Effect.ensuring( + Effect.gen(function*() { + const cold = yield* runOnce( + root, + optionsOf(root, { commandRunner, incrementalFile: `${root}/reports/cold.json` }), + ) + const spawnsInColdRun = yield* lineCountOf(spawnLog) + yield* runOnce( + root, + optionsOf(root, { + commandRunner, + dryRunOnly: true, + incrementalFile: `${root}/reports/preflight.json`, + }), + ) + const spawnsAfterPreflight = yield* lineCountOf(spawnLog) + const preflighted = yield* runOnce( + root, + optionsOf(root, { commandRunner, incrementalSources: ['reports/preflight.json'] }), + ) + const spawnsAfterPreflighted = yield* lineCountOf(spawnLog) + return { + cold, + preflighted, + spawnsInColdRun, + spawnsInPreflight: spawnsAfterPreflight - spawnsInColdRun, + spawnsInPreflightedRun: spawnsAfterPreflighted - spawnsAfterPreflight, + } + }), + Effect.andThen(removeFixture(root), Effect.orDie(fs.remove(spawnLog, { force: true }))), + ) + }).pipe(Effect.orDie, Effect.provide(filePorts)), + ), + Then( + 'the preflight spawns exactly the dry run a cold run spawns, and the preflighted run spawns only its mutants', + )((s, expect) => { + const coldDryRunSpawns = s.fixture.spawnsInColdRun - (s.fixture.cold.reuse?.ran ?? 0) + return expect({ + coldRanMutants: (s.fixture.cold.reuse?.ran ?? 0) > 0, + coldDryRunSpawned: coldDryRunSpawns > 0, + preflightSpawns: s.fixture.spawnsInPreflight, + preflightedRan: s.fixture.preflighted.reuse?.ran, + preflightedDryRunSpawns: s.fixture.spawnsInPreflightedRun - (s.fixture.preflighted.reuse?.ran ?? 0), + }).toEqual({ + coldRanMutants: true, + coldDryRunSpawned: true, + preflightSpawns: coldDryRunSpawns, + preflightedRan: s.fixture.cold.reuse?.ran, + preflightedDryRunSpawns: 0, + }) + }), + ), + ) + + scenario( + 'A dry-run-only run into an existing incremental file keeps the verdicts it holds', + Gherkin.Do.pipe( + Given('a workspace whose incremental file already holds every verdict')( + 'fixture', + () => + Effect.gen(function*() { + const root = yield* writeFixture([['src/math.ts', SOURCE]]) + return yield* Effect.ensuring( + Effect.gen(function*() { + const first = yield* runOnce(root, optionsOf(root)) + yield* runOnce(root, optionsOf(root, { dryRunOnly: true })) + const after = yield* runOnce(root, optionsOf(root)) + return { first, after } + }), + removeFixture(root), + ) + }).pipe(Effect.orDie, Effect.provide(filePorts)), + ), + Then('the run after the dry-run-only run reuses every verdict the first run wrote')((s, expect) => + expect({ + plannedNonZero: s.fixture.first.mutants.length > 0, + after: { + reused: s.fixture.after.reuse?.reused, + ran: s.fixture.after.reuse?.ran, + refused: s.fixture.after.reuse?.refused, + }, + }).toEqual({ + plannedNonZero: true, + after: { reused: s.fixture.first.mutants.length, ran: 0, refused: ZERO_REFUSALS }, + }) + ), + ), + ) + scenario( 'A verdict written under another report path is reused through the incrementalSources globs', Gherkin.Do.pipe( From b6031b21a32f5357bb546b03319d81f9d5b18a88 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:07:38 +0000 Subject: [PATCH 2/4] chore(repo): keep the api report CI renders for the Plugin namespace Local builds render the Plugin namespace export as Node and CI renders Node_2 as Node (docs/solutions/api-extractor-node-alias-nondeterminism.md). #227 committed the local rendering and api:check failed in run 37626449206; main's rendering is the one CI accepts Verdict-Semantics: unchanged --- packages/stryker-js/etc/stryker-js.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stryker-js/etc/stryker-js.api.md b/packages/stryker-js/etc/stryker-js.api.md index 348c3ac94..e9d0a4e4c 100644 --- a/packages/stryker-js/etc/stryker-js.api.md +++ b/packages/stryker-js/etc/stryker-js.api.md @@ -41,7 +41,7 @@ export namespace Mcp { // @public (undocumented) export namespace Plugin { - export { AnyPluginDescriptor, AnyWorkerPluginDescriptor, AnyWorkerPluginSource, EvaluatorPluginDescriptor, EvaluatorPluginSource, Framework, Ignorer, LoadedPlugins, Node, PluginDescriptor, PluginDescriptorOf, PluginKind, PluginSource, PooledTestRunner, PooledTestRunnerError, REPORTER_EVENT_BATCH_BOUND, ReporterStage, ReporterWorkerClient, SpawnReporterWorkerParams, TestRunnerBuildContext, WorkerPluginDescriptor, WorkerPluginSource, buildTestRunner, isCommandRunner, isVmRunner, makeChildProcessTestRunner, reporterWorkerFactory, spawnReporterWorker, testRunnerConfigOf, vmRunnerPluginUrl, vmTestRunnerConfig }; + export { AnyPluginDescriptor, AnyWorkerPluginDescriptor, AnyWorkerPluginSource, EvaluatorPluginDescriptor, EvaluatorPluginSource, Framework, Ignorer, LoadedPlugins, Node_2 as Node, PluginDescriptor, PluginDescriptorOf, PluginKind, PluginSource, PooledTestRunner, PooledTestRunnerError, REPORTER_EVENT_BATCH_BOUND, ReporterStage, ReporterWorkerClient, SpawnReporterWorkerParams, TestRunnerBuildContext, WorkerPluginDescriptor, WorkerPluginSource, buildTestRunner, isCommandRunner, isVmRunner, makeChildProcessTestRunner, reporterWorkerFactory, spawnReporterWorker, testRunnerConfigOf, vmRunnerPluginUrl, vmTestRunnerConfig }; } // @public (undocumented) From 258edf71a9a4668274194ee9a5dde1eb64e73d34 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:21:46 +0000 Subject: [PATCH 3/4] docs(solutions): record the Plugin namespace Node alias split between CI and local builds Run 37626449206 failed api:check on a report regenerated locally; run 37649609332 passed with main's rendering Verdict-Semantics: unchanged --- docs/solutions/api-extractor-node-alias-nondeterminism.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/solutions/api-extractor-node-alias-nondeterminism.md b/docs/solutions/api-extractor-node-alias-nondeterminism.md index b1e2af956..9a559cc67 100644 --- a/docs/solutions/api-extractor-node-alias-nondeterminism.md +++ b/docs/solutions/api-extractor-node-alias-nondeterminism.md @@ -6,7 +6,7 @@ component: api-extractor / tsdown tags: [api-extractor, api-report, tsdown, chunk-hashing, ci-divergence] severity: medium date: 2026-09-22 -last_updated: 2026-09-24 +last_updated: 2026-10-07 --- ## Symptoms @@ -36,3 +36,4 @@ A forgotten export can also name a chunk file: `// dist/-.d.mts:
  • .d.mts` path, export the type the public signature reaches. Do not regenerate the report and hope for a stable alias. - The oxc `Node` that the exported `Node` alias wraps is still a forgotten export (`Node$1$1`). It has stayed stable so far. If it starts to rotate, the same fix applies. +- The exported `Node` still splits by machine inside `export namespace Plugin`. CI renders `Node_2 as Node` and local builds render `Node`. A change that moves dist chunk order makes a local `api:update` and a local `api:check` agree on `Node`, and CI still fails. That happened on #227 (run 37626449206). A local byte-identical regeneration does not prove a report: when the only api diff is this alias, keep main's rendering, since CI produced it. The gate is CI's `api:check`, not the local one. From 137563a362643926a803cbf0ed6f8e3c3a64d51f Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 17:19:47 +0000 Subject: [PATCH 4/4] build(release): name the changesets versioning strategy the release tools now decode pnpm-release-management main renamed versioning.strategy pnpm to changesets (#9, #33), and both the Changeset Check and Release callers run its main, so every pull request here failed with 'cannot parse config release.jsonc' (run 37657899053) Verdict-Semantics: unchanged --- release.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release.jsonc b/release.jsonc index f38f95c93..e61e4c676 100644 --- a/release.jsonc +++ b/release.jsonc @@ -1,7 +1,7 @@ { // Consumed by the shared release toolchain // (systemfsoftware/pnpm-release-management) via the reusable workflows in - // .github/workflows/. Packages are versioned independently with pnpm, tagged + // .github/workflows/. Packages are versioned independently through the changesets libraries, tagged // @vX.Y.Z, and distributed as this repo's Nix flake outputs consumed from // a git ref — not published to any npm registry. "base": "main", @@ -9,7 +9,7 @@ "changesetDir": ".changeset", "changelogDir": ".changeset/changelogs", "versioning": { - "strategy": "pnpm", + "strategy": "changesets", }, "gate": { "strategy": "paths" }, "pr": {