Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/petite-insects-feel.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion docs/solutions/api-extractor-node-alias-nondeterminism.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -36,3 +36,4 @@ A forgotten export can also name a chunk file: `// dist/<chunk>-<hash>.d.mts:<li

- When an `etc/*.api.md` diff contains a `$N`/`_N`-suffixed forgotten symbol or a hashed `dist/*-<hash>.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.
2 changes: 2 additions & 0 deletions packages/stryker-js/src/mutation-reporting.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ export class ManifestUnreadable extends S.TaggedError<ManifestUnreadable>()('Man
}

export const ManifestSchema = S.Struct({ version: S.optional(S.String) })

export const IncrementalReportObjectSchema = S.Record(S.String, S.Json)
38 changes: 37 additions & 1 deletion packages/stryker-js/src/mutation-reporting.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -149,6 +149,7 @@ export interface MutationReportingService {
input: MutationReportingInput,
plannedMutants: readonly Mutant.Mutant[],
) => Effect.Effect<void, PlatformError>
readonly publishDryRunCoverage: (input: MutationReportingInput) => Effect.Effect<void, PlatformError>
}

export class MutationReporting extends Context.Service<MutationReporting, MutationReportingService>()(
Expand All @@ -174,6 +175,7 @@ export class MutationReporting extends Context.Service<MutationReporting, Mutati
reportMutantRunResult: (mutant, result) => mapRunResult(mutant, result),
reportAll: (input) => reportAll(deps, input),
checkpoint: (input, plannedMutants) => checkpoint(deps, input, plannedMutants),
publishDryRunCoverage: (input) => publishDryRunCoverage(deps, input),
})
}),
)
Expand Down Expand Up @@ -996,6 +998,40 @@ const checkpoint = (
onFalse: () => Effect.void,
})

const priorIncrementalObjectOf = (text: string): Option.Option<typeof IncrementalReportObjectSchema.Type> =>
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 {
Expand Down
13 changes: 11 additions & 2 deletions packages/stryker-js/src/run/mutation-test.cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }) =>
Expand Down
2 changes: 1 addition & 1 deletion packages/stryker-js/src/verdict-semantics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
100 changes: 100 additions & 0 deletions packages/stryker-js/tests/incremental-reuse.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions release.jsonc
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
// 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
// <pkg>@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",
"branch": "changeset-release/main",
"changesetDir": ".changeset",
"changelogDir": ".changeset/changelogs",
"versioning": {
"strategy": "pnpm",
"strategy": "changesets",
},
"gate": { "strategy": "paths" },
"pr": {
Expand Down
Loading