From 1ab117c44f8c64b253bae696e7a6a0490adc715a Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 13:02:20 +0000 Subject: [PATCH 01/16] 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 8fcfe384814b57ee9a939f5a8a7bb9bce557aa3a Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 14:13:10 +0000 Subject: [PATCH 02/16] feat(repo): reuse CompileError verdicts across incremental runs The TypeScript checker reports the digest of the program it loaded: every source file the program holds, hashed, the tsconfig files it was built from, the TypeScript version, the checker plugin version and the checker's options. The engine stamps that digest beside every CompileError verdict it records, and a later incremental run reuses such a verdict only when the digest it recomputes is byte-equal. A missing digest, a changed file anywhere in the program, a changed tsconfig, toolchain or option all send the mutant back to the checker. Hashing the program needs the checker running, so the saving is the per-mutant check Holding the digest means a plugin interface that can answer `digest`, and a `programChanged` refusal reason on the reuse stream line. Verdicts the engine did not remember keep their previous keys and refusals Verdict-Semantics: unchanged --- .changeset/checker-reports-program-digest.md | 13 + .changeset/compile-error-verdicts-reusable.md | 20 + .../contract/span-taxonomy.json | 28 ++ .../contract/stream.schema.json | 10 + .../src/SpanTaxonomy.ts | 11 + .../src/run-event.schema.ts | 1 + .../etc/stryker-js-plugin-interface.api.md | 4 +- .../src/Checker.schema.ts | 14 + .../src/Checker.service.ts | 3 +- .../src/Checker/mod.ts | 1 + .../src/Mutant.schema.ts | 9 +- .../src/Plugin.schema.ts | 3 + .../src/Plugin/mod.ts | 1 + .../src/PluginRpcs.service.ts | 15 +- .../package.json | 1 + .../src/CheckerRuntime.service.ts | 4 +- .../src/CheckerWorker.service.ts | 3 + .../src/Compiler.schema.ts | 5 + ...identify-program.workflow.property.test.ts | 115 ++++++ .../src/identify-program.workflow.ts | 47 +++ .../src/program-digest.schema.ts | 25 ++ .../src/ts-compiler.handle.ts | 79 ++++ .../tests/program-digest.integration.test.ts | 218 +++++++++++ .../src/Checker/Checker.blueprint.ts | 2 + .../stryker-js/src/Checker/Checker.handle.ts | 21 + .../src/Checker/checker-pool.handle.ts | 24 ++ .../stryker-js/src/IncrementalDiff.schema.ts | 3 + ...ental-diff.workflow.property.test.ts.jsonl | 3 + .../checker-pool.workflow.property.test.ts | 8 +- ...incremental-diff.workflow.property.test.ts | 114 ++++-- .../src/incremental-diff.workflow.ts | 39 +- .../src/mutation-reporting.service.ts | 34 +- packages/stryker-js/src/plan-request.cell.ts | 1 + .../src/run/incremental-reuse.cell.ts | 17 + .../stryker-js/src/run/mutation-test.cell.ts | 14 +- .../__fixtures__/cost-checker/worker.mjs | 1 + .../program-digesting-checker/index.mjs | 3 + .../program-digesting-checker/worker.mjs | 50 +++ .../__fixtures__/rejecting-checker/worker.mjs | 1 + .../tests/checker-rpc.integration.test.ts | 5 +- .../compile-error-reuse.integration.test.ts | 358 ++++++++++++++++++ ...-coverage-across-roots.integration.test.ts | 1 + .../incremental-reuse.integration.test.ts | 1 + .../plan-shard-reuse.integration.test.ts | 1 + .../plan-stream-location.integration.test.ts | 1 + ...worker-connection-loss.integration.test.ts | 5 +- pnpm-lock.yaml | 3 + 47 files changed, 1297 insertions(+), 43 deletions(-) create mode 100644 .changeset/checker-reports-program-digest.md create mode 100644 .changeset/compile-error-verdicts-reusable.md create mode 100644 packages/stryker-js-typescript-checker/src/__tests__/identify-program.workflow.property.test.ts create mode 100644 packages/stryker-js-typescript-checker/src/identify-program.workflow.ts create mode 100644 packages/stryker-js-typescript-checker/src/program-digest.schema.ts create mode 100644 packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts create mode 100644 packages/stryker-js/src/__tests__/__property_seeds__/incremental-diff.workflow.property.test.ts.jsonl create mode 100644 packages/stryker-js/tests/__fixtures__/program-digesting-checker/index.mjs create mode 100644 packages/stryker-js/tests/__fixtures__/program-digesting-checker/worker.mjs create mode 100644 packages/stryker-js/tests/compile-error-reuse.integration.test.ts diff --git a/.changeset/checker-reports-program-digest.md b/.changeset/checker-reports-program-digest.md new file mode 100644 index 000000000..9a974482b --- /dev/null +++ b/.changeset/checker-reports-program-digest.md @@ -0,0 +1,13 @@ +--- +"@systemfsoftware/stryker-js-plugin-interface": minor +"@systemfsoftware/stryker-js-typescript-checker": minor +--- + +A checker plugin can now report the program it loaded. `CheckerService` gains a +`digest` holding the digest of that program, the checker RPC exposes it as +`digest`, and a TypeScript checker answers with the digest of the TypeScript +program it built: the hashed content of every source file the program loaded +(project files, declaration files and the ones TypeScript ships), the tsconfig +files it was built from, the TypeScript version, the checker's own version, and +its configured options. The digest is stable for an unchanged program and moves +when any of those change. diff --git a/.changeset/compile-error-verdicts-reusable.md b/.changeset/compile-error-verdicts-reusable.md new file mode 100644 index 000000000..d63640114 --- /dev/null +++ b/.changeset/compile-error-verdicts-reusable.md @@ -0,0 +1,20 @@ +--- +"@systemfsoftware/stryker-js": minor +"@systemfsoftware/stryker-js-cli-contract": minor +--- + +An incremental run now reuses the verdicts of mutants a checker rejected as +`CompileError`, instead of type-checking every one of them again. A remembered +`CompileError` is kept only while nothing that fed the check has changed: the +mutant's own identity and replacement, every file the checker's TypeScript +program loaded — including the declaration files pulled in transitively and the +ones shipped with TypeScript — every tsconfig the program was built from, the +TypeScript version, the checker plugin version, and the checker's own options. A +change to any of them, or a verdict recorded without that key, re-checks the +mutant. + +Hashing the program needs the checker running, so a run that has one re-checks +the mutants it must either way; only the per-mutant check is saved. The +incremental report writes the program digest beside every `CompileError` verdict +it records, and the reuse stream line counts a refusal to reuse one under the new +`programChanged` reason, next to the existing refusal reasons. diff --git a/packages/stryker-js-cli-contract/contract/span-taxonomy.json b/packages/stryker-js-cli-contract/contract/span-taxonomy.json index 06bacd077..712746cf1 100644 --- a/packages/stryker-js-cli-contract/contract/span-taxonomy.json +++ b/packages/stryker-js-cli-contract/contract/span-taxonomy.json @@ -16,6 +16,15 @@ "name": "stryker.checker.check_plans", "attributes": {} }, + { + "id": "stryker.checker.digest", + "name": "stryker.checker.digest", + "attributes": { + "stryker.checker.name": { + "type": "string" + } + } + }, { "id": "stryker.checker.group", "name": "stryker.checker.group", @@ -43,6 +52,11 @@ "name": "stryker.checker_pool.check_plans", "attributes": {} }, + { + "id": "stryker.checker_pool.program_digest", + "name": "stryker.checker_pool.program_digest", + "attributes": {} + }, { "id": "stryker.checker_pool.split_checked", "name": "stryker.checker_pool.split_checked", @@ -657,6 +671,15 @@ "name": "RpcClient.onEventBatch", "attributes": {} }, + { + "id": "rpc.digest", + "name": "rpc.digest", + "attributes": { + "rpc.method": { + "type": "string" + } + } + }, { "id": "rpc.dryRun", "name": "rpc.dryRun", @@ -1059,6 +1082,11 @@ "name": "typescript-checker.compiler.nodes", "attributes": {} }, + { + "id": "typescript-checker.compiler.programDigest", + "name": "typescript-checker.compiler.programDigest", + "attributes": {} + }, { "id": "typescript-checker.compiler.scoped", "name": "typescript-checker.compiler.scoped", diff --git a/packages/stryker-js-cli-contract/contract/stream.schema.json b/packages/stryker-js-cli-contract/contract/stream.schema.json index bc0279824..2c3cdfe96 100644 --- a/packages/stryker-js-cli-contract/contract/stream.schema.json +++ b/packages/stryker-js-cli-contract/contract/stream.schema.json @@ -280,6 +280,10 @@ "type": "integer", "minimum": 0 }, + "programChanged": { + "type": "integer", + "minimum": 0 + }, "timeoutUnreproduced": { "type": "integer", "minimum": 0 @@ -298,6 +302,7 @@ "policyChanged", "runInputsChanged", "closureChanged", + "programChanged", "timeoutUnreproduced", "flakyDependency", "noPriorRecord" @@ -776,6 +781,10 @@ "type": "integer", "minimum": 0 }, + "programChanged": { + "type": "integer", + "minimum": 0 + }, "timeoutUnreproduced": { "type": "integer", "minimum": 0 @@ -794,6 +803,7 @@ "policyChanged", "runInputsChanged", "closureChanged", + "programChanged", "timeoutUnreproduced", "flakyDependency", "noPriorRecord" diff --git a/packages/stryker-js-cli-contract/src/SpanTaxonomy.ts b/packages/stryker-js-cli-contract/src/SpanTaxonomy.ts index a6052258f..31201a165 100644 --- a/packages/stryker-js-cli-contract/src/SpanTaxonomy.ts +++ b/packages/stryker-js-cli-contract/src/SpanTaxonomy.ts @@ -17,6 +17,10 @@ export const Spans = { attributes: { 'stryker.checker.name': SpanAttributeText, 'stryker.mutants.count': SpanAttributeCount }, }, checkerCheckPlans: { name: 'stryker.checker.check_plans', attributes: {} }, + checkerDigest: { + name: 'stryker.checker.digest', + attributes: { 'stryker.checker.name': SpanAttributeText }, + }, checkerGroup: { name: 'stryker.checker.group', attributes: { 'stryker.checker.name': SpanAttributeText, 'stryker.mutants.count': SpanAttributeCount }, @@ -24,6 +28,7 @@ export const Spans = { checkerGroupPlans: { name: 'stryker.checker.group_plans', attributes: {} }, checkerLogSkipped: { name: 'stryker.checker.log_skipped', attributes: {} }, checkerPoolCheckPlans: { name: 'stryker.checker_pool.check_plans', attributes: {} }, + checkerPoolProgramDigest: { name: 'stryker.checker_pool.program_digest', attributes: {} }, checkerPoolSplitChecked: { name: 'stryker.checker_pool.split_checked', attributes: {} }, checkerRecordSkipped: { name: 'stryker.checker.record_skipped', attributes: {} }, cliRun: { @@ -159,6 +164,7 @@ export const Spans = { rpcClientInit: { name: 'RpcClient.init', attributes: {} }, rpcClientMutantRun: { name: 'RpcClient.mutantRun', attributes: {} }, rpcClientOnEventBatch: { name: 'RpcClient.onEventBatch', attributes: {} }, + rpcDigest: { name: 'rpc.digest', attributes: { 'rpc.method': SpanAttributeText } }, rpcDryRun: { name: 'rpc.dryRun', attributes: { 'rpc.method': SpanAttributeText } }, rpcFlush: { name: 'rpc.flush', attributes: { 'rpc.method': SpanAttributeText } }, rpcGroup: { name: 'rpc.group', attributes: { 'rpc.method': SpanAttributeText } }, @@ -240,6 +246,10 @@ export const Spans = { typescriptCheckerCompilerLineAndCharacter: { name: 'typescript-checker.compiler.lineAndCharacter', attributes: {} }, typescriptCheckerCompilerMake: { name: 'typescript-checker.compiler.make', attributes: {} }, typescriptCheckerCompilerNodes: { name: 'typescript-checker.compiler.nodes', attributes: {} }, + typescriptCheckerCompilerProgramDigest: { + name: 'typescript-checker.compiler.programDigest', + attributes: {}, + }, typescriptCheckerCompilerScoped: { name: 'typescript-checker.compiler.scoped', attributes: {} }, typescriptCheckerRuntimeMakeChecker: { name: 'typescript-checker.runtime.makeChecker', attributes: {} }, typescriptCheckerSoloRound: { @@ -274,6 +284,7 @@ export const spanMembers: ReadonlyArray = Object.values(Spans) const rpcServedSpans: ReadonlyMap = new Map([ ['capabilities', Spans.rpcCapabilities], ['check', Spans.rpcCheck], + ['digest', Spans.rpcDigest], ['dryRun', Spans.rpcDryRun], ['flush', Spans.rpcFlush], ['group', Spans.rpcGroup], diff --git a/packages/stryker-js-cli-contract/src/run-event.schema.ts b/packages/stryker-js-cli-contract/src/run-event.schema.ts index 7f7093ad8..b4990b9ef 100644 --- a/packages/stryker-js-cli-contract/src/run-event.schema.ts +++ b/packages/stryker-js-cli-contract/src/run-event.schema.ts @@ -34,6 +34,7 @@ export const ReuseRefusals = S.Struct({ policyChanged: Report.NonNegativeInt, runInputsChanged: Report.NonNegativeInt, closureChanged: Report.NonNegativeInt, + programChanged: Report.NonNegativeInt, timeoutUnreproduced: Report.NonNegativeInt, flakyDependency: Report.NonNegativeInt, noPriorRecord: Report.NonNegativeInt, diff --git a/packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md b/packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md index cd7c9591b..7faf34bd4 100644 --- a/packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md +++ b/packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md @@ -6,7 +6,7 @@ // @public (undocumented) export namespace Checker { - export { CheckResult, CheckResultSchema, CheckStatus, Checker, CheckerFailed, CheckerMutantWire, CheckerService, FailedCheckResult, IgnoredCheckResult, PassedCheckResult }; + export { CheckResult, CheckResultSchema, CheckStatus, Checker, CheckerFailed, CheckerMutantWire, CheckerService, FailedCheckResult, IgnoredCheckResult, PassedCheckResult, ProgramDigest }; } // @public (undocumented) @@ -36,7 +36,7 @@ export namespace Options { // @public (undocumented) export namespace Plugin { - export { BoundaryError, BoundaryErrorSchema, BoundaryPayloadRejected, BoundaryUnrecognizedSignal, CheckerCheckResult, CheckerGroupResult, CheckerRequest, CheckerRpcs, EvaluatorPluginKind, ExitClass, ExitCode, ExitCodeFromClass, PluginKind, PluginKindSchema, ReporterAck, ReporterDrained, ReporterEventBatch, ReporterInitOptions, ReporterRpcs, TestRunnerDryRunRequest, TestRunnerMutantRunRequest, TestRunnerRpcs, WorkerEntryUrl, WorkerPluginKind, WorkerPluginSpawn, WorkerPluginSpawnSchema }; + export { BoundaryError, BoundaryErrorSchema, BoundaryPayloadRejected, BoundaryUnrecognizedSignal, CheckerCheckResult, CheckerDigestRequest, CheckerGroupResult, CheckerRequest, CheckerRpcs, EvaluatorPluginKind, ExitClass, ExitCode, ExitCodeFromClass, PluginKind, PluginKindSchema, ReporterAck, ReporterDrained, ReporterEventBatch, ReporterInitOptions, ReporterRpcs, TestRunnerDryRunRequest, TestRunnerMutantRunRequest, TestRunnerRpcs, WorkerEntryUrl, WorkerPluginKind, WorkerPluginSpawn, WorkerPluginSpawnSchema }; } // @public (undocumented) diff --git a/packages/stryker-js-plugin-interface/src/Checker.schema.ts b/packages/stryker-js-plugin-interface/src/Checker.schema.ts index ec23bba51..3f4bf608d 100644 --- a/packages/stryker-js-plugin-interface/src/Checker.schema.ts +++ b/packages/stryker-js-plugin-interface/src/Checker.schema.ts @@ -12,6 +12,20 @@ export const CheckerMutantWire = S.Struct({ }) export type CheckerMutantWire = typeof CheckerMutantWire.Type +/** + * The digest of the TypeScript program a checker loaded: SHA-256 over the sorted + * hashes of every source file in the program (lib and node_modules declarations + * included), every tsconfig in its chain, the TypeScript version, the checker + * plugin version, and the checker's options. A `CompileError` verdict is reusable + * only when two runs' digests are byte-equal, so a missing digest is never a match. + */ +export const ProgramDigest = S.String.check( + S.isPattern(/^[0-9a-f]{64}$/u, { + expected: 'a 64-character lowercase hexadecimal program digest', + }), +).pipe(S.brand('ProgramDigest')) +export type ProgramDigest = typeof ProgramDigest.Type + export const CheckResultSchema = S.Union([ S.Struct({ status: S.Literal('passed') }), S.Struct({ status: S.Literal('compileError'), reason: S.String }), diff --git a/packages/stryker-js-plugin-interface/src/Checker.service.ts b/packages/stryker-js-plugin-interface/src/Checker.service.ts index 9efad90fb..bf4674138 100644 --- a/packages/stryker-js-plugin-interface/src/Checker.service.ts +++ b/packages/stryker-js-plugin-interface/src/Checker.service.ts @@ -2,7 +2,7 @@ import * as Context from 'effect/Context' import type * as Effect from 'effect/Effect' import type * as HashMap from 'effect/HashMap' -import type { CheckerMutantWire, CheckResult } from './Checker.schema.js' +import type { CheckerMutantWire, CheckResult, ProgramDigest } from './Checker.schema.js' import { CheckerFailed } from './Checker.schema.js' export interface CheckerService { @@ -13,6 +13,7 @@ export interface CheckerService { readonly group: ( mutants: readonly CheckerMutantWire[], ) => Effect.Effect + readonly digest: Effect.Effect } export class Checker extends Context.Service()( diff --git a/packages/stryker-js-plugin-interface/src/Checker/mod.ts b/packages/stryker-js-plugin-interface/src/Checker/mod.ts index ef042d8b8..935d11714 100644 --- a/packages/stryker-js-plugin-interface/src/Checker/mod.ts +++ b/packages/stryker-js-plugin-interface/src/Checker/mod.ts @@ -7,5 +7,6 @@ export { type FailedCheckResult, type IgnoredCheckResult, type PassedCheckResult, + ProgramDigest, } from '../Checker.schema.js' export * from '../Checker.service.js' diff --git a/packages/stryker-js-plugin-interface/src/Mutant.schema.ts b/packages/stryker-js-plugin-interface/src/Mutant.schema.ts index 7a74006d6..df44770c2 100644 --- a/packages/stryker-js-plugin-interface/src/Mutant.schema.ts +++ b/packages/stryker-js-plugin-interface/src/Mutant.schema.ts @@ -19,7 +19,14 @@ export type MutantStatus = typeof MutantStatusSchema.Type export const SurvivorStatusSchema = S.Literals(['Survived', 'NoCoverage']) export type SurvivorStatus = typeof SurvivorStatusSchema.Type -export const RememberedStatusSchema = S.Literals(['Survived', 'Killed', 'Timeout', 'NoCoverage', 'Ignored']) +export const RememberedStatusSchema = S.Literals([ + 'Survived', + 'Killed', + 'Timeout', + 'NoCoverage', + 'Ignored', + 'CompileError', +]) export type RememberedStatus = typeof RememberedStatusSchema.Type export const EphemeralStatusSchema = S.Literals(['CompileError', 'RuntimeError', 'Pending']) diff --git a/packages/stryker-js-plugin-interface/src/Plugin.schema.ts b/packages/stryker-js-plugin-interface/src/Plugin.schema.ts index 8791047ea..fc3d2112f 100644 --- a/packages/stryker-js-plugin-interface/src/Plugin.schema.ts +++ b/packages/stryker-js-plugin-interface/src/Plugin.schema.ts @@ -28,6 +28,9 @@ export type CheckerRequest = typeof CheckerRequest.Type export const CheckerCheckResult = S.Record(S.String, CheckResultSchema) export const CheckerGroupResult = S.String.pipe(S.Array, S.Array) +export const CheckerDigestRequest = S.Struct({ checkerName: S.String }) +export type CheckerDigestRequest = typeof CheckerDigestRequest.Type + export const ReporterInitOptions = S.Struct({ traceparent: S.optionalKey(Traceparent), tracestate: S.optionalKey(Tracestate), diff --git a/packages/stryker-js-plugin-interface/src/Plugin/mod.ts b/packages/stryker-js-plugin-interface/src/Plugin/mod.ts index 9ac778e8d..30581c2d5 100644 --- a/packages/stryker-js-plugin-interface/src/Plugin/mod.ts +++ b/packages/stryker-js-plugin-interface/src/Plugin/mod.ts @@ -5,6 +5,7 @@ export { BoundaryPayloadRejected, BoundaryUnrecognizedSignal, CheckerCheckResult, + CheckerDigestRequest, CheckerGroupResult, CheckerRequest, EvaluatorPluginKind, diff --git a/packages/stryker-js-plugin-interface/src/PluginRpcs.service.ts b/packages/stryker-js-plugin-interface/src/PluginRpcs.service.ts index 717d644f5..9cfb2bcab 100644 --- a/packages/stryker-js-plugin-interface/src/PluginRpcs.service.ts +++ b/packages/stryker-js-plugin-interface/src/PluginRpcs.service.ts @@ -4,7 +4,7 @@ import * as RpcGroup from 'effect/rpc/RpcGroup' import type * as RpcSchema from 'effect/rpc/RpcSchema' -import { CheckerFailed } from './Checker.schema.js' +import { CheckerFailed, ProgramDigest } from './Checker.schema.js' import { ReporterFailed } from './ReporterEvent.schema.js' import { DryRunResultSchema, @@ -15,6 +15,7 @@ import { import { CheckerCheckResult, + CheckerDigestRequest, CheckerGroupResult, CheckerRequest, ReporterAck, @@ -67,6 +68,12 @@ const group: TracedRpc<'group', typeof CheckerRequest, typeof CheckerGroupResult ) .middleware(TraceContextMiddleware) +const digest: TracedRpc<'digest', typeof CheckerDigestRequest, typeof ProgramDigest, typeof CheckerFailed> = Rpc.make( + 'digest', + { payload: CheckerDigestRequest, success: ProgramDigest, error: CheckerFailed }, +) + .middleware(TraceContextMiddleware) + const init: TracedRpc<'init', typeof ReporterInitOptions, typeof ReporterAck, typeof ReporterFailed> = Rpc.make( 'init', { payload: ReporterInitOptions, success: ReporterAck, error: ReporterFailed }, @@ -89,7 +96,11 @@ export const TestRunnerRpcs: RpcGroup.RpcGroup = RpcGroup.make(check, group) +export const CheckerRpcs: RpcGroup.RpcGroup = RpcGroup.make( + check, + group, + digest, +) export const ReporterRpcs: RpcGroup.RpcGroup = RpcGroup.make( init, diff --git a/packages/stryker-js-typescript-checker/package.json b/packages/stryker-js-typescript-checker/package.json index 4603394cf..4c7514591 100644 --- a/packages/stryker-js-typescript-checker/package.json +++ b/packages/stryker-js-typescript-checker/package.json @@ -42,6 +42,7 @@ "license": "Apache-2.0", "dependencies": { "@effect/opentelemetry": "catalog:", + "@noble/hashes": "catalog:", "@opentelemetry/exporter-trace-otlp-http": "catalog:", "@opentelemetry/sdk-node": "catalog:", "@opentelemetry/sdk-trace-base": "catalog:", diff --git a/packages/stryker-js-typescript-checker/src/CheckerRuntime.service.ts b/packages/stryker-js-typescript-checker/src/CheckerRuntime.service.ts index b7e6e3e63..c71f7975f 100644 --- a/packages/stryker-js-typescript-checker/src/CheckerRuntime.service.ts +++ b/packages/stryker-js-typescript-checker/src/CheckerRuntime.service.ts @@ -20,7 +20,7 @@ import { checkCell } from './Checker.cell.js' import { CheckMutantsCommand } from './Checker.schema.js' import { type CompilerError, DryRunCompileErrors } from './Compiler.schema.js' import { make as makeCompilerBlueprint } from './ts-compiler.blueprint.js' -import { describeDiagnostics, groups, init, type TSCompiler } from './ts-compiler.handle.js' +import { describeDiagnostics, groups, init, programDigest, type TSCompiler } from './ts-compiler.handle.js' import { TypeScriptCompiler } from './ts-compiler.service.js' type CheckEvent = CheckMutantsAnswer[number] @@ -93,6 +93,8 @@ const makeChecker = Effect.fn(SpanTaxonomy.Spans.typescriptCheckerRuntimeMakeChe ), group: (mutants) => groups([...mutants]), + + digest: programDigest(compiler).pipe(Effect.mapError((cause) => refuse({ mutantIds: [], cause }))), } yield* service.init diff --git a/packages/stryker-js-typescript-checker/src/CheckerWorker.service.ts b/packages/stryker-js-typescript-checker/src/CheckerWorker.service.ts index 9c7bce058..5bc8f66dd 100644 --- a/packages/stryker-js-typescript-checker/src/CheckerWorker.service.ts +++ b/packages/stryker-js-typescript-checker/src/CheckerWorker.service.ts @@ -56,6 +56,9 @@ export const checkerHandlers = Plugin.CheckerRpcs.toLayer( readonly checkerName: string readonly mutants: readonly Checker.CheckerMutantWire[] }) => resolve(runtime, checkerName, mutants).pipe(Effect.flatMap((checker) => checker.group([...mutants]))), + + digest: ({ checkerName }: { readonly checkerName: string }) => + resolve(runtime, checkerName, []).pipe(Effect.flatMap((checker) => checker.digest)), } }), ) diff --git a/packages/stryker-js-typescript-checker/src/Compiler.schema.ts b/packages/stryker-js-typescript-checker/src/Compiler.schema.ts index cd696bc11..b2a78855f 100644 --- a/packages/stryker-js-typescript-checker/src/Compiler.schema.ts +++ b/packages/stryker-js-typescript-checker/src/Compiler.schema.ts @@ -56,6 +56,7 @@ export class CompilerFailed extends S.TaggedError()('CompilerFai 'unknown-file-node', 'file-not-in-project', 'mutant-outside-file', + 'program-digest-unavailable', ]), subject: S.optional(S.String), }) { @@ -75,6 +76,10 @@ export class CompilerFailed extends S.TaggedError()('CompilerFai 'mutant-outside-file', () => `A mutant's location falls outside '${this.subject ?? 'a file'}'`, ), + Match.when( + 'program-digest-unavailable', + () => `The loaded TypeScript program could not be identified: ${this.subject ?? 'unknown input'}`, + ), Match.exhaustive, ) } diff --git a/packages/stryker-js-typescript-checker/src/__tests__/identify-program.workflow.property.test.ts b/packages/stryker-js-typescript-checker/src/__tests__/identify-program.workflow.property.test.ts new file mode 100644 index 000000000..b969b29e3 --- /dev/null +++ b/packages/stryker-js-typescript-checker/src/__tests__/identify-program.workflow.property.test.ts @@ -0,0 +1,115 @@ +import { describe } from '@systemfsoftware/vitest' +import * as Arr from 'effect/Array' +import * as Equal from 'effect/Equal' +import * as Result from 'effect/Result' +import * as S from 'effect/Schema' + +import { identifyProgram, IdentifyProgramCommand, ProgramIdentified } from '../identify-program.workflow.js' +import { type ProgramFile } from '../program-digest.schema.js' + +const keyOf = (command: IdentifyProgramCommand): string | undefined => + Result.match(identifyProgram(command), { + onFailure: () => undefined, + onSuccess: (identity) => S.is(ProgramIdentified)(identity) ? identity.key : undefined, + }) + +const sourceFile = (fileName: string, digest: string): ProgramFile => ({ fileName, digest }) + +const seedFile = sourceFile('/project/src/seed.ts', 'seed-digest') + +interface Overrides { + readonly typescriptVersion?: string + readonly checkerVersion?: string + readonly checkerOptionsJson?: string + readonly sourceFiles?: ReadonlyArray + readonly tsconfigs?: ReadonlyArray +} + +const rebuild = (command: IdentifyProgramCommand, overrides: Overrides): IdentifyProgramCommand => + IdentifyProgramCommand.make({ + typescriptVersion: overrides.typescriptVersion ?? command.typescriptVersion, + checkerVersion: overrides.checkerVersion ?? command.checkerVersion, + checkerOptionsJson: overrides.checkerOptionsJson ?? command.checkerOptionsJson, + sourceFiles: [...(overrides.sourceFiles ?? command.sourceFiles)], + tsconfigs: [...(overrides.tsconfigs ?? command.tsconfigs)], + }) + +const seeded = (command: IdentifyProgramCommand): IdentifyProgramCommand => + rebuild(command, { sourceFiles: [seedFile, ...command.sourceFiles] }) + +describe('identifyProgram', (it) => { + it.prop( + '∀command_PermutedFiles_≡SameProgramKey', + { of: [IdentifyProgramCommand], subject: keyOf }, + (subject, [command]) => + Equal.equals( + subject(command), + subject( + rebuild(command, { + sourceFiles: Arr.reverse([...command.sourceFiles]), + tsconfigs: Arr.reverse([...command.tsconfigs]), + }), + ), + ), + ) + + it.prop( + '∀command_TypeScriptVersion_≡MovesTheProgramKey', + { of: [IdentifyProgramCommand], subject: keyOf }, + (subject, [command]) => + !Equal.equals( + subject(seeded(command)), + subject(rebuild(seeded(command), { typescriptVersion: `${command.typescriptVersion}~` })), + ), + ) + + it.prop( + '∀command_CheckerVersion_≡MovesTheProgramKey', + { of: [IdentifyProgramCommand], subject: keyOf }, + (subject, [command]) => + !Equal.equals( + subject(seeded(command)), + subject(rebuild(seeded(command), { checkerVersion: `${command.checkerVersion}~` })), + ), + ) + + it.prop( + '∀command_CheckerOptions_≡MovesTheProgramKey', + { of: [IdentifyProgramCommand], subject: keyOf }, + (subject, [command]) => + !Equal.equals( + subject(seeded(command)), + subject( + rebuild(seeded(command), { + checkerOptionsJson: `{"typescriptChecker":${JSON.stringify(command.checkerOptionsJson)}}`, + }), + ), + ), + ) + + it.prop( + '∀command_AddedSourceFile_≡MovesTheProgramKey', + { of: [IdentifyProgramCommand], subject: keyOf }, + (subject, [command]) => + !Equal.equals( + subject(seeded(command)), + subject(rebuild(seeded(command), { sourceFiles: [...command.sourceFiles, seedFile, seedFile] })), + ), + ) + + it.prop( + '∀command_ChangedTsconfig_≡MovesTheProgramKey', + { of: [IdentifyProgramCommand], subject: keyOf }, + (subject, [command]) => + !Equal.equals( + subject(seeded(command)), + subject(rebuild(seeded(command), { tsconfigs: [...command.tsconfigs, seedFile] })), + ), + ) + + it.prop( + '∀command_NoSourceFiles_≡Unidentified', + { of: [IdentifyProgramCommand], subject: keyOf }, + (subject, [command]) => subject(rebuild(command, { sourceFiles: [] })) === undefined, + ) +}) diff --git a/packages/stryker-js-typescript-checker/src/identify-program.workflow.ts b/packages/stryker-js-typescript-checker/src/identify-program.workflow.ts new file mode 100644 index 000000000..fcb80a78f --- /dev/null +++ b/packages/stryker-js-typescript-checker/src/identify-program.workflow.ts @@ -0,0 +1,47 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import * as Boolean from 'effect/Boolean' +import * as Result from 'effect/Result' +import * as S from 'effect/Schema' + +import { ProgramFile, programKeyOf } from './program-digest.schema.js' + +export class IdentifyProgramCommand extends S.TaggedClass()('IdentifyProgramCommand', { + typescriptVersion: S.String, + checkerVersion: S.String, + checkerOptionsJson: S.String, + sourceFiles: S.Array(ProgramFile), + tsconfigs: S.Array(ProgramFile), +}) { + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +const IdentifyTypeId: unique symbol = Symbol.for('@systemfsoftware/stryker-js-typescript-checker/IdentifyProgram') +type IdentifyTypeId = typeof IdentifyTypeId + +export class ProgramIdentified extends S.TaggedClass()('ProgramIdentified', { + key: S.String, +}) { + readonly [IdentifyTypeId] = IdentifyTypeId +} + +export class ProgramUnidentified extends S.TaggedClass()('ProgramUnidentified', { + reason: S.String, +}) { + readonly [IdentifyTypeId] = IdentifyTypeId +} + +export const ProgramIdentity = S.Union([ProgramIdentified, ProgramUnidentified]) +export type ProgramIdentity = typeof ProgramIdentity.Type + +const decide = (command: IdentifyProgramCommand): Result.Result => + Boolean.match(command.sourceFiles.length === 0, { + onTrue: () => Result.succeed(ProgramUnidentified.make({ reason: 'the program has no source files' })), + onFalse: () => Result.succeed(ProgramIdentified.make({ key: programKeyOf(command) })), + }) + +export const identifyProgram = Workflow.make({ + command: IdentifyProgramCommand, + decision: ProgramIdentity, + error: S.Never, + decide, +}) diff --git a/packages/stryker-js-typescript-checker/src/program-digest.schema.ts b/packages/stryker-js-typescript-checker/src/program-digest.schema.ts new file mode 100644 index 000000000..0a7388076 --- /dev/null +++ b/packages/stryker-js-typescript-checker/src/program-digest.schema.ts @@ -0,0 +1,25 @@ +import * as Arr from 'effect/Array' +import * as Order from 'effect/Order' +import * as S from 'effect/Schema' + +export const ProgramFile = S.Struct({ fileName: S.String, digest: S.String }) +export type ProgramFile = typeof ProgramFile.Type + +export type ProgramIdentityInput = { + readonly typescriptVersion: string + readonly checkerVersion: string + readonly checkerOptionsJson: string + readonly sourceFiles: readonly ProgramFile[] + readonly tsconfigs: readonly ProgramFile[] +} + +const entryLineOf = (kind: string, entry: ProgramFile): string => `${kind}\u0000${entry.fileName}\u0000${entry.digest}` + +export const programKeyOf = (input: ProgramIdentityInput): string => + [ + `typescript\u0000${input.typescriptVersion}`, + `checker\u0000${input.checkerVersion}`, + `options\u0000${input.checkerOptionsJson}`, + ...Arr.sort(Arr.map(input.sourceFiles, (entry) => entryLineOf('source', entry)), Order.String), + ...Arr.sort(Arr.map(input.tsconfigs, (entry) => entryLineOf('tsconfig', entry)), Order.String), + ].join('\n') diff --git a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts index 1fb7439de..e445ed3d0 100644 --- a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts +++ b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts @@ -1,3 +1,5 @@ +import { sha256 } from '@noble/hashes/sha2.js' +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js' import { Handle } from '@systemfsoftware/effect-cell-types' import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' import { offsetAt } from '@systemfsoftware/stryker-js-instrumenter' @@ -11,6 +13,7 @@ import * as HashMap from 'effect/HashMap' import * as HashSet from 'effect/HashSet' import * as Match from 'effect/Match' import * as Option from 'effect/Option' +import * as Order from 'effect/Order' import * as Path from 'effect/Path' import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner' import * as Result from 'effect/Result' @@ -56,10 +59,12 @@ import { } from './classify-tce.workflow.js' import { type CompilerError, CompilerFailed, UnsupportedTypeScriptVersionError } from './Compiler.schema.js' import { groupMutants } from './group-mutants.workflow.js' +import { identifyProgram as identifyProgramWorkflow, IdentifyProgramCommand } from './identify-program.workflow.js' import { overrideTsconfigOptions } from './override-tsconfig-options.workflow.js' import { parseTsconfigText } from './parse-tsconfig-text.workflow.js' import { planDiagnosticBatches } from './plan-diagnostic-batches.workflow.js' import { planResolutionCandidates } from './plan-resolution-candidates.workflow.js' +import { type ProgramFile } from './program-digest.schema.js' import { requestAffectedFiles } from './request-affected-files.workflow.js' import { emitNormalized } from './tce-emit.js' import { traceAffectedFiles } from './trace-affected-files.workflow.js' @@ -234,6 +239,80 @@ const readTypescriptVersion = Effect.fnUntraced(function*(rt: TSCompilerRuntime) }) }) +const readCheckerVersion = Effect.fnUntraced(function*(rt: TSCompilerRuntime) { + const packagePath = yield* rt.pathService.fromFileUrl(new URL('../package.json', import.meta.url)) + const text = yield* rt.host.readFileString(packagePath).pipe(Effect.orElseSucceed(() => '')) + return Result.match(S.decodeResult(S.fromJsonString(S.Struct({ version: S.String })))(text), { + onFailure: () => '', + onSuccess: (pkg) => pkg.version, + }) +}) + +const checkerOptionsJsonOf = (options: Options.StrykerOptions): Effect.Effect => + S.encodeEffect(S.fromJsonString(S.Json))( + Option.getOrElse(S.decodeUnknownOption(S.Json)(options['typescriptChecker']), () => ({})), + ).pipe(Effect.orDie) + +const readProgramFiles = ( + rt: TSCompilerRuntime, + fileNames: readonly string[], +): Effect.Effect => + Effect.forEach( + fileNames, + (fileName): Effect.Effect => + Effect.map( + Effect.mapError( + rt.host.readFileString(fileName), + () => CompilerFailed.make({ reason: 'program-digest-unavailable', subject: fileName }), + ), + (content): ProgramFile => ({ fileName, digest: sha256HexOf(content) }), + ), + { concurrency: 1 }, + ) + +const sortedSourceFileNamesOf = (programs: ReadonlyArray): Effect.Effect => + Effect.map( + Effect.forEach(programs, (program) => Effect.promise(() => program.getSourceFileNames())), + (perProgram) => Arr.sort(Arr.dedupe(Arr.map(Arr.flatten(perProgram), normalizeFileName)), Order.String), + ) + +const sha256HexOf = (content: string): string => bytesToHex(sha256(utf8ToBytes(content))) + +const decidedProgramIdentity = ( + command: IdentifyProgramCommand, +): Effect.Effect => { + const identity = decided(identifyProgramWorkflow(command)) + return Match.value(identity).pipe( + Match.tag('ProgramIdentified', ({ key }) => Effect.succeed(Checker.ProgramDigest.make(sha256HexOf(key)))), + Match.tag( + 'ProgramUnidentified', + ({ reason }) => Effect.fail(CompilerFailed.make({ reason: 'program-digest-unavailable', subject: reason })), + ), + Match.exhaustive, + ) +} + +export const programDigest = Effect.fn(SpanTaxonomy.Spans.typescriptCheckerCompilerProgramDigest.name)(function*( + self: TSCompiler, +): Effect.fn.Return { + const rt = runtimeOf(self) + const programs = yield* programsOf(rt) + const state = yield* SynchronizedRef.get(rt.state) + const typescriptVersion = yield* readTypescriptVersion(rt).pipe(Effect.orElseSucceed(() => '')) + const checkerVersion = yield* readCheckerVersion(rt).pipe(Effect.orElseSucceed(() => '')) + yield* Boolean.match(Boolean.or(typescriptVersion === '', checkerVersion === ''), { + onTrue: () => + Effect.fail(CompilerFailed.make({ reason: 'program-digest-unavailable', subject: 'toolchain version' })), + onFalse: () => Effect.void, + }) + const checkerOptionsJson = yield* checkerOptionsJsonOf(rt.options) + const sourceFiles = yield* readProgramFiles(rt, yield* sortedSourceFileNamesOf(programs)) + const tsconfigs = yield* readProgramFiles(rt, Arr.sort(Arr.fromIterable(state.allTSConfigFiles), Order.String)) + return yield* decidedProgramIdentity( + IdentifyProgramCommand.make({ typescriptVersion, checkerVersion, checkerOptionsJson, sourceFiles, tsconfigs }), + ) +}) + const guardTypescriptVersion = (rt: TSCompilerRuntime): Effect.Effect => Effect.flatMap( readTypescriptVersion(rt).pipe(Effect.orElseSucceed(() => '')), diff --git a/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts b/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts new file mode 100644 index 000000000..45912e9e9 --- /dev/null +++ b/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts @@ -0,0 +1,218 @@ +import { NodeFileSystem, NodePath } from '@effect/platform-node' +import * as NodeChildProcessSpawner from '@effect/platform-node-shared/NodeChildProcessSpawner' +import { Gherkin, Given, it, makeFeature, Then } from '@systemfsoftware/effect-gherkin-spec' +import { Options } from '@systemfsoftware/stryker-js-plugin-interface' +import { CheckerRuntime } from '@systemfsoftware/stryker-js-typescript-checker/runtime' +import * as Effect from 'effect/Effect' +import * as FileSystem from 'effect/FileSystem' +import * as Layer from 'effect/Layer' +import * as Path from 'effect/Path' +import * as S from 'effect/Schema' + +const Feature = makeFeature({ it }) + +const FILE_AND_PATH = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer) + +const FILE_PORTS = Layer.mergeAll( + FILE_AND_PATH, + NodeChildProcessSpawner.layer.pipe(Layer.provide(FILE_AND_PATH)), +) + +const TSCONFIG_FILE = 'tsconfig.json' +const MAIN_FILE = 'src/main.ts' +const CHAIN_FILE = 'src/chain.ts' +const DECLARATION_FILE = 'src/deep/types.d.ts' +const OUTSIDE_FILE = 'outside/notes.ts' + +const TSCONFIG_SOURCE = JSON.stringify( + { + compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true }, + include: ['src'], + }, + null, + 2, +) + '\n' + +const STRICT_TSCONFIG_SOURCE = JSON.stringify( + { + compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: false }, + include: ['src'], + }, + null, + 2, +) + '\n' + +const MAIN_SOURCE = [ + "import { shifted } from './chain.js'", + '', + 'export const doubled = (value: number): number => value * 2', + 'export const combined = (value: number): number => doubled(value) + shifted()', + '', +].join('\n') + +const CHAIN_SOURCE = [ + "import type { Offset } from './deep/types.js'", + '', + 'export const shifted = (): number => (1 as Offset) + 1', + '', +].join('\n') + +const DECLARATION_SOURCE = 'export type Offset = number\n' + +const OUTSIDE_SOURCE = 'export const notes: number = 1\n' + +interface Workspace { + readonly directory: string +} + +const writeWorkspace = (): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const directory = yield* fs.makeTempDirectory() + const files: ReadonlyArray = [ + [TSCONFIG_FILE, TSCONFIG_SOURCE], + [MAIN_FILE, MAIN_SOURCE], + [CHAIN_FILE, CHAIN_SOURCE], + [DECLARATION_FILE, DECLARATION_SOURCE], + [OUTSIDE_FILE, OUTSIDE_SOURCE], + ] + yield* Effect.forEach( + files, + ([name, content]) => + Effect.gen(function*() { + const target = path.join(directory, name) + yield* fs.makeDirectory(path.dirname(target), { recursive: true }) + yield* fs.writeFileString(target, content) + }), + { discard: true }, + ) + return { directory } + }).pipe(Effect.orDie) + +const removeWorkspace = (directory: string): Effect.Effect => + Effect.orDie( + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(directory, { recursive: true, force: true })), + ) + +const rewriteFile = (directory: string, file: string, content: string): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + yield* fs.writeFileString(`${directory}/${file}`, content) + }).pipe(Effect.orDie, Effect.provide(FILE_AND_PATH)) + +const appendComment = (directory: string, file: string): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const text = yield* fs.readFileString(`${directory}/${file}`) + yield* fs.writeFileString(`${directory}/${file}`, `${text}// edited between runs\n`) + }).pipe(Effect.orDie, Effect.provide(FILE_AND_PATH)) + +const digestOf = (directory: string): Effect.Effect => + Effect.gen(function*() { + const pathService = yield* Path.Path + const options = yield* S.decodeEffect(Options.StrykerOptionsSchema)({ + tsconfigFile: pathService.join(directory, TSCONFIG_FILE), + }) + return yield* Effect.gen(function*() { + const runtime = yield* CheckerRuntime + const checker = yield* Effect.orDie(runtime.checker) + return String(yield* Effect.orDie(checker.digest)) + }).pipe(Effect.provide(CheckerRuntime.layer(options))) + }).pipe(Effect.orDie, Effect.provide(FILE_PORTS)) + +const DIGEST_SHAPE = /^[0-9a-f]{64}$/u + +const digestTwice = ( + directory: string, + between: (directory: string) => Effect.Effect, +): Effect.Effect<{ readonly first: string; readonly second: string }, never, never> => + Effect.gen(function*() { + const first = yield* digestOf(directory) + yield* between(directory) + const second = yield* digestOf(directory) + return { first, second } + }) + +const withWorkspace = ( + use: (workspace: Workspace) => Effect.Effect, +): Effect.Effect => + Effect.gen(function*() { + const workspace = yield* writeWorkspace() + return yield* use(workspace).pipe(Effect.ensuring(removeWorkspace(workspace.directory))) + }).pipe(Effect.orDie, Effect.provide(FILE_PORTS)) + +Feature('Identifying the TypeScript program a checker loaded', { timeout: 120_000 }) + .withLayer(FILE_PORTS) + .live('one real TypeScript checker runtime over a real program on disk') + .body(({ scenario }) => { + scenario( + 'the digest of an unchanged program is stable and shaped like a digest', + Gherkin.Do.pipe( + Given('a program whose mutated module reaches a declaration through another module')( + 'observed', + () => withWorkspace((workspace) => digestTwice(workspace.directory, () => Effect.void)), + ), + Then('both digests are the same 64-character lowercase hexadecimal digest')((s, expect) => + expect({ + shape: DIGEST_SHAPE.test(s.observed.first) && DIGEST_SHAPE.test(s.observed.second), + stable: s.observed.first === s.observed.second, + }).toEqual({ shape: true, stable: true }) + ), + ), + ) + + scenario( + 'editing a declaration the mutated module only reaches transitively moves the digest', + Gherkin.Do.pipe( + Given('a program whose declaration file is loaded through another module')( + 'observed', + () => + withWorkspace((workspace) => + digestTwice(workspace.directory, (directory) => appendComment(directory, DECLARATION_FILE)) + ), + ), + Then('the digest moves')((s, expect) => + expect({ moved: s.observed.first !== s.observed.second }).toEqual({ moved: true }) + ), + ), + ) + + scenario( + 'editing the tsconfig moves the digest', + Gherkin.Do.pipe( + Given('a program configured by a tsconfig')( + 'observed', + () => + withWorkspace((workspace) => + digestTwice( + workspace.directory, + (directory) => rewriteFile(directory, TSCONFIG_FILE, STRICT_TSCONFIG_SOURCE), + ) + ), + ), + Then('the digest moves')((s, expect) => + expect({ moved: s.observed.first !== s.observed.second }).toEqual({ moved: true }) + ), + ), + ) + + scenario( + 'editing a source file the program never loads leaves the digest alone', + Gherkin.Do.pipe( + Given('a program whose tsconfig includes only its source directory')( + 'observed', + () => + withWorkspace((workspace) => + digestTwice( + workspace.directory, + (directory) => rewriteFile(directory, OUTSIDE_FILE, `${OUTSIDE_SOURCE}// edited between runs\n`), + ) + ), + ), + Then('the digest is unchanged')((s, expect) => + expect({ stable: s.observed.first === s.observed.second }).toEqual({ stable: true }) + ), + ), + ) + }) diff --git a/packages/stryker-js/src/Checker/Checker.blueprint.ts b/packages/stryker-js/src/Checker/Checker.blueprint.ts index a0609b4e2..68ad81779 100644 --- a/packages/stryker-js/src/Checker/Checker.blueprint.ts +++ b/packages/stryker-js/src/Checker/Checker.blueprint.ts @@ -13,6 +13,7 @@ import { type CheckerHandle, type CheckerResourceService, connectionCrashed, + digest, group, makeCheckerHandle, } from './Checker.handle.js' @@ -42,6 +43,7 @@ const nodeArgsOf = (options: Options.StrykerOptions) => const serviceOf = (handle: CheckerHandle): CheckerResourceService => ({ check: (checkerName, mutants) => check(handle, checkerName, mutants), group: (checkerName, mutants) => group(handle, checkerName, mutants), + digest: (checkerName) => digest(handle, checkerName), }) const acquire = Effect.fnUntraced(function*(spec: CheckerSpec) { diff --git a/packages/stryker-js/src/Checker/Checker.handle.ts b/packages/stryker-js/src/Checker/Checker.handle.ts index 794edf107..4b339fe50 100644 --- a/packages/stryker-js/src/Checker/Checker.handle.ts +++ b/packages/stryker-js/src/Checker/Checker.handle.ts @@ -60,6 +60,9 @@ export interface CheckerResourceService { checkerName: string, mutants: readonly Checker.CheckerMutantWire[], ) => Effect.Effect + readonly digest: ( + checkerName: string, + ) => Effect.Effect } export const connectionCrashed = (cause: string): ChildProcessCrashedError => @@ -118,6 +121,11 @@ const groupOf = (self: CheckerHandle, checkerName: string, mutants: readonly Che CheckerHandle.slot(self).group({ checkerName, mutants: [...mutants] }), ) +const digestOf = (self: CheckerHandle, checkerName: string) => + CheckerHandle.slot(self).digest({ checkerName }).pipe( + Effect.withSpan(SpanTaxonomy.Spans.checkerDigest.name, { attributes: { 'stryker.checker.name': checkerName } }), + ) + export const check: { ( checkerName: string, @@ -150,4 +158,17 @@ export const group: { groupOf(self, checkerName, mutants), ) +export const digest: { + ( + checkerName: string, + ): (self: CheckerHandle) => Effect.Effect + ( + self: CheckerHandle, + checkerName: string, + ): Effect.Effect +} = dual( + (args) => isCheckerHandle(args[0]), + (self: CheckerHandle, checkerName: string) => digestOf(self, checkerName), +) + export const makeCheckerHandle = (client: CheckerClient): CheckerHandle => CheckerHandle.make({}, client) diff --git a/packages/stryker-js/src/Checker/checker-pool.handle.ts b/packages/stryker-js/src/Checker/checker-pool.handle.ts index ccbf4a6dd..fe7d8df20 100644 --- a/packages/stryker-js/src/Checker/checker-pool.handle.ts +++ b/packages/stryker-js/src/Checker/checker-pool.handle.ts @@ -1,3 +1,5 @@ +import { sha256 } from '@noble/hashes/sha2.js' +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js' import { Handle } from '@systemfsoftware/effect-cell-types' import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' import type { Mutant } from '@systemfsoftware/stryker-js-plugin-interface' @@ -31,6 +33,8 @@ export type TypeId = typeof TypeId export type CheckerSlot = { readonly checkerName: string; readonly checker: CheckerResourceService }[] +const hashOf = (content: string): string => bytesToHex(sha256(utf8ToBytes(content))) + export type CheckerPool = Pool.Pool export interface CheckedPlans { @@ -176,6 +180,26 @@ export const splitCheckedPlans = Effect.fn(SpanTaxonomy.Spans.checkerPoolSplitCh const checkerNamesOf = (pool: CheckerPool): Effect.Effect => Pool.use(pool, (slot) => Effect.succeed(slot.map(({ checkerName }) => checkerName))) +const digestLineOf = ({ checkerName, checker }: CheckerSlot[number]): Effect.Effect> => + checker.digest(checkerName).pipe( + Effect.map((digest) => Option.some(`${checkerName}\u0000${digest}`)), + Effect.catchTags({ + CheckerFailed: () => Effect.succeed(Option.none()), + ChildProcessCrashedError: () => Effect.succeed(Option.none()), + OutOfMemoryError: () => Effect.succeed(Option.none()), + }), + ) + +export const programDigestOf = (handle: CheckerPoolHandle): Effect.Effect => + Effect.orElseSucceed(answeredProgramDigestOf(handle), () => undefined) + +const answeredProgramDigestOf = Effect.fn(SpanTaxonomy.Spans.checkerPoolProgramDigest.name)(function*( + handle: CheckerPoolHandle, +) { + const lines = yield* Pool.use(CheckerPoolHandle.slot(handle), (slot) => Effect.forEach(slot, digestLineOf)) + return Option.getOrUndefined(Option.map(Option.all(lines), (answered) => hashOf([...answered].sort().join('\n')))) +}) + const failedElement = ( failedChecks: readonly (readonly [Mutant.MutantRunPlan, Checker.FailedCheckResult])[], checkMsByMutantId: Readonly>, diff --git a/packages/stryker-js/src/IncrementalDiff.schema.ts b/packages/stryker-js/src/IncrementalDiff.schema.ts index 36195fc80..57ee1c38f 100644 --- a/packages/stryker-js/src/IncrementalDiff.schema.ts +++ b/packages/stryker-js/src/IncrementalDiff.schema.ts @@ -14,6 +14,7 @@ export const ReuseRefusalReasonSchema = S.Literals([ 'policyChanged', 'runInputsChanged', 'closureChanged', + 'programChanged', 'flakyDependency', 'timeoutUnreproduced', 'noPriorRecord', @@ -40,6 +41,7 @@ export const PreviousReuseRecordSchema = S.Struct({ mutantId: Mutant.MutantId, status: Mutant.MutantStatusSchema, closureDigest: S.optional(S.String), + programDigest: S.optional(S.String), verdictSemanticsVersion: S.Int, mutantSetPolicy: Options.MutantSetPolicy, runInputsDigest: S.String, @@ -56,6 +58,7 @@ const ReuseMutantSchema = S.Struct({ id: Mutant.MutantId, status: Mutant.MutantStatusSchema, closureDigest: S.optional(S.String), + programDigest: S.optional(S.String), timeoutKind: S.optional(TimeoutKindSchema), reproductions: S.optional(S.Natural), testsCompleted: S.optional(S.Finite), diff --git a/packages/stryker-js/src/__tests__/__property_seeds__/incremental-diff.workflow.property.test.ts.jsonl b/packages/stryker-js/src/__tests__/__property_seeds__/incremental-diff.workflow.property.test.ts.jsonl new file mode 100644 index 000000000..92cc7b380 --- /dev/null +++ b/packages/stryker-js/src/__tests__/__property_seeds__/incremental-diff.workflow.property.test.ts.jsonl @@ -0,0 +1,3 @@ +{"_tag":"Refuted","property":"incrementalDiff > ∀is_RecordAndDigest_≡AChangedClosureDigestRunsNamingClosureChanged","seed":3155186633,"attempt":8,"size":1,"path":[0,0,0,0],"failure":"ReturnedFalse"} +{"_tag":"Refuted","property":"incrementalDiff > ∀r_RecordWithAKey_≡AFailedClosureAnalysisRunsNamingClosureChanged","seed":56957555,"attempt":1,"size":0,"path":[0],"failure":"ReturnedFalse"} +{"_tag":"Refuted","property":"incrementalDiff > ∀r_Record_≡AStaticMutantWithoutFlakesIsRememberedExactlyLikeAnyOtherMutant","seed":4144520539,"attempt":99,"size":10,"path":[0,1,1,1,1,1,1,2,2,2,2],"failure":"ReturnedFalse"} diff --git a/packages/stryker-js/src/__tests__/checker-pool.workflow.property.test.ts b/packages/stryker-js/src/__tests__/checker-pool.workflow.property.test.ts index c72e59132..eaac3ef2a 100644 --- a/packages/stryker-js/src/__tests__/checker-pool.workflow.property.test.ts +++ b/packages/stryker-js/src/__tests__/checker-pool.workflow.property.test.ts @@ -101,10 +101,16 @@ const passedAnswers = (mutants: readonly Checker.CheckerMutantWire[]): Record mutants.map((mutant) => [mutant.id]) +const UNUSED_DIGEST = Checker.ProgramDigest.make('0'.repeat(64)) + const checkerServiceOf = (handlers: { readonly group: CheckerResourceService['group'] readonly check: CheckerResourceService['check'] -}): CheckerResourceService => ({ group: handlers.group, check: handlers.check }) +}): CheckerResourceService => ({ + group: handlers.group, + check: handlers.check, + digest: () => Effect.succeed(UNUSED_DIGEST), +}) const checkerSlotOf = (checkerName: string, checker: CheckerResourceService): CheckerSlot => [{ checkerName, diff --git a/packages/stryker-js/src/__tests__/incremental-diff.workflow.property.test.ts b/packages/stryker-js/src/__tests__/incremental-diff.workflow.property.test.ts index 22288db10..e4ecf9a0f 100644 --- a/packages/stryker-js/src/__tests__/incremental-diff.workflow.property.test.ts +++ b/packages/stryker-js/src/__tests__/incremental-diff.workflow.property.test.ts @@ -1,4 +1,4 @@ -import { Mutant, Options } from '@systemfsoftware/stryker-js-plugin-interface' +import { Checker, Mutant, Options } from '@systemfsoftware/stryker-js-plugin-interface' import { describe, it } from '@systemfsoftware/vitest' import * as Arr from 'effect/Array' import * as Result from 'effect/Result' @@ -30,7 +30,22 @@ const isReusable = S.is(Mutant.RememberedStatusSchema) const unreproducedWallClock = (record: PreviousReuseRecord): boolean => record.status === 'Timeout' && record.timeoutKind !== 'hitLimit' && (record.reproductions ?? 0) < 1 -const remembers = (record: PreviousReuseRecord): boolean => isReusable(record.status) && !unreproducedWallClock(record) +const matchingProgramRecordDigest = (record: PreviousReuseRecord, digest: string | undefined): boolean => + (record.programDigest ?? '') !== '' && record.programDigest === digest + +const remembersWith = (record: PreviousReuseRecord, programDigest: string | undefined): boolean => + isReusable(record.status) && + !unreproducedWallClock(record) && + (record.status === 'CompileError' ? matchingProgramRecordDigest(record, programDigest) : true) + +const remembers = (record: PreviousReuseRecord): boolean => remembersWith(record, record.programDigest) + +const refusalOfTheMatchingCommand = (record: PreviousReuseRecord): string => + record.status === 'CompileError' && (record.programDigest ?? '') === '' + ? 'programChanged' + : unreproducedWallClock(record) + ? 'timeoutUnreproduced' + : 'noPriorRecord' const recordOf = ( mutantId: Mutant.MutantId, @@ -53,6 +68,7 @@ interface CommandFields { readonly verdictSemanticsVersion?: number readonly mutantSetPolicy?: Options.MutantSetPolicy readonly runInputsDigest?: string + readonly programDigest?: string readonly force?: boolean readonly previousRecords?: ReadonlyArray readonly flakyMutantIds?: ReadonlyArray @@ -71,6 +87,7 @@ const commandOf = ( verdictSemanticsVersion: fields.verdictSemanticsVersion ?? 1, mutantSetPolicy: fields.mutantSetPolicy ?? 'default', runInputsDigest: fields.runInputsDigest ?? 'run-inputs', + ...(fields.programDigest === undefined ? {} : { programDigest: fields.programDigest }), force: fields.force ?? false, ...(fields.flakyMutantIds === undefined ? {} : { flakyMutantIds: [...fields.flakyMutantIds] }), }) @@ -84,6 +101,7 @@ const matchingCommandOf = ( verdictSemanticsVersion: record.verdictSemanticsVersion, mutantSetPolicy: record.mutantSetPolicy, runInputsDigest: record.runInputsDigest, + ...(record.programDigest === undefined ? {} : { programDigest: record.programDigest }), ...fields, }) @@ -146,10 +164,41 @@ describe('incrementalDiff', () => { if (decision === undefined) { return false } - return remembers(record) + return remembersWith(record, record.programDigest) ? S.is(MutantRemembered)(decision) && decision.mutantId === record.mutantId && decision.status === record.status - : S.is(MutantToRun)(decision) && - decision.refusal === (unreproducedWallClock(record) ? 'timeoutUnreproduced' : 'noPriorRecord') + : S.is(MutantToRun)(decision) && decision.refusal === refusalOfTheMatchingCommand(record) + }, + ) + + it.prop( + '∀r_RecordWithAProgramDigest_≡AMatchingProgramDigestRemembersACompileError', + { of: [PreviousReuseRecordSchema], subject: incrementalDiff }, + (subject, [record]) => { + const programDigest = 'a'.repeat(64) + const prior = { ...record, status: 'CompileError' as const, programDigest } + const decision = onlyDecision(subject(matchingCommandOf(prior))) + return decision !== undefined && S.is(MutantRemembered)(decision) && decision.status === 'CompileError' + }, + ) + + it.prop( + '∀rd_RecordAndDigest_≡AChangedProgramDigestRunsNamingProgramChanged', + { of: [PreviousReuseRecordSchema, Checker.ProgramDigest], subject: incrementalDiff }, + (subject, [record, drawn]) => { + const programDigest = 'a'.repeat(64) + const current = drawn === programDigest ? `b${drawn.slice(1)}` : drawn + const prior = { ...record, status: 'CompileError' as const, programDigest } + return runsWithRefusal(subject(matchingCommandOf(prior, { programDigest: current })), 'programChanged') + }, + ) + + it.prop( + '∀r_Record_≡ACompileErrorWithoutAProgramDigestRunsNamingProgramChanged', + { of: [PreviousReuseRecordSchema], subject: incrementalDiff }, + (subject, [record]) => { + const { programDigest: _absent, ...withoutProgramDigest } = record + const prior = { ...withoutProgramDigest, status: 'CompileError' as const } + return runsWithRefusal(subject(matchingCommandOf(prior)), 'programChanged') }, ) @@ -181,15 +230,20 @@ describe('incrementalDiff', () => { ) it.prop( - '∀is_RecordAndDigest_≡AChangedClosureDigestRunsNamingClosureChanged', + '∀is_RecordAndDigest_≡AChangedClosureDigestNamesClosureChangedUnlessTheRecordIsACompileError', { of: [PreviousReuseRecordSchema, S.NonEmptyString], subject: incrementalDiff }, (subject, [record, drawn]) => { const current = record.closureDigest ?? '' const changed = drawn === current ? `${drawn}-changed` : drawn - const result = subject(matchingCommandOf(record, { + const decision = onlyDecision(subject(matchingCommandOf(record, { closureDigestsByMutantId: { [record.mutantId]: changed }, - })) - return runsWithRefusal(result, 'closureChanged') + }))) + if (decision === undefined) { + return false + } + return record.status === 'CompileError' + ? !S.is(MutantToRun)(decision) || decision.refusal !== 'closureChanged' + : S.is(MutantToRun)(decision) && decision.refusal === 'closureChanged' }, ) @@ -222,10 +276,14 @@ describe('incrementalDiff', () => { ) it.prop( - '∀r_RecordWithAKey_≡AFailedClosureAnalysisRunsNamingClosureChanged', + '∀r_RecordWithAKey_≡AFailedClosureAnalysisNamesTheProgramGateForAnUnkeyedCompileError', { of: [PreviousReuseRecordSchema], subject: incrementalDiff }, - (subject, [record]) => - runsWithRefusal(subject(matchingCommandOf(record, { closureAnalysisFailed: true })), 'closureChanged'), + (subject, [record]) => { + const result = subject(matchingCommandOf(record, { closureAnalysisFailed: true })) + return record.status === 'CompileError' && (record.programDigest ?? '') === '' + ? runsWithRefusal(result, 'programChanged') + : runsWithRefusal(result, 'closureChanged') + }, ) it.prop( @@ -279,21 +337,23 @@ describe('incrementalDiff', () => { ) it.prop( - '∀r_Record_≡AStaticMutantWithoutFlakesIsRememberedExactlyLikeAnyOtherMutant', - { of: [PreviousReuseRecordSchema], subject: incrementalDiff }, - (subject, [record]) => { - const mutant = Mutant.Mutant.make({ ...mutantOf(record.mutantId), static: true }) - const decision = onlyDecision(subject(commandOf([mutant], [record], { - closureDigestsByMutantId: { [record.mutantId]: record.closureDigest ?? '' }, - verdictSemanticsVersion: record.verdictSemanticsVersion, - mutantSetPolicy: record.mutantSetPolicy, - runInputsDigest: record.runInputsDigest, - flakyMutantIds: [], - }))) - if (decision === undefined) { - return false - } - return remembers(record) ? S.is(MutantRemembered)(decision) : S.is(MutantToRun)(decision) + '∀d_ProgramDigest_≡AKeyedCompileErrorRecordIsRememberedByItsOwnDigestAndRunsUnderAnother', + { of: [Checker.ProgramDigest], subject: incrementalDiff }, + (subject, [drawn]) => { + const id = Mutant.MutantId.make('0000000000000000') + const digest = 'a'.repeat(64) + const current = drawn === digest ? `b${drawn.slice(1)}` : drawn + const record = recordOf(id, 'CompileError', 'digest', { programDigest: digest }) + const remembered = onlyDecision(subject(matchingCommandOf(record))) + const refused = onlyDecision(subject(matchingCommandOf(record, { programDigest: current }))) + return remembered !== undefined && + S.is(MutantRemembered)(remembered) && + remembered.mutantId === id && + remembered.status === 'CompileError' && + refused !== undefined && + S.is(MutantToRun)(refused) && + refused.mutant.id === id && + refused.refusal === 'programChanged' }, ) diff --git a/packages/stryker-js/src/incremental-diff.workflow.ts b/packages/stryker-js/src/incremental-diff.workflow.ts index 1834e3432..485b119ff 100644 --- a/packages/stryker-js/src/incremental-diff.workflow.ts +++ b/packages/stryker-js/src/incremental-diff.workflow.ts @@ -33,6 +33,7 @@ export class IncrementalDiffCommand extends S.TaggedClass Option.getOrElse(Option.fromUndefinedOr(digest), () => '') +const isCompileErrorRecord = (record: PreviousReuseRecord): boolean => record.status === 'CompileError' + const cacheKeyOf = (mutantId: string, closureDigest: string | undefined, components: CacheKeyComponents): string => [ mutantId, @@ -101,21 +104,47 @@ const cacheKeyOf = (mutantId: string, closureDigest: string | undefined, compone components.runInputsDigest, ].join('\u0000') +const programKeyOf = (mutantId: string, programDigest: string | undefined, components: CacheKeyComponents): string => + [ + mutantId, + digestOf(programDigest), + String(components.verdictSemanticsVersion), + components.mutantSetPolicy, + components.runInputsDigest, + ].join('\u0000') + const currentKeyOf = (command: IncrementalDiffCommand, mutantId: Mutant.MutantId): string => cacheKeyOf(mutantId, command.closureDigestsByMutantId[mutantId], command) +const matchingProgramKey = (command: IncrementalDiffCommand, record: PreviousReuseRecord): boolean => + Boolean.and( + digestOf(record.programDigest) !== '', + programKeyOf(record.mutantId, record.programDigest, record) === + programKeyOf(record.mutantId, command.programDigest, command), + ) + const matchingKey = (command: IncrementalDiffCommand, record: RememberedReuseRecord): boolean => Boolean.and( Boolean.not(command.closureAnalysisFailed), - cacheKeyOf(record.mutantId, record.closureDigest, record) === currentKeyOf(command, record.mutantId), + Boolean.match(isCompileErrorRecord(record), { + onTrue: () => matchingProgramKey(command, record), + onFalse: () => + cacheKeyOf(record.mutantId, record.closureDigest, record) === currentKeyOf(command, record.mutantId), + }), ) const closureChanged = (command: IncrementalDiffCommand, record: PreviousReuseRecord): boolean => Boolean.or( command.closureAnalysisFailed, - digestOf(record.closureDigest) !== digestOf(command.closureDigestsByMutantId[record.mutantId]), + Boolean.and( + Boolean.not(isCompileErrorRecord(record)), + digestOf(record.closureDigest) !== digestOf(command.closureDigestsByMutantId[record.mutantId]), + ), ) +const programChanged = (command: IncrementalDiffCommand, record: PreviousReuseRecord): boolean => + Boolean.and(isCompileErrorRecord(record), Boolean.not(matchingProgramKey(command, record))) + const reasonAfterClosure = (command: IncrementalDiffCommand, record: PreviousReuseRecord): ReuseRefusalReason => Boolean.match(closureChanged(command, record), { onTrue: (): ReuseRefusalReason => 'closureChanged', @@ -129,7 +158,11 @@ const reasonAfterClosure = (command: IncrementalDiffCommand, record: PreviousReu const reasonAfterRunInputs = (command: IncrementalDiffCommand, record: PreviousReuseRecord): ReuseRefusalReason => Boolean.match(record.runInputsDigest !== command.runInputsDigest, { onTrue: (): ReuseRefusalReason => 'runInputsChanged', - onFalse: () => reasonAfterClosure(command, record), + onFalse: () => + Boolean.match(programChanged(command, record), { + onTrue: (): ReuseRefusalReason => 'programChanged', + onFalse: () => reasonAfterClosure(command, record), + }), }) const reasonAfterPolicy = (command: IncrementalDiffCommand, record: PreviousReuseRecord): ReuseRefusalReason => diff --git a/packages/stryker-js/src/mutation-reporting.service.ts b/packages/stryker-js/src/mutation-reporting.service.ts index 870d32ebd..4a672915a 100644 --- a/packages/stryker-js/src/mutation-reporting.service.ts +++ b/packages/stryker-js/src/mutation-reporting.service.ts @@ -126,6 +126,7 @@ export interface MutationReportingInput { readonly timeOverheadMs: number readonly closureDigestsByMutantId?: Readonly> readonly timeoutEvidenceByMutantId?: Readonly> + readonly programDigest?: string readonly concurrency: number readonly runStartedAt: number } @@ -308,6 +309,29 @@ const stampClosureDigests = ( ]), ) +const isCompileError = (mutant: Report.MutantResult): boolean => mutant.status === 'CompileError' + +const withProgramDigest = (mutant: Report.MutantResult, digest: string | undefined): Report.MutantResult => + Option.match(Option.fromUndefinedOr(digest), { + onNone: () => mutant, + onSome: (present) => + Boolean.match(isCompileError(mutant), { + onTrue: () => ({ ...mutant, programDigest: present }), + onFalse: () => mutant, + }), + }) + +const stampProgramDigests = ( + files: Record, + digest: string | undefined, +): Record => + Object.fromEntries( + Object.entries(files).map(([name, file]): readonly [string, Report.FileResult] => [ + name, + { ...file, mutants: file.mutants.map((mutant) => withProgramDigest(mutant, digest)) }, + ]), + ) + const stampFileIdentities = ( files: Report.FileResultDictionary, identities: HashMap.HashMap>, @@ -840,7 +864,10 @@ const writeIncrementalReport = Effect.fn(SpanTaxonomy.Spans.mutationReportingWri mutantSetPolicy: input.options.mutator.mutantSetPolicy, runInputsDigest, ...report, - files: stampFileIdentities(stampClosureDigests(report.files, input.closureDigestsByMutantId), identities), + files: stampFileIdentities( + stampProgramDigests(stampClosureDigests(report.files, input.closureDigestsByMutantId), input.programDigest), + identities, + ), costs: costsOf(input, input.results), ...dryRunCoverageFieldOf(input.testCoverage), }).pipe(Effect.orDie) @@ -926,7 +953,10 @@ const slimIncrementalReport = Effect.fn(SpanTaxonomy.Spans.mutationReportingSlim runInputsDigest, schemaVersion: Report.WrittenSchemaVersion.literal, thresholds: input.options.thresholds, - files: stampFileIdentities(stampClosureDigests(files, input.closureDigestsByMutantId), identities), + files: stampFileIdentities( + stampProgramDigests(stampClosureDigests(files, input.closureDigestsByMutantId), input.programDigest), + identities, + ), costs: costsOf(input, results), testFiles, budget: budgetOf({ diff --git a/packages/stryker-js/src/plan-request.cell.ts b/packages/stryker-js/src/plan-request.cell.ts index 12e4da840..e3c4a44a1 100644 --- a/packages/stryker-js/src/plan-request.cell.ts +++ b/packages/stryker-js/src/plan-request.cell.ts @@ -188,6 +188,7 @@ const planProject = ( ), observedModules: Option.getOrUndefined(Option.map(coverage, (present) => present.testFileModules)), originalFileOf: (file) => path.resolve(file), + programDigestOf: Effect.as(Effect.void, undefined), }) const mutants = [ ...reuse.mutants.map((mutant) => ({ diff --git a/packages/stryker-js/src/run/incremental-reuse.cell.ts b/packages/stryker-js/src/run/incremental-reuse.cell.ts index cd5dd0c56..f7986c59f 100644 --- a/packages/stryker-js/src/run/incremental-reuse.cell.ts +++ b/packages/stryker-js/src/run/incremental-reuse.cell.ts @@ -46,6 +46,7 @@ const emptyRefusalCounts = (): Record => ({ policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + programChanged: 0, flakyDependency: 0, timeoutUnreproduced: 0, noPriorRecord: 0, @@ -102,6 +103,7 @@ const recordsOfReport = (report: ReuseReport): readonly PreviousReuseRecord[] => mutantId: mutant.id, status: mutant.status, ...digestField(mutant.closureDigest), + ...optionalField('programDigest', mutant.programDigest), verdictSemanticsVersion: report.verdictSemanticsVersion, mutantSetPolicy: report.mutantSetPolicy, runInputsDigest: report.runInputsDigest, @@ -343,6 +345,7 @@ const readIncrementalReuseCommand = Effect.fn(SpanTaxonomy.Spans.incrementalReus const previousRecords = yield* previousRecordsOf(input) const closures = yield* closureDigestsOf(input) const runInputsDigest = yield* runInputsDigestOf(fs, path, input.basePath, input.options) + const programDigest = yield* input.programDigestOf const command: typeof IncrementalDiffCommand.Encoded = { _tag: 'IncrementalDiffCommand', currentMutants: [...input.currentMutants], @@ -353,6 +356,10 @@ const readIncrementalReuseCommand = Effect.fn(SpanTaxonomy.Spans.incrementalReus mutantSetPolicy: input.options.mutator.mutantSetPolicy, runInputsDigest, force: input.force, + ...Option.match(Option.fromUndefinedOr(programDigest), { + onNone: () => ({}), + onSome: (present) => ({ programDigest: present }), + }), flakyMutantIds: Option.getOrElse( Option.map(Option.fromUndefinedOr(input.testCoverage.dryRunCoverage), (coverage) => [ ...coverage.flakyMutantIds, @@ -375,6 +382,7 @@ export interface IncrementalReusePart { readonly closureDigestsByMutantId: Record readonly timeoutEvidenceByMutantId: Record readonly priorKilledByByMutantId: Record + readonly programDigest: string | undefined } const priorTimeoutEvidenceOf = (decision: typeof MutantToRun.Encoded): Record => @@ -413,6 +421,7 @@ const mutantToRunPart = Effect.fnUntraced(function*( onNone: () => ({}), onSome: (killedBy) => ({ [mutant.id]: [...killedBy] }), }), + programDigest: command.programDigest, }) }) @@ -460,6 +469,7 @@ const rememberedMutantPart = Effect.fnUntraced(function*( closureDigestsByMutantId: command.closureDigestsByMutantId, timeoutEvidenceByMutantId: {}, priorKilledByByMutantId: {}, + programDigest: command.programDigest, }), onSome: (mutant) => Effect.map( @@ -471,6 +481,7 @@ const rememberedMutantPart = Effect.fnUntraced(function*( closureDigestsByMutantId: command.closureDigestsByMutantId, timeoutEvidenceByMutantId: rememberedTimeoutEvidenceOf(decision), priorKilledByByMutantId: {}, + programDigest: command.programDigest, }), ), }) @@ -495,6 +506,7 @@ export interface IncrementalReuseInput { readonly globalTestInputs: readonly string[] readonly observedModules: Readonly> | undefined readonly originalFileOf: (file: string) => string + readonly programDigestOf: Effect.Effect } export type RefusalCounts = Record @@ -506,6 +518,7 @@ export interface IncrementalReuse { readonly closureDigestsByMutantId: Record readonly timeoutEvidenceByMutantId: Record readonly priorKilledByByMutantId: Record + readonly programDigest: string | undefined } const closureDigestsOfParts = (parts: readonly IncrementalReusePart[]): Record => @@ -514,6 +527,9 @@ const closureDigestsOfParts = (parts: readonly IncrementalReusePart[]): Record => ({}), ) +const firstProgramDigestOfParts = (parts: readonly IncrementalReusePart[]): string | undefined => + Option.flatMap(Arr.head(parts), (part) => Option.fromUndefinedOr(part.programDigest)).pipe(Option.getOrUndefined) + const timeoutEvidenceOfParts = (parts: readonly IncrementalReusePart[]): Record => parts.reduce>( (accumulated, part) => ({ ...accumulated, ...part.timeoutEvidenceByMutantId }), @@ -537,4 +553,5 @@ export const readIncrementalReuse = (input: IncrementalReuseInput) => closureDigestsByMutantId: closureDigestsOfParts(parts), timeoutEvidenceByMutantId: timeoutEvidenceOfParts(parts), priorKilledByByMutantId: priorKilledByOfParts(parts), + programDigest: firstProgramDigestOfParts(parts), })) diff --git a/packages/stryker-js/src/run/mutation-test.cell.ts b/packages/stryker-js/src/run/mutation-test.cell.ts index 25f225585..c1c78d494 100644 --- a/packages/stryker-js/src/run/mutation-test.cell.ts +++ b/packages/stryker-js/src/run/mutation-test.cell.ts @@ -23,6 +23,7 @@ import { checkPlansStream as checkPlansWithConfiguredCheckers, inOwnScope, makeCheckerPoolHandle, + programDigestOf, runCheckedPlans, } from '../Checker/checker-pool.handle.js' import { checkOnlyCostOf, decidedWithoutATest } from '../mutant-cost.js' @@ -102,6 +103,12 @@ const writeMutationTestDryRunOnly = Effect.fn(SpanTaxonomy.Spans.mutationTestDry return { results: [], verdict: null } }) +const programDigestFieldOf = (programDigest: string | undefined): Record => + Option.match(Option.fromUndefinedOr(programDigest), { + onNone: (): Record => ({}), + onSome: (present) => ({ programDigest: present }), + }) + const proceedPipeline = Effect.fnUntraced(function*(raw: MutationTestRaw) { const prev = raw.prev const testRunnerCapacity = prev.concurrency.testRunners + prev.concurrency.checkers @@ -134,6 +141,7 @@ const proceedPipeline = Effect.fnUntraced(function*(raw: MutationTestRaw) { }) const reporting = yield* MutationReporting const progressQueue = yield* RunEvents + const checkerHandle = Option.map(Option.fromNullishOr(checkers.resources), makeCheckerPoolHandle) const reuse = yield* readIncrementalReuse({ project: prev.project, currentMutants: plannableMutants, @@ -144,6 +152,10 @@ const proceedPipeline = Effect.fnUntraced(function*(raw: MutationTestRaw) { globalTestInputs: prev.dryRunResult.globalTestInputs ?? [], observedModules: prev.dryRunResult.testFileModules, originalFileOf: (file) => originalFileFor(prev.sandbox, file), + programDigestOf: Option.match(checkerHandle, { + onNone: () => Effect.as(Effect.void, undefined), + onSome: (handle) => programDigestOf(handle), + }), }) const rememberedResults = reuse.rememberedResults yield* Queue.offer( @@ -171,7 +183,6 @@ const proceedPipeline = Effect.fnUntraced(function*(raw: MutationTestRaw) { rememberedCount: rememberedResults.length, reporterStage: prev.reporterStage, }) - const checkerHandle = Option.map(Option.fromNullishOr(checkers.resources), makeCheckerPoolHandle) const checkedPlans = checkPlansWithConfiguredCheckers(Option.getOrUndefined(checkerHandle), plan.runPlans) const completedRef = yield* Ref.make(0) const pathService = yield* Path.Path @@ -258,6 +269,7 @@ const proceedPipeline = Effect.fnUntraced(function*(raw: MutationTestRaw) { ...reportingInputOf({ prev, env, results: allResults }), closureDigestsByMutantId: reuse.closureDigestsByMutantId, timeoutEvidenceByMutantId: reuse.timeoutEvidenceByMutantId, + ...programDigestFieldOf(reuse.programDigest), }) yield* Fiber.await(checkerRelease) const doneNow = yield* Clock.currentTimeMillis diff --git a/packages/stryker-js/tests/__fixtures__/cost-checker/worker.mjs b/packages/stryker-js/tests/__fixtures__/cost-checker/worker.mjs index dcf244d25..1b69b01ca 100644 --- a/packages/stryker-js/tests/__fixtures__/cost-checker/worker.mjs +++ b/packages/stryker-js/tests/__fixtures__/cost-checker/worker.mjs @@ -27,6 +27,7 @@ const CHECK_DELAY = '25 millis' const handlers = Plugin.CheckerRpcs.toLayer({ group: ({ mutants }) => Effect.succeed([mutants.map((mutant) => mutant.id)]), + digest: () => Effect.succeed('0123456789abcdef'.repeat(4)), check: ({ mutants }) => Effect.as( Effect.sleep(CHECK_DELAY), diff --git a/packages/stryker-js/tests/__fixtures__/program-digesting-checker/index.mjs b/packages/stryker-js/tests/__fixtures__/program-digesting-checker/index.mjs new file mode 100644 index 000000000..2f5332442 --- /dev/null +++ b/packages/stryker-js/tests/__fixtures__/program-digesting-checker/index.mjs @@ -0,0 +1,3 @@ +export const strykerPlugins = [ + { kind: 'Checker', name: 'program-digesting', workerEntry: new URL('./worker.mjs', import.meta.url).href }, +] diff --git a/packages/stryker-js/tests/__fixtures__/program-digesting-checker/worker.mjs b/packages/stryker-js/tests/__fixtures__/program-digesting-checker/worker.mjs new file mode 100644 index 000000000..7da86d265 --- /dev/null +++ b/packages/stryker-js/tests/__fixtures__/program-digesting-checker/worker.mjs @@ -0,0 +1,50 @@ +import { NodeFileSystem, NodeSocketServer } from '@effect/platform-node' +import * as NodeRuntime from '@effect/platform-node/NodeRuntime' +import { Plugin } from '@systemfsoftware/stryker-js-plugin-interface' +import { Worker } from '@systemfsoftware/stryker-js-plugin-runtime' +import * as Config from 'effect/Config' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' + +const PROGRAM_FILES = ['tsconfig.json', 'src/lib/chain.ts', 'src/lib/subject.ts', 'src/types/transitive.d.ts'] + +const MUTATED_FILE = 'src/lib/subject.ts' +const REJECTION_REASON = 'rejected by the fixture checker' + +const programDigestOf = async () => { + const lines = [] + for (const file of [...PROGRAM_FILES].sort()) { + lines.push(`${file}\u0000${await readFile(file, 'utf8')}`) + } + return createHash('sha256').update(lines.join('\n')).digest('hex') +} + +const answerOf = (mutant) => + mutant.fileName === MUTATED_FILE || mutant.fileName.endsWith(`/${MUTATED_FILE}`) + ? { status: 'compileError', reason: REJECTION_REASON } + : { status: 'passed' } + +const handlers = Plugin.CheckerRpcs.toLayer({ + group: ({ mutants }) => Effect.succeed([mutants.map((mutant) => mutant.id)]), + digest: () => Effect.promise(programDigestOf), + check: ({ mutants }) => + Effect.succeed( + Object.fromEntries(mutants.map((mutant) => [mutant.id, answerOf(mutant)])), + ), +}) + +const platform = Layer.unwrap( + Effect.gen(function*() { + const socketPath = yield* Config.String('STRYKER_SOCKET') + return Layer.merge(NodeSocketServer.layer({ path: socketPath }), NodeFileSystem.layer) + }), +) + +NodeRuntime.runMain( + Worker.workerServerLayer({ rpcs: Plugin.CheckerRpcs, handlers, schemaServices: Layer.empty }).pipe( + Layer.provide(platform), + Layer.launch, + ), +) diff --git a/packages/stryker-js/tests/__fixtures__/rejecting-checker/worker.mjs b/packages/stryker-js/tests/__fixtures__/rejecting-checker/worker.mjs index 93802d8dd..11849459c 100644 --- a/packages/stryker-js/tests/__fixtures__/rejecting-checker/worker.mjs +++ b/packages/stryker-js/tests/__fixtures__/rejecting-checker/worker.mjs @@ -16,6 +16,7 @@ const answerOf = (mutant) => const handlers = Plugin.CheckerRpcs.toLayer({ group: ({ mutants }) => Effect.succeed([mutants.map((mutant) => mutant.id)]), + digest: () => Effect.succeed('0123456789abcdef'.repeat(4)), check: ({ mutants }) => Effect.succeed( Object.fromEntries(mutants.map((mutant) => [mutant.id, answerOf(mutant)])), diff --git a/packages/stryker-js/tests/checker-rpc.integration.test.ts b/packages/stryker-js/tests/checker-rpc.integration.test.ts index 69d1ceb1d..4cb6abba4 100644 --- a/packages/stryker-js/tests/checker-rpc.integration.test.ts +++ b/packages/stryker-js/tests/checker-rpc.integration.test.ts @@ -1,7 +1,7 @@ import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' import { Worker } from '@systemfsoftware/stryker-js' import { Mutant } from '@systemfsoftware/stryker-js-plugin-interface' -import { type Checker, Options, Plugin } from '@systemfsoftware/stryker-js-plugin-interface' +import { Checker, Options, Plugin } from '@systemfsoftware/stryker-js-plugin-interface' import { Trace } from '@systemfsoftware/stryker-js-plugin-runtime' import * as Effect from 'effect/Effect' import * as Exit from 'effect/Exit' @@ -20,6 +20,8 @@ import { memorySocketPair, singleConnection } from './__fixtures__/substituted-w const Feature = makeFeature({ it }) +const FAKE_DIGEST = Checker.ProgramDigest.make('0123456789abcdef'.repeat(4)) + type CheckerRpcsUnion = typeof Plugin.CheckerRpcs extends RpcGroup.RpcGroup ? Rpcs : never interface CheckerHarness { @@ -43,6 +45,7 @@ const makeCheckerServer = ( ), ), group: ({ mutants }) => Effect.succeed([mutants.map((m) => m.id)]), + digest: () => Effect.succeed(FAKE_DIGEST), }), ), Layer.provide(RpcServer.layerProtocolSocketServer), diff --git a/packages/stryker-js/tests/compile-error-reuse.integration.test.ts b/packages/stryker-js/tests/compile-error-reuse.integration.test.ts new file mode 100644 index 000000000..5148f6332 --- /dev/null +++ b/packages/stryker-js/tests/compile-error-reuse.integration.test.ts @@ -0,0 +1,358 @@ +import { NodeFileSystem, NodePath } from '@effect/platform-node' +import { Gherkin, Given, it, makeFeature, Then } from '@systemfsoftware/effect-gherkin-spec' +import { Engine } from '@systemfsoftware/stryker-js' +import { RunEvent } from '@systemfsoftware/stryker-js-cli-contract' +import type * as Cause from 'effect/Cause' +import * as Effect from 'effect/Effect' +import * as Exit from 'effect/Exit' +import * as FileSystem from 'effect/FileSystem' +import * as Layer from 'effect/Layer' +import * as Option from 'effect/Option' +import * as Path from 'effect/Path' +import * as Queue from 'effect/Queue' +import * as S from 'effect/Schema' + +const Feature = makeFeature({ it }) + +const filePorts = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer) + +const PACKAGE_ROOT = decodeURIComponent(new URL('..', import.meta.url).pathname).replace(/\/$/, '') + +const CHECKER_PLUGIN = new URL('./__fixtures__/program-digesting-checker/index.mjs', import.meta.url).href + +const SUBJECT_FILE = 'src/lib/subject.ts' +const CHAIN_FILE = 'src/lib/chain.ts' +const DECLARATION_FILE = 'src/types/transitive.d.ts' +const UNRELATED_TEST_FILE = 'test/unrelated.test.mjs' +const TSCONFIG_FILE = 'tsconfig.json' + +const SUBJECT_SOURCE = [ + "import { shifted } from './chain.ts'", + '', + 'export const doubled = (value: number): number => value * 2', + 'export const combined = (value: number): number => doubled(value) + shifted()', + '', +].join('\n') + +const CHAIN_SOURCE = [ + "import type { Offset } from '../types/transitive.d.ts'", + '', + 'export const shifted = (): number => (1 as Offset) + 1', + '', +].join('\n') + +const DECLARATION_SOURCE = 'export type Offset = number\n' + +const TEST_SOURCE = [ + "import { expect, test } from 'vitest'", + "import { combined } from '../src/lib/subject.ts'", + '', + "test('loads the subject module without exercising it', () => {", + " expect(typeof combined).toBe('function')", + '})', + '', +].join('\n') + +const UNRELATED_TEST_SOURCE = [ + "import { expect, test } from 'vitest'", + '', + "test('adds two numbers', () => {", + ' expect(1 + 1).toBe(2)', + '})', + '', +].join('\n') + +const TSCONFIG_SOURCE = JSON.stringify({ compilerOptions: { target: 'ES2022' }, include: ['src'] }, null, 2) + '\n' + +const VITEST_CONFIG_SOURCE = 'export default { test: { testTimeout: 600_000, hookTimeout: 600_000 } }\n' + +interface Workspace { + readonly directory: string +} + +const incrementalFileOf = (directory: string): string => `${directory}/reports/main.json` + +const writeWorkspace = (): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const directory = yield* fs.makeTempDirectory() + const files: ReadonlyArray = [ + ['package.json', '{ "type": "commonjs" }\n'], + ['vitest.config.ts', VITEST_CONFIG_SOURCE], + [TSCONFIG_FILE, TSCONFIG_SOURCE], + [SUBJECT_FILE, SUBJECT_SOURCE], + [CHAIN_FILE, CHAIN_SOURCE], + [DECLARATION_FILE, DECLARATION_SOURCE], + ['test/sample.test.mjs', TEST_SOURCE], + [UNRELATED_TEST_FILE, UNRELATED_TEST_SOURCE], + ] + yield* Effect.forEach( + files, + ([name, content]) => + Effect.gen(function*() { + const target = path.join(directory, name) + yield* fs.makeDirectory(path.dirname(target), { recursive: true }) + yield* fs.writeFileString(target, content) + }), + { discard: true }, + ) + yield* fs.symlink(path.join(PACKAGE_ROOT, 'node_modules'), path.join(directory, 'node_modules')) + return { directory } + }).pipe(Effect.orDie) + +const removeWorkspace = (directory: string): Effect.Effect => + Effect.orDie( + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(directory, { recursive: true, force: true })), + ) + +const rewriteFile = (directory: string, file: string, content: string): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + yield* fs.writeFileString(`${directory}/${file}`, content) + }).pipe(Effect.orDie, Effect.provide(filePorts)) + +const appendComment = (directory: string, file: string): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const target = path.join(directory, file) + const text = yield* fs.readFileString(target) + yield* fs.writeFileString(target, `${text}\n// edited between runs\n`) + }).pipe(Effect.orDie, Effect.provide(filePorts)) + +const environmentFor = (directory: string): Engine.RunEnvironmentShape => ({ + runId: '01ARZ3NDEKTSV4RRFFQ69G5FAX', + resolvedMode: { mode: 'machine', signal: 'flag', stdoutIsTTY: false }, + runStartedAt: 0, + basePath: directory, + builtinReporters: {}, + allowConsoleColors: false, +}) + +interface MutantRow { + readonly id: string + readonly status: string + readonly programDigest?: string | undefined +} + +interface ReuseObservation { + readonly exit: Exit.Exit + readonly reuse: RunEvent.ReuseReported | undefined + readonly mutants: readonly MutantRow[] + readonly incrementalText: string +} + +const rowsOf = (text: string): readonly MutantRow[] => { + const rowSchema = S.Struct({ + id: S.String, + status: S.String, + programDigest: S.optional(S.String), + }) + const reportSchema = S.Struct({ + files: S.Record(S.String, S.Struct({ mutants: S.Array(rowSchema) })), + }) + return Option.getOrElse( + Option.map( + S.decodeOption(S.fromJsonString(reportSchema))(text), + (report) => Object.values(report.files).flatMap((file) => file.mutants), + ), + (): readonly MutantRow[] => [], + ) +} + +const executeRun = (workspace: Workspace): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const queue = yield* Queue.bounded(RunEvent.RunEvent.QUEUE_BOUND) + const runLayer = Layer.merge( + Layer.provide(Engine.RunEnvironment.stage(environmentFor(workspace.directory), queue), Engine.nodePlatformLayer), + Engine.nodePlatformLayer, + ) + const exit = yield* Engine.mutationTestCell + .run({ + cliOptions: { + testRunner: 'vm', + plugins: [], + reporters: [], + checkers: [{ plugin: CHECKER_PLUGIN }], + testFiles: ['test/**/*.mjs'], + mutate: [SUBJECT_FILE], + coverageAnalysis: 'perTest', + cleanTempDir: 'always', + incremental: true, + incrementalFile: incrementalFileOf(workspace.directory), + }, + targetMutatePatterns: undefined, + }) + .pipe(Effect.provide(runLayer), Effect.scoped, Effect.exit) + const events = [ + ...(yield* Queue.takeAll(queue).pipe( + Effect.orElseSucceed((): ReadonlyArray => []), + )), + ] + const incrementalText = yield* fs.readFileString(incrementalFileOf(workspace.directory)).pipe( + Effect.orElseSucceed(() => ''), + ) + return { + exit, + reuse: events.find((event): event is RunEvent.ReuseReported => S.is(RunEvent.ReuseReported)(event)), + mutants: rowsOf(incrementalText), + incrementalText, + } + }).pipe(Effect.provide(filePorts)) + +interface TwoRuns { + readonly first: ReuseObservation + readonly second: ReuseObservation +} + +const runTwice = ( + workspace: Workspace, + between: (workspace: Workspace) => Effect.Effect, +): Effect.Effect => + Effect.gen(function*() { + const first = yield* executeRun(workspace) + yield* between(workspace) + const second = yield* executeRun(workspace) + return { first, second } + }) + +const withWorkspace = ( + use: (workspace: Workspace) => Effect.Effect, +): Effect.Effect => + Effect.gen(function*() { + const workspace = yield* writeWorkspace() + return yield* use(workspace).pipe(Effect.ensuring(removeWorkspace(workspace.directory))) + }).pipe(Effect.orDie, Effect.provide(filePorts)) + +const compileErrorRows = (rows: readonly MutantRow[]): readonly MutantRow[] => + rows.filter((row) => row.status === 'CompileError') + +const digestCountOf = (rows: readonly MutantRow[]): number => + compileErrorRows(rows).filter((row) => (row.programDigest ?? '').length > 0).length + +const ranOf = (observation: ReuseObservation): number => observation.reuse?.ran ?? -1 + +const reusedOf = (observation: ReuseObservation): number => observation.reuse?.reused ?? -1 + +const programChangedOf = (observation: ReuseObservation): number => observation.reuse?.refused.programChanged ?? -1 + +Feature('Reusing CompileError verdicts across incremental runs', { timeout: 240_000 }) + .withLayer(Layer.empty) + .live('the run keys every CompileError verdict by the program the checker loaded') + .body(({ scenario }) => { + scenario( + 'an unchanged rerun reuses the CompileError verdicts and checks nothing', + Gherkin.Do.pipe( + Given('a project whose checker rejects every mutant of a module and digests its loaded program')( + 'runs', + () => withWorkspace((workspace) => runTwice(workspace, () => Effect.void)), + ), + Then( + 'the first run stamped every CompileError verdict with a program digest and the second run reused them all without a check', + )((s, expect) => + expect({ + firstCompileErrors: compileErrorRows(s.runs.first.mutants).length > 0, + firstDigestsStamped: digestCountOf(s.runs.first.mutants) === + compileErrorRows(s.runs.first.mutants).length, + secondSucceeded: Exit.isSuccess(s.runs.second.exit), + secondReusedAll: reusedOf(s.runs.second) === s.runs.first.mutants.length, + secondRanNothing: ranOf(s.runs.second), + secondRefusals: programChangedOf(s.runs.second), + }).toEqual({ + firstCompileErrors: true, + firstDigestsStamped: true, + secondSucceeded: true, + secondReusedAll: true, + secondRanNothing: 0, + secondRefusals: 0, + }) + ), + ), + ) + + scenario( + 'editing a declaration the mutated module only reaches transitively re-checks every CompileError', + Gherkin.Do.pipe( + Given('a project whose checker digests a declaration reached through another module')( + 'runs', + () => + withWorkspace((workspace) => + runTwice(workspace, (inner) => appendComment(inner.directory, DECLARATION_FILE)) + ), + ), + Then('the second run refuses the remembered verdicts naming the changed program')((s, expect) => + expect({ + firstCompileErrors: compileErrorRows(s.runs.first.mutants).length > 0, + secondSucceeded: Exit.isSuccess(s.runs.second.exit), + secondRanChecks: ranOf(s.runs.second) > 0, + secondRefusedForProgram: programChangedOf(s.runs.second) > 0, + secondRecheckedStatuses: compileErrorRows(s.runs.second.mutants).length, + }).toEqual({ + firstCompileErrors: true, + secondSucceeded: true, + secondRanChecks: true, + secondRefusedForProgram: true, + secondRecheckedStatuses: compileErrorRows(s.runs.first.mutants).length, + }) + ), + ), + ) + + scenario( + 'changing tsconfig re-checks every CompileError', + Gherkin.Do.pipe( + Given('a project whose checker digests its tsconfig')( + 'runs', + () => + withWorkspace((workspace) => runTwice(workspace, (inner) => appendComment(inner.directory, TSCONFIG_FILE))), + ), + Then('the second run refuses the remembered verdicts naming the changed program')((s, expect) => + expect({ + firstCompileErrors: compileErrorRows(s.runs.first.mutants).length > 0, + secondSucceeded: Exit.isSuccess(s.runs.second.exit), + secondRanChecks: ranOf(s.runs.second) > 0, + secondRefusedForProgram: programChangedOf(s.runs.second) > 0, + }).toEqual({ + firstCompileErrors: true, + secondSucceeded: true, + secondRanChecks: true, + secondRefusedForProgram: true, + }) + ), + ), + ) + + scenario( + 'editing a test file outside the checker program keeps every CompileError remembered', + Gherkin.Do.pipe( + Given('a project whose checker does not digest the unrelated test file')( + 'runs', + () => + withWorkspace((workspace) => + runTwice( + workspace, + (inner) => + rewriteFile(inner.directory, UNRELATED_TEST_FILE, `${UNRELATED_TEST_SOURCE}// edited between runs\n`), + ) + ), + ), + Then('the second run still reuses every CompileError verdict')((s, expect) => + expect({ + firstCompileErrors: compileErrorRows(s.runs.first.mutants).length > 0, + secondSucceeded: Exit.isSuccess(s.runs.second.exit), + secondRanNothing: ranOf(s.runs.second), + secondReusedAll: reusedOf(s.runs.second) === s.runs.first.mutants.length, + secondProgramRefusals: programChangedOf(s.runs.second), + }).toEqual({ + firstCompileErrors: true, + secondSucceeded: true, + secondRanNothing: 0, + secondReusedAll: true, + secondProgramRefusals: 0, + }) + ), + ), + ) + }) diff --git a/packages/stryker-js/tests/dry-run-coverage-across-roots.integration.test.ts b/packages/stryker-js/tests/dry-run-coverage-across-roots.integration.test.ts index 4ea15a42c..020adbe5e 100644 --- a/packages/stryker-js/tests/dry-run-coverage-across-roots.integration.test.ts +++ b/packages/stryker-js/tests/dry-run-coverage-across-roots.integration.test.ts @@ -161,6 +161,7 @@ const ZERO_REFUSALS = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + programChanged: 0, flakyDependency: 0, timeoutUnreproduced: 0, noPriorRecord: 0, diff --git a/packages/stryker-js/tests/incremental-reuse.integration.test.ts b/packages/stryker-js/tests/incremental-reuse.integration.test.ts index fcf1b5e7d..0d53bd0e9 100644 --- a/packages/stryker-js/tests/incremental-reuse.integration.test.ts +++ b/packages/stryker-js/tests/incremental-reuse.integration.test.ts @@ -157,6 +157,7 @@ const ZERO_REFUSALS = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + programChanged: 0, flakyDependency: 0, timeoutUnreproduced: 0, noPriorRecord: 0, diff --git a/packages/stryker-js/tests/plan-shard-reuse.integration.test.ts b/packages/stryker-js/tests/plan-shard-reuse.integration.test.ts index a6965731f..cdcce1b23 100644 --- a/packages/stryker-js/tests/plan-shard-reuse.integration.test.ts +++ b/packages/stryker-js/tests/plan-shard-reuse.integration.test.ts @@ -173,6 +173,7 @@ const NO_REFUSALS: RunEvent.ReuseRefusals = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + programChanged: 0, timeoutUnreproduced: 0, flakyDependency: 0, noPriorRecord: 0, diff --git a/packages/stryker-js/tests/plan-stream-location.integration.test.ts b/packages/stryker-js/tests/plan-stream-location.integration.test.ts index 6844bd23b..82f7fb9d9 100644 --- a/packages/stryker-js/tests/plan-stream-location.integration.test.ts +++ b/packages/stryker-js/tests/plan-stream-location.integration.test.ts @@ -152,6 +152,7 @@ const NO_REFUSALS: RunEvent.ReuseRefusals = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + programChanged: 0, timeoutUnreproduced: 0, flakyDependency: 0, noPriorRecord: 0, diff --git a/packages/stryker-js/tests/worker-connection-loss.integration.test.ts b/packages/stryker-js/tests/worker-connection-loss.integration.test.ts index be48e4d3b..a936db004 100644 --- a/packages/stryker-js/tests/worker-connection-loss.integration.test.ts +++ b/packages/stryker-js/tests/worker-connection-loss.integration.test.ts @@ -1,7 +1,7 @@ import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' import { Worker } from '@systemfsoftware/stryker-js' import { Mutant } from '@systemfsoftware/stryker-js-plugin-interface' -import { type Checker, Options, Plugin } from '@systemfsoftware/stryker-js-plugin-interface' +import { Checker, Options, Plugin } from '@systemfsoftware/stryker-js-plugin-interface' import { Trace } from '@systemfsoftware/stryker-js-plugin-runtime' import * as Effect from 'effect/Effect' import * as Fiber from 'effect/Fiber' @@ -28,6 +28,8 @@ const ORPHAN_GUARD = '30 seconds' const BOOT_REFUSALS = 2 const BOOT_RETRY_WINDOW = '5 seconds' +const FAKE_DIGEST = Checker.ProgramDigest.make('0123456789abcdef'.repeat(4)) + type CheckerRpcsUnion = typeof Plugin.CheckerRpcs extends RpcGroup.RpcGroup ? Rpcs : never interface SilentWorkerHarness { @@ -54,6 +56,7 @@ const makeSilentCheckerServer = ( Effect.map(() => Object.fromEntries(mutants.map((mutant) => [mutant.id, { status: 'passed' as const }]))), ), group: ({ mutants }) => Effect.succeed([mutants.map((mutant) => mutant.id)]), + digest: () => Effect.succeed(FAKE_DIGEST), }), ), Layer.provide(RpcServer.layerProtocolSocketServer), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9ec34cc3..001e96918 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1216,6 +1216,9 @@ importers: packages/stryker-js-typescript-checker: dependencies: + '@noble/hashes': + specifier: 'catalog:' + version: 2.4.0 '@effect/opentelemetry': specifier: 'catalog:' version: 4.0.0(@opentelemetry/api-logs@0.222.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0) From 003aee5573a6177f85eb4d0b5253746fb07591b9 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 14:13:54 +0000 Subject: [PATCH 03/16] fix(repo): keep the reuse stream line decodable without the new count The reuse stream's refused counts gained a `programChanged` member, and the generated contract document listed it as required, so a consumer built against the committed stream would have refused every line an earlier engine wrote. The count is optional, the way the plan line's `projects` array is: a stream line without it still decodes Verdict-Semantics: unchanged --- .changeset/compile-error-verdicts-reusable.md | 4 +++- packages/stryker-js-cli-contract/contract/stream.schema.json | 2 -- packages/stryker-js-cli-contract/src/run-event.schema.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/compile-error-verdicts-reusable.md b/.changeset/compile-error-verdicts-reusable.md index d63640114..2ee9f70c4 100644 --- a/.changeset/compile-error-verdicts-reusable.md +++ b/.changeset/compile-error-verdicts-reusable.md @@ -17,4 +17,6 @@ Hashing the program needs the checker running, so a run that has one re-checks the mutants it must either way; only the per-mutant check is saved. The incremental report writes the program digest beside every `CompileError` verdict it records, and the reuse stream line counts a refusal to reuse one under the new -`programChanged` reason, next to the existing refusal reasons. +`programChanged` reason, next to the existing refusal reasons. A stream line +without that count still decodes, so consumers of an earlier machine stream keep +working. diff --git a/packages/stryker-js-cli-contract/contract/stream.schema.json b/packages/stryker-js-cli-contract/contract/stream.schema.json index 2c3cdfe96..5fa1f973d 100644 --- a/packages/stryker-js-cli-contract/contract/stream.schema.json +++ b/packages/stryker-js-cli-contract/contract/stream.schema.json @@ -302,7 +302,6 @@ "policyChanged", "runInputsChanged", "closureChanged", - "programChanged", "timeoutUnreproduced", "flakyDependency", "noPriorRecord" @@ -803,7 +802,6 @@ "policyChanged", "runInputsChanged", "closureChanged", - "programChanged", "timeoutUnreproduced", "flakyDependency", "noPriorRecord" diff --git a/packages/stryker-js-cli-contract/src/run-event.schema.ts b/packages/stryker-js-cli-contract/src/run-event.schema.ts index b4990b9ef..b5f5ee08c 100644 --- a/packages/stryker-js-cli-contract/src/run-event.schema.ts +++ b/packages/stryker-js-cli-contract/src/run-event.schema.ts @@ -34,7 +34,7 @@ export const ReuseRefusals = S.Struct({ policyChanged: Report.NonNegativeInt, runInputsChanged: Report.NonNegativeInt, closureChanged: Report.NonNegativeInt, - programChanged: Report.NonNegativeInt, + programChanged: S.optionalKey(Report.NonNegativeInt), timeoutUnreproduced: Report.NonNegativeInt, flakyDependency: Report.NonNegativeInt, noPriorRecord: Report.NonNegativeInt, From b6031b21a32f5357bb546b03319d81f9d5b18a88 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:07:38 +0000 Subject: [PATCH 04/16] 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 f618d778b33c9956ba4808386e9c94b6ad5ede6b Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:07:53 +0000 Subject: [PATCH 05/16] docs(repo): mark the checker digest RPC as a breaking change Every checker worker must answer the new digest RPC, so the plugin interface and the engine take a major bump per BREAK-1 Verdict-Semantics: unchanged --- .changeset/checker-reports-program-digest.md | 8 ++++-- .changeset/compile-error-verdicts-reusable.md | 27 +++++++------------ 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/.changeset/checker-reports-program-digest.md b/.changeset/checker-reports-program-digest.md index 9a974482b..bc124f563 100644 --- a/.changeset/checker-reports-program-digest.md +++ b/.changeset/checker-reports-program-digest.md @@ -1,9 +1,9 @@ --- -"@systemfsoftware/stryker-js-plugin-interface": minor +"@systemfsoftware/stryker-js-plugin-interface": major "@systemfsoftware/stryker-js-typescript-checker": minor --- -A checker plugin can now report the program it loaded. `CheckerService` gains a +A checker plugin must now report the program it loaded. `CheckerService` gains a `digest` holding the digest of that program, the checker RPC exposes it as `digest`, and a TypeScript checker answers with the digest of the TypeScript program it built: the hashed content of every source file the program loaded @@ -11,3 +11,7 @@ program it built: the hashed content of every source file the program loaded files it was built from, the TypeScript version, the checker's own version, and its configured options. The digest is stable for an unchanged program and moves when any of those change. + +Breaking for checker plugin authors: a checker worker that does not answer the +`digest` RPC no longer works with the engine. Add a `digest` handler that +returns the digest of everything your check reads. diff --git a/.changeset/compile-error-verdicts-reusable.md b/.changeset/compile-error-verdicts-reusable.md index 2ee9f70c4..7044a3753 100644 --- a/.changeset/compile-error-verdicts-reusable.md +++ b/.changeset/compile-error-verdicts-reusable.md @@ -1,22 +1,15 @@ --- -"@systemfsoftware/stryker-js": minor +"@systemfsoftware/stryker-js": major "@systemfsoftware/stryker-js-cli-contract": minor --- -An incremental run now reuses the verdicts of mutants a checker rejected as -`CompileError`, instead of type-checking every one of them again. A remembered -`CompileError` is kept only while nothing that fed the check has changed: the -mutant's own identity and replacement, every file the checker's TypeScript -program loaded — including the declaration files pulled in transitively and the -ones shipped with TypeScript — every tsconfig the program was built from, the -TypeScript version, the checker plugin version, and the checker's own options. A -change to any of them, or a verdict recorded without that key, re-checks the -mutant. +An incremental run now reuses `CompileError` verdicts instead of type-checking +every one again. A verdict is kept only while nothing that fed the check +changed: the mutant and its replacement, every file the checker's TypeScript +program loaded (including transitive and bundled declaration files), its +tsconfig files, the TypeScript version, the checker version and its options. +Otherwise the mutant is re-checked. The reuse stream line reports such refusals +as `programChanged`; lines without that count still decode. -Hashing the program needs the checker running, so a run that has one re-checks -the mutants it must either way; only the per-mutant check is saved. The -incremental report writes the program digest beside every `CompileError` verdict -it records, and the reuse stream line counts a refusal to reuse one under the new -`programChanged` reason, next to the existing refusal reasons. A stream line -without that count still decodes, so consumers of an earlier machine stream keep -working. +Breaking: a configured checker must answer the new `digest` RPC, so upgrade +checker plugins together with this release. From 258edf71a9a4668274194ee9a5dde1eb64e73d34 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:21:46 +0000 Subject: [PATCH 06/16] 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 acdcb34fc76ca13323170bfe213721408fbed887 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:39:34 +0000 Subject: [PATCH 07/16] fix(repo): hash the tsconfig extends chain into the program digest The digest covered the root tsconfig and its project references but not the configs reached through "extends", so editing an extended base left the program key unchanged and CompileError verdicts were reused across a real program change. The chain is now followed the way TypeScript resolves it - relative, absolute and node_modules package specifiers, string or array form - and every file in it is hashed. An unresolvable or unparseable config fails the digest, which forces a re-check. The engine-level scenario needs a fixture checker that digests the same chain, which the program-digesting fixture now does Verdict-Semantics: unchanged --- .../src/Tsconfig.schema.ts | 11 ++ .../src/ts-compiler.handle.ts | 103 +++++++++++++++++- .../tests/program-digest.integration.test.ts | 94 ++++++++++++++-- .../program-digesting-checker/worker.mjs | 28 ++++- .../compile-error-reuse.integration.test.ts | 82 +++++++++++--- 5 files changed, 291 insertions(+), 27 deletions(-) diff --git a/packages/stryker-js-typescript-checker/src/Tsconfig.schema.ts b/packages/stryker-js-typescript-checker/src/Tsconfig.schema.ts index 59b540d75..df67924ad 100644 --- a/packages/stryker-js-typescript-checker/src/Tsconfig.schema.ts +++ b/packages/stryker-js-typescript-checker/src/Tsconfig.schema.ts @@ -31,8 +31,11 @@ export const TsConfigCompilerOptionsSchema = JsonRecord export type TsConfigCompilerOptions = S.Schema.Type +export const TsConfigExtendsSchema = S.Union([S.String, S.Array(S.String)]) + export const TsConfigDocumentSchema = S.StructWithRest( S.Struct({ + extends: S.optional(TsConfigExtendsSchema), references: S.optional(S.Array(ProjectReferenceSchema)), compilerOptions: S.optional(TsConfigCompilerOptionsSchema), }), @@ -53,14 +56,18 @@ if (import.meta.vitest !== void 0) { const isJsonObject = S.is(S.Record(S.String, S.Json)) const isJsonArray = S.is(S.Array(S.Json)) + const isStringArray = (value: JsonValue): boolean => + isJsonArray(value) && value.every((entry) => typeof entry === 'string') const isReference = (value: JsonValue): boolean => isJsonObject(value) && typeof value['path'] === 'string' const referencesHold = (references: JsonValue): boolean => isJsonArray(references) && references.every(isReference) const compilerOptionsHold = (compilerOptions: JsonValue): boolean => isJsonObject(compilerOptions) + const extendsHold = (extended: JsonValue): boolean => typeof extended === 'string' || isStringArray(extended) const fieldHolds = (field: JsonValue | undefined, holds: (value: JsonValue) => boolean): boolean => field === undefined || holds(field) const isDocument = (value: JsonValue): boolean => isJsonObject(value) && Arr.every([ + fieldHolds(value['extends'], extendsHold), fieldHolds(value['references'], referencesHold), fieldHolds(value['compilerOptions'], compilerOptionsHold), ], ( @@ -73,6 +80,10 @@ if (import.meta.vitest !== void 0) { 'tsconfig', [], {}, + { extends: './base.json' }, + { extends: ['./base.json', '@tsconfig/node20/tsconfig.json'] }, + { extends: [1] }, + { extends: {} }, { references: [] }, { references: [{}] }, { references: [{ path: 'p' }] }, diff --git a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts index e445ed3d0..ab4954c44 100644 --- a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts +++ b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts @@ -307,7 +307,10 @@ export const programDigest = Effect.fn(SpanTaxonomy.Spans.typescriptCheckerCompi }) const checkerOptionsJson = yield* checkerOptionsJsonOf(rt.options) const sourceFiles = yield* readProgramFiles(rt, yield* sortedSourceFileNamesOf(programs)) - const tsconfigs = yield* readProgramFiles(rt, Arr.sort(Arr.fromIterable(state.allTSConfigFiles), Order.String)) + const tsconfigs = yield* readProgramFiles( + rt, + yield* tsConfigChainOf(rt, Arr.sort(Arr.fromIterable(state.allTSConfigFiles), Order.String)), + ) return yield* decidedProgramIdentity( IdentifyProgramCommand.make({ typescriptVersion, checkerVersion, checkerOptionsJson, sourceFiles, tsconfigs }), ) @@ -362,6 +365,104 @@ const referencedProjectsOf = ( ) }) +const TS_CONFIG_JSON_EXTENSION = '.json' + +const NODE_MODULES_DIRECTORY = 'node_modules' + +const tsConfigExtendsOf = (config: TsConfigDocument): ReadonlyArray => { + const declared = config['extends'] + return declared === undefined ? [] : typeof declared === 'string' ? [declared] : [...declared] +} + +const ancestorDirectoriesOf = (pathService: Path.Path, from: string): ReadonlyArray => { + const directories: Array = [from] + let current = from + let parent = pathService.dirname(current) + while (parent !== current) { + directories.push(parent) + current = parent + parent = pathService.dirname(current) + } + return directories +} + +const isRelativeSpecifier = (specifier: string): boolean => specifier.startsWith('./') || specifier.startsWith('../') + +const withJsonExtension = (base: string): ReadonlyArray => + base.endsWith(TS_CONFIG_JSON_EXTENSION) ? [base] : [base, `${base}${TS_CONFIG_JSON_EXTENSION}`] + +const tsConfigExtendsCandidatesOf = ( + pathService: Path.Path, + fromDirName: string, + specifier: string, +): ReadonlyArray => + Boolean.match(pathService.isAbsolute(specifier) || isRelativeSpecifier(specifier), { + onTrue: () => withJsonExtension(normalizeFileName(pathService.resolve(fromDirName, specifier))), + onFalse: () => + Arr.flatMap(ancestorDirectoriesOf(pathService, fromDirName), (directory) => + Arr.flatMap(withJsonExtension(specifier), (candidate) => [ + normalizeFileName(pathService.join(directory, NODE_MODULES_DIRECTORY, candidate)), + ...Boolean.match(candidate.endsWith(TS_CONFIG_JSON_EXTENSION), { + onTrue: (): ReadonlyArray => [], + onFalse: () => [ + normalizeFileName(pathService.join(directory, NODE_MODULES_DIRECTORY, candidate, 'tsconfig.json')), + ], + }), + ])), + }) + +const isFileOf = (rt: TSCompilerRuntime, fileName: string): Effect.Effect => + rt.host.stat(fileName).pipe( + Effect.map((info) => info.type === 'File'), + Effect.orElseSucceed(() => false), + ) + +const resolveTsConfigExtends = ( + rt: TSCompilerRuntime, + fromDirName: string, + specifier: string, +): Effect.Effect => + Effect.gen(function*() { + const candidates = tsConfigExtendsCandidatesOf(rt.pathService, fromDirName, specifier) + for (const candidate of candidates) { + if (yield* isFileOf(rt, candidate)) { + return candidate + } + } + return yield* Effect.fail( + CompilerFailed.make({ reason: 'program-digest-unavailable', subject: `tsconfig extends "${specifier}"` }), + ) + }) + +const tsConfigChainOf = ( + rt: TSCompilerRuntime, + seeds: ReadonlyArray, +): Effect.Effect, CompilerError> => + Effect.gen(function*() { + const visited = new Set() + const chain: Array = [] + const pending: Array = [...seeds] + while (pending.length > 0) { + const fileName = pending.pop() as string + if (visited.has(fileName)) { + continue + } + visited.add(fileName) + chain.push(fileName) + const jsonText = yield* readTsConfigText(rt, HashMap.empty(), fileName) + const parsed = parseTsConfig(fileName, jsonText) + if (Result.isFailure(parsed)) { + return yield* Effect.fail(parsed.failure) + } + const extended = yield* Effect.forEach( + tsConfigExtendsOf(parsed.success), + (specifier) => resolveTsConfigExtends(rt, rt.pathService.dirname(fileName), specifier), + ) + pending.push(...extended) + } + return chain + }) + interface TsConfigWalk { readonly overrides: HashMap.HashMap readonly files: HashSet.HashSet diff --git a/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts b/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts index 45912e9e9..12242480f 100644 --- a/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts +++ b/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts @@ -42,6 +42,26 @@ const STRICT_TSCONFIG_SOURCE = JSON.stringify( 2, ) + '\n' +const BASE_TSCONFIG_FILE = 'tsconfig.base.json' +const BASE_TSCONFIG_SOURCE = JSON.stringify( + { compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true } }, + null, + 2, +) + '\n' +const CHANGED_BASE_TSCONFIG_SOURCE = JSON.stringify( + { compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: false } }, + null, + 2, +) + '\n' +const EXTENDS_TSCONFIG_SOURCE = JSON.stringify({ extends: `./${BASE_TSCONFIG_FILE}`, include: ['src'] }, null, 2) + + '\n' +const PACKAGE_BASE_TSCONFIG_FILE = 'node_modules/@probe/tsconfig-base/tsconfig.json' +const PACKAGE_EXTENDS_TSCONFIG_SOURCE = JSON.stringify( + { extends: '@probe/tsconfig-base/tsconfig.json', include: ['src'] }, + null, + 2, +) + '\n' + const MAIN_SOURCE = [ "import { shifted } from './chain.js'", '', @@ -65,20 +85,23 @@ interface Workspace { readonly directory: string } -const writeWorkspace = (): Effect.Effect => +const DEFAULT_FILES: Readonly> = { + [TSCONFIG_FILE]: TSCONFIG_SOURCE, + [MAIN_FILE]: MAIN_SOURCE, + [CHAIN_FILE]: CHAIN_SOURCE, + [DECLARATION_FILE]: DECLARATION_SOURCE, + [OUTSIDE_FILE]: OUTSIDE_SOURCE, +} + +const writeWorkspace = ( + files: Readonly> = DEFAULT_FILES, +): Effect.Effect => Effect.gen(function*() { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const directory = yield* fs.makeTempDirectory() - const files: ReadonlyArray = [ - [TSCONFIG_FILE, TSCONFIG_SOURCE], - [MAIN_FILE, MAIN_SOURCE], - [CHAIN_FILE, CHAIN_SOURCE], - [DECLARATION_FILE, DECLARATION_SOURCE], - [OUTSIDE_FILE, OUTSIDE_SOURCE], - ] yield* Effect.forEach( - files, + Object.entries(files), ([name, content]) => Effect.gen(function*() { const target = path.join(directory, name) @@ -136,9 +159,10 @@ const digestTwice = ( const withWorkspace = ( use: (workspace: Workspace) => Effect.Effect, + files?: Readonly>, ): Effect.Effect => Effect.gen(function*() { - const workspace = yield* writeWorkspace() + const workspace = yield* writeWorkspace(files) return yield* use(workspace).pipe(Effect.ensuring(removeWorkspace(workspace.directory))) }).pipe(Effect.orDie, Effect.provide(FILE_PORTS)) @@ -215,4 +239,54 @@ Feature('Identifying the TypeScript program a checker loaded', { timeout: 120_00 ), ), ) + + scenario( + 'editing a base tsconfig the root extends moves the digest', + Gherkin.Do.pipe( + Given('a program whose tsconfig extends a sibling base config')( + 'observed', + () => + withWorkspace( + (workspace) => + digestTwice( + workspace.directory, + (directory) => rewriteFile(directory, BASE_TSCONFIG_FILE, CHANGED_BASE_TSCONFIG_SOURCE), + ), + { + ...DEFAULT_FILES, + [TSCONFIG_FILE]: EXTENDS_TSCONFIG_SOURCE, + [BASE_TSCONFIG_FILE]: BASE_TSCONFIG_SOURCE, + }, + ), + ), + Then('the digest moves')((s, expect) => + expect({ moved: s.observed.first !== s.observed.second }).toEqual({ moved: true }) + ), + ), + ) + + scenario( + 'editing a base tsconfig the root extends by package specifier moves the digest', + Gherkin.Do.pipe( + Given('a program whose tsconfig extends a base config through node_modules')( + 'observed', + () => + withWorkspace( + (workspace) => + digestTwice( + workspace.directory, + (directory) => rewriteFile(directory, PACKAGE_BASE_TSCONFIG_FILE, CHANGED_BASE_TSCONFIG_SOURCE), + ), + { + ...DEFAULT_FILES, + [TSCONFIG_FILE]: PACKAGE_EXTENDS_TSCONFIG_SOURCE, + [PACKAGE_BASE_TSCONFIG_FILE]: BASE_TSCONFIG_SOURCE, + }, + ), + ), + Then('the digest moves')((s, expect) => + expect({ moved: s.observed.first !== s.observed.second }).toEqual({ moved: true }) + ), + ), + ) }) diff --git a/packages/stryker-js/tests/__fixtures__/program-digesting-checker/worker.mjs b/packages/stryker-js/tests/__fixtures__/program-digesting-checker/worker.mjs index 7da86d265..bc423305b 100644 --- a/packages/stryker-js/tests/__fixtures__/program-digesting-checker/worker.mjs +++ b/packages/stryker-js/tests/__fixtures__/program-digesting-checker/worker.mjs @@ -7,15 +7,39 @@ import * as Effect from 'effect/Effect' import * as Layer from 'effect/Layer' import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' +import { relative, resolve } from 'node:path' -const PROGRAM_FILES = ['tsconfig.json', 'src/lib/chain.ts', 'src/lib/subject.ts', 'src/types/transitive.d.ts'] +const PROGRAM_FILES = ['src/lib/chain.ts', 'src/lib/subject.ts', 'src/types/transitive.d.ts'] + +const CONFIG_FILE = 'tsconfig.json' const MUTATED_FILE = 'src/lib/subject.ts' const REJECTION_REASON = 'rejected by the fixture checker' +const relativeToCwd = (file) => relative(process.cwd(), resolve(file)).replaceAll('\\', '/') + +const configFileChain = async () => { + const files = [] + const seen = new Set() + const pending = [CONFIG_FILE] + while (pending.length > 0) { + const configFile = pending.pop() + if (seen.has(configFile)) { + continue + } + seen.add(configFile) + files.push(configFile) + const config = JSON.parse(await readFile(configFile, 'utf8')) + for (const specifier of [].concat(config.extends ?? [])) { + pending.push(relativeToCwd(specifier)) + } + } + return files +} + const programDigestOf = async () => { const lines = [] - for (const file of [...PROGRAM_FILES].sort()) { + for (const file of [...PROGRAM_FILES, ...(await configFileChain())].sort()) { lines.push(`${file}\u0000${await readFile(file, 'utf8')}`) } return createHash('sha256').update(lines.join('\n')).digest('hex') diff --git a/packages/stryker-js/tests/compile-error-reuse.integration.test.ts b/packages/stryker-js/tests/compile-error-reuse.integration.test.ts index 5148f6332..5e8d3dbd2 100644 --- a/packages/stryker-js/tests/compile-error-reuse.integration.test.ts +++ b/packages/stryker-js/tests/compile-error-reuse.integration.test.ts @@ -63,6 +63,21 @@ const UNRELATED_TEST_SOURCE = [ ].join('\n') const TSCONFIG_SOURCE = JSON.stringify({ compilerOptions: { target: 'ES2022' }, include: ['src'] }, null, 2) + '\n' +const CHANGED_TSCONFIG_SOURCE = JSON.stringify( + { compilerOptions: { target: 'ES2022', strict: false }, include: ['src'] }, + null, + 2, +) + '\n' + +const BASE_TSCONFIG_FILE = 'tsconfig.base.json' +const BASE_TSCONFIG_SOURCE = JSON.stringify({ compilerOptions: { target: 'ES2022', strict: true } }, null, 2) + '\n' +const CHANGED_BASE_TSCONFIG_SOURCE = JSON.stringify({ compilerOptions: { target: 'ES2022', strict: false } }, null, 2) + + '\n' +const EXTENDS_TSCONFIG_SOURCE = JSON.stringify( + { extends: `./${BASE_TSCONFIG_FILE}`, compilerOptions: { target: 'ES2022' }, include: ['src'] }, + null, + 2, +) + '\n' const VITEST_CONFIG_SOURCE = 'export default { test: { testTimeout: 600_000, hookTimeout: 600_000 } }\n' @@ -72,23 +87,26 @@ interface Workspace { const incrementalFileOf = (directory: string): string => `${directory}/reports/main.json` -const writeWorkspace = (): Effect.Effect => +const DEFAULT_FILES: Readonly> = { + 'package.json': '{ "type": "commonjs" }\n', + 'vitest.config.ts': VITEST_CONFIG_SOURCE, + [TSCONFIG_FILE]: TSCONFIG_SOURCE, + [SUBJECT_FILE]: SUBJECT_SOURCE, + [CHAIN_FILE]: CHAIN_SOURCE, + [DECLARATION_FILE]: DECLARATION_SOURCE, + 'test/sample.test.mjs': TEST_SOURCE, + [UNRELATED_TEST_FILE]: UNRELATED_TEST_SOURCE, +} + +const writeWorkspace = ( + files: Readonly> = DEFAULT_FILES, +): Effect.Effect => Effect.gen(function*() { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const directory = yield* fs.makeTempDirectory() - const files: ReadonlyArray = [ - ['package.json', '{ "type": "commonjs" }\n'], - ['vitest.config.ts', VITEST_CONFIG_SOURCE], - [TSCONFIG_FILE, TSCONFIG_SOURCE], - [SUBJECT_FILE, SUBJECT_SOURCE], - [CHAIN_FILE, CHAIN_SOURCE], - [DECLARATION_FILE, DECLARATION_SOURCE], - ['test/sample.test.mjs', TEST_SOURCE], - [UNRELATED_TEST_FILE, UNRELATED_TEST_SOURCE], - ] yield* Effect.forEach( - files, + Object.entries(files), ([name, content]) => Effect.gen(function*() { const target = path.join(directory, name) @@ -220,9 +238,10 @@ const runTwice = ( const withWorkspace = ( use: (workspace: Workspace) => Effect.Effect, + files?: Readonly>, ): Effect.Effect => Effect.gen(function*() { - const workspace = yield* writeWorkspace() + const workspace = yield* writeWorkspace(files) return yield* use(workspace).pipe(Effect.ensuring(removeWorkspace(workspace.directory))) }).pipe(Effect.orDie, Effect.provide(filePorts)) @@ -306,7 +325,9 @@ Feature('Reusing CompileError verdicts across incremental runs', { timeout: 240_ Given('a project whose checker digests its tsconfig')( 'runs', () => - withWorkspace((workspace) => runTwice(workspace, (inner) => appendComment(inner.directory, TSCONFIG_FILE))), + withWorkspace((workspace) => + runTwice(workspace, (inner) => rewriteFile(inner.directory, TSCONFIG_FILE, CHANGED_TSCONFIG_SOURCE)) + ), ), Then('the second run refuses the remembered verdicts naming the changed program')((s, expect) => expect({ @@ -355,4 +376,37 @@ Feature('Reusing CompileError verdicts across incremental runs', { timeout: 240_ ), ), ) + + scenario( + 'editing only a base tsconfig the root extends re-checks every CompileError', + Gherkin.Do.pipe( + Given('a project whose root tsconfig extends a base holding its compiler options')( + 'runs', + () => + withWorkspace( + (workspace) => + runTwice(workspace, (inner) => + rewriteFile(inner.directory, BASE_TSCONFIG_FILE, CHANGED_BASE_TSCONFIG_SOURCE)), + { + ...DEFAULT_FILES, + [TSCONFIG_FILE]: EXTENDS_TSCONFIG_SOURCE, + [BASE_TSCONFIG_FILE]: BASE_TSCONFIG_SOURCE, + }, + ), + ), + Then('the second run refuses the remembered verdicts naming the changed program')((s, expect) => + expect({ + firstCompileErrors: compileErrorRows(s.runs.first.mutants).length > 0, + secondSucceeded: Exit.isSuccess(s.runs.second.exit), + secondRanChecks: ranOf(s.runs.second) > 0, + secondRefusedForProgram: programChangedOf(s.runs.second) > 0, + }).toEqual({ + firstCompileErrors: true, + secondSucceeded: true, + secondRanChecks: true, + secondRefusedForProgram: true, + }) + ), + ), + ) }) From 718946899b8417d545a7ea6ab856d24d6939c1ae Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:39:54 +0000 Subject: [PATCH 08/16] fix(repo): key the program digest on project-relative paths Every digest entry was an absolute path, so the checker's sandbox directory (.stryker-tmp/sandbox-) put a different key on the same program on every run and CompileError verdicts were never reused on CI. Paths are now named relative to the root tsconfig's directory; files outside it, such as node_modules realpaths, become deterministic "../" paths Verdict-Semantics: unchanged --- .../src/ts-compiler.handle.ts | 10 ++++-- .../tests/program-digest.integration.test.ts | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts index ab4954c44..573757093 100644 --- a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts +++ b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts @@ -255,6 +255,7 @@ const checkerOptionsJsonOf = (options: Options.StrykerOptions): Effect.Effect => Effect.forEach( @@ -265,7 +266,10 @@ const readProgramFiles = ( rt.host.readFileString(fileName), () => CompilerFailed.make({ reason: 'program-digest-unavailable', subject: fileName }), ), - (content): ProgramFile => ({ fileName, digest: sha256HexOf(content) }), + (content): ProgramFile => ({ + fileName: normalizeFileName(rt.pathService.relative(root, fileName)), + digest: sha256HexOf(content), + }), ), { concurrency: 1 }, ) @@ -306,9 +310,11 @@ export const programDigest = Effect.fn(SpanTaxonomy.Spans.typescriptCheckerCompi onFalse: () => Effect.void, }) const checkerOptionsJson = yield* checkerOptionsJsonOf(rt.options) - const sourceFiles = yield* readProgramFiles(rt, yield* sortedSourceFileNamesOf(programs)) + const root = rt.pathService.dirname(state.tsconfigFile) + const sourceFiles = yield* readProgramFiles(rt, root, yield* sortedSourceFileNamesOf(programs)) const tsconfigs = yield* readProgramFiles( rt, + root, yield* tsConfigChainOf(rt, Arr.sort(Arr.fromIterable(state.allTSConfigFiles), Order.String)), ) return yield* decidedProgramIdentity( diff --git a/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts b/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts index 12242480f..2e6e4657e 100644 --- a/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts +++ b/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts @@ -166,6 +166,19 @@ const withWorkspace = ( return yield* use(workspace).pipe(Effect.ensuring(removeWorkspace(workspace.directory))) }).pipe(Effect.orDie, Effect.provide(FILE_PORTS)) +const withTwoWorkspaces = ( + use: (first: Workspace, second: Workspace) => Effect.Effect, +): Effect.Effect => + Effect.gen(function*() { + const first = yield* writeWorkspace() + const second = yield* writeWorkspace() + return yield* use(first, second).pipe( + Effect.ensuring( + Effect.all([removeWorkspace(first.directory), removeWorkspace(second.directory)], { discard: true }), + ), + ) + }).pipe(Effect.orDie, Effect.provide(FILE_PORTS)) + Feature('Identifying the TypeScript program a checker loaded', { timeout: 120_000 }) .withLayer(FILE_PORTS) .live('one real TypeScript checker runtime over a real program on disk') @@ -289,4 +302,24 @@ Feature('Identifying the TypeScript program a checker loaded', { timeout: 120_00 ), ), ) + + scenario( + 'the same program copied into two temporary directories has one digest', + Gherkin.Do.pipe( + Given('two copies of one program under different temporary directories')( + 'observed', + () => + withTwoWorkspaces((first, second) => + Effect.gen(function*() { + const firstDigest = yield* digestOf(first.directory) + const secondDigest = yield* digestOf(second.directory) + return { firstDigest, secondDigest } + }) + ), + ), + Then('both copies have the same digest')((s, expect) => + expect({ equal: s.observed.firstDigest === s.observed.secondDigest }).toEqual({ equal: true }) + ), + ), + ) }) From bd0c88bc5cb68491bc66a6536b2d9cdea31a024b Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:40:35 +0000 Subject: [PATCH 09/16] fix(repo): canonicalize checker options for the digest and fail closed The options JSON was re-encoded in insertion order, so semantically equal option sets produced different keys and never matched; and a value that is not JSON was silently replaced with {}, keying a configuration the checker never ran. Object keys are now sorted before hashing and an unencodable option set fails the digest, which forces a re-check Verdict-Semantics: unchanged --- .../src/ts-compiler.handle.ts | 31 ++++++++++-- .../tests/program-digest.integration.test.ts | 49 ++++++++++++++++++- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts index 573757093..4d6ced993 100644 --- a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts +++ b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts @@ -248,10 +248,33 @@ const readCheckerVersion = Effect.fnUntraced(function*(rt: TSCompilerRuntime) { }) }) -const checkerOptionsJsonOf = (options: Options.StrykerOptions): Effect.Effect => - S.encodeEffect(S.fromJsonString(S.Json))( - Option.getOrElse(S.decodeUnknownOption(S.Json)(options['typescriptChecker']), () => ({})), - ).pipe(Effect.orDie) +type JsonValue = S.Schema.Type + +const isJsonObject = (value: JsonValue): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const canonicalJsonOf = (value: JsonValue): string => + Array.isArray(value) + ? `[${value.map(canonicalJsonOf).join(',')}]` + : isJsonObject(value) + ? `{${ + Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJsonOf(value[key] as JsonValue)}`) + .join(',') + }}` + : JSON.stringify(value) + +const checkerOptionsJsonOf = (options: Options.StrykerOptions): Effect.Effect => + Option.match(Option.fromUndefinedOr(options['typescriptChecker']), { + onNone: () => Effect.succeed('{}'), + onSome: (configured) => + Result.match(S.decodeUnknownResult(S.Json)(configured), { + onFailure: () => + Effect.fail(CompilerFailed.make({ reason: 'program-digest-unavailable', subject: 'checker options' })), + onSuccess: (value) => Effect.succeed(canonicalJsonOf(value)), + }), + }) const readProgramFiles = ( rt: TSCompilerRuntime, diff --git a/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts b/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts index 2e6e4657e..6e0574b8d 100644 --- a/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts +++ b/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts @@ -4,6 +4,7 @@ import { Gherkin, Given, it, makeFeature, Then } from '@systemfsoftware/effect-g import { Options } from '@systemfsoftware/stryker-js-plugin-interface' import { CheckerRuntime } from '@systemfsoftware/stryker-js-typescript-checker/runtime' import * as Effect from 'effect/Effect' +import * as Exit from 'effect/Exit' import * as FileSystem from 'effect/FileSystem' import * as Layer from 'effect/Layer' import * as Path from 'effect/Path' @@ -131,11 +132,15 @@ const appendComment = (directory: string, file: string): Effect.Effect => +const digestOf = ( + directory: string, + checkerOptions?: Readonly>, +): Effect.Effect => Effect.gen(function*() { const pathService = yield* Path.Path const options = yield* S.decodeEffect(Options.StrykerOptionsSchema)({ tsconfigFile: pathService.join(directory, TSCONFIG_FILE), + ...(checkerOptions === undefined ? {} : { typescriptChecker: checkerOptions }), }) return yield* Effect.gen(function*() { const runtime = yield* CheckerRuntime @@ -144,6 +149,12 @@ const digestOf = (directory: string): Effect.Effect => }).pipe(Effect.provide(CheckerRuntime.layer(options))) }).pipe(Effect.orDie, Effect.provide(FILE_PORTS)) +const digestOutcomeOf = ( + directory: string, + checkerOptions?: Readonly>, +): Effect.Effect, never, never> => + Effect.exit(digestOf(directory, checkerOptions).pipe(Effect.provide(FILE_PORTS))) + const DIGEST_SHAPE = /^[0-9a-f]{64}$/u const digestTwice = ( @@ -322,4 +333,40 @@ Feature('Identifying the TypeScript program a checker loaded', { timeout: 120_00 ), ), ) + + scenario( + 'the same checker options in a different key order keep one digest', + Gherkin.Do.pipe( + Given('one program digested with semantically equal checker options')( + 'observed', + () => + withWorkspace((workspace) => + Effect.gen(function*() { + const first = yield* digestOf(workspace.directory, { alpha: 1, beta: 2 }) + const second = yield* digestOf(workspace.directory, { beta: 2, alpha: 1 }) + return { first, second } + }) + ), + ), + Then('both digests are the same')((s, expect) => + expect({ equal: s.observed.first === s.observed.second }).toEqual({ equal: true }) + ), + ), + ) + + scenario( + 'checker options that cannot be encoded yield no digest', + Gherkin.Do.pipe( + Given('a program digested with a checker option that is not JSON')( + 'observed', + () => + withWorkspace((workspace) => + Effect.map(digestOutcomeOf(workspace.directory, { probe: () => 1 }), (outcome) => ({ + failed: Exit.isFailure(outcome), + })) + ), + ), + Then('the digest request fails')((s, expect) => expect(s.observed).toEqual({ failed: true })), + ), + ) }) From 67cc1dab05c56af2b79f77dd01ed04abfbf05bae Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:41:09 +0000 Subject: [PATCH 10/16] fix(repo): name a failed closure analysis in the reuse refusal reason A refusal caused only by a failed closure analysis was reported as closureChanged, and a keyed CompileError whose program still matched was reported the same way - neither names the input that was actually missing. The stream now carries a closureAnalysisFailed count, and that reason is chosen whenever the closure analysis failed and no earlier gate outranks it Verdict-Semantics: unchanged --- .changeset/compile-error-verdicts-reusable.md | 10 ++++--- .../src/run-event.schema.ts | 1 + .../stryker-js/src/IncrementalDiff.schema.ts | 1 + ...incremental-diff.workflow.property.test.ts | 17 ++++++++++-- .../src/incremental-diff.workflow.ts | 27 ++++++++++--------- .../src/run/incremental-reuse.cell.ts | 1 + 6 files changed, 38 insertions(+), 19 deletions(-) diff --git a/.changeset/compile-error-verdicts-reusable.md b/.changeset/compile-error-verdicts-reusable.md index 7044a3753..9b7f473d8 100644 --- a/.changeset/compile-error-verdicts-reusable.md +++ b/.changeset/compile-error-verdicts-reusable.md @@ -6,10 +6,12 @@ An incremental run now reuses `CompileError` verdicts instead of type-checking every one again. A verdict is kept only while nothing that fed the check changed: the mutant and its replacement, every file the checker's TypeScript -program loaded (including transitive and bundled declaration files), its -tsconfig files, the TypeScript version, the checker version and its options. -Otherwise the mutant is re-checked. The reuse stream line reports such refusals -as `programChanged`; lines without that count still decode. +program loaded (including transitive and bundled declaration files), every +tsconfig in the `extends` chain and its project references, the TypeScript +version, the checker version and its options. Otherwise the mutant is +re-checked. The reuse stream line reports such refusals as `programChanged` +(or `closureAnalysisFailed` when the closure analysis failed); lines without a +count still decode. Breaking: a configured checker must answer the new `digest` RPC, so upgrade checker plugins together with this release. diff --git a/packages/stryker-js-cli-contract/src/run-event.schema.ts b/packages/stryker-js-cli-contract/src/run-event.schema.ts index b5f5ee08c..a00ef95a6 100644 --- a/packages/stryker-js-cli-contract/src/run-event.schema.ts +++ b/packages/stryker-js-cli-contract/src/run-event.schema.ts @@ -34,6 +34,7 @@ export const ReuseRefusals = S.Struct({ policyChanged: Report.NonNegativeInt, runInputsChanged: Report.NonNegativeInt, closureChanged: Report.NonNegativeInt, + closureAnalysisFailed: S.optionalKey(Report.NonNegativeInt), programChanged: S.optionalKey(Report.NonNegativeInt), timeoutUnreproduced: Report.NonNegativeInt, flakyDependency: Report.NonNegativeInt, diff --git a/packages/stryker-js/src/IncrementalDiff.schema.ts b/packages/stryker-js/src/IncrementalDiff.schema.ts index 57ee1c38f..236ca7a9a 100644 --- a/packages/stryker-js/src/IncrementalDiff.schema.ts +++ b/packages/stryker-js/src/IncrementalDiff.schema.ts @@ -14,6 +14,7 @@ export const ReuseRefusalReasonSchema = S.Literals([ 'policyChanged', 'runInputsChanged', 'closureChanged', + 'closureAnalysisFailed', 'programChanged', 'flakyDependency', 'timeoutUnreproduced', diff --git a/packages/stryker-js/src/__tests__/incremental-diff.workflow.property.test.ts b/packages/stryker-js/src/__tests__/incremental-diff.workflow.property.test.ts index e4ecf9a0f..78318a7f5 100644 --- a/packages/stryker-js/src/__tests__/incremental-diff.workflow.property.test.ts +++ b/packages/stryker-js/src/__tests__/incremental-diff.workflow.property.test.ts @@ -276,13 +276,26 @@ describe('incrementalDiff', () => { ) it.prop( - '∀r_RecordWithAKey_≡AFailedClosureAnalysisNamesTheProgramGateForAnUnkeyedCompileError', + '∀r_RecordWithAKey_≡AFailedClosureAnalysisNamesItselfUnlessTheProgramGateOutranksIt', { of: [PreviousReuseRecordSchema], subject: incrementalDiff }, (subject, [record]) => { const result = subject(matchingCommandOf(record, { closureAnalysisFailed: true })) return record.status === 'CompileError' && (record.programDigest ?? '') === '' ? runsWithRefusal(result, 'programChanged') - : runsWithRefusal(result, 'closureChanged') + : runsWithRefusal(result, 'closureAnalysisFailed') + }, + ) + + it.prop( + '∀r_RecordWithAKey_≡AFailedClosureAnalysisNamesItselfWhenTheProgramMatches', + { of: [PreviousReuseRecordSchema], subject: incrementalDiff }, + (subject, [record]) => { + const programDigest = 'a'.repeat(64) + const prior = { ...record, status: 'CompileError' as const, programDigest } + return runsWithRefusal( + subject(matchingCommandOf(prior, { closureAnalysisFailed: true })), + 'closureAnalysisFailed', + ) }, ) diff --git a/packages/stryker-js/src/incremental-diff.workflow.ts b/packages/stryker-js/src/incremental-diff.workflow.ts index 485b119ff..82f6fbfbf 100644 --- a/packages/stryker-js/src/incremental-diff.workflow.ts +++ b/packages/stryker-js/src/incremental-diff.workflow.ts @@ -133,25 +133,26 @@ const matchingKey = (command: IncrementalDiffCommand, record: RememberedReuseRec }), ) -const closureChanged = (command: IncrementalDiffCommand, record: PreviousReuseRecord): boolean => - Boolean.or( - command.closureAnalysisFailed, - Boolean.and( - Boolean.not(isCompileErrorRecord(record)), - digestOf(record.closureDigest) !== digestOf(command.closureDigestsByMutantId[record.mutantId]), - ), +const closureDigestChanged = (command: IncrementalDiffCommand, record: PreviousReuseRecord): boolean => + Boolean.and( + Boolean.not(isCompileErrorRecord(record)), + digestOf(record.closureDigest) !== digestOf(command.closureDigestsByMutantId[record.mutantId]), ) const programChanged = (command: IncrementalDiffCommand, record: PreviousReuseRecord): boolean => Boolean.and(isCompileErrorRecord(record), Boolean.not(matchingProgramKey(command, record))) const reasonAfterClosure = (command: IncrementalDiffCommand, record: PreviousReuseRecord): ReuseRefusalReason => - Boolean.match(closureChanged(command, record), { - onTrue: (): ReuseRefusalReason => 'closureChanged', - onFalse: (): ReuseRefusalReason => - Boolean.match(isUnreproducedWallClockTimeout(record), { - onTrue: () => 'timeoutUnreproduced', - onFalse: () => 'noPriorRecord', + Boolean.match(command.closureAnalysisFailed, { + onTrue: (): ReuseRefusalReason => 'closureAnalysisFailed', + onFalse: () => + Boolean.match(closureDigestChanged(command, record), { + onTrue: (): ReuseRefusalReason => 'closureChanged', + onFalse: (): ReuseRefusalReason => + Boolean.match(isUnreproducedWallClockTimeout(record), { + onTrue: () => 'timeoutUnreproduced', + onFalse: () => 'noPriorRecord', + }), }), }) diff --git a/packages/stryker-js/src/run/incremental-reuse.cell.ts b/packages/stryker-js/src/run/incremental-reuse.cell.ts index f7986c59f..938539901 100644 --- a/packages/stryker-js/src/run/incremental-reuse.cell.ts +++ b/packages/stryker-js/src/run/incremental-reuse.cell.ts @@ -46,6 +46,7 @@ const emptyRefusalCounts = (): Record => ({ policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + closureAnalysisFailed: 0, programChanged: 0, flakyDependency: 0, timeoutUnreproduced: 0, From 0a76c3fb5a8beae96b71b00013999a79f61bd2d7 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:42:35 +0000 Subject: [PATCH 11/16] refactor(repo): consolidate the reuse key and version helpers cacheKeyOf and programKeyOf differed only in the digest they read, so one keyOf now serves both. readTypescriptVersion and readCheckerVersion share one reader whose failure returns no version and so no digest. The program digest borrows the existing sha256 and optional-field helpers instead of local copies, and reads program files with bounded concurrency Verdict-Semantics: unchanged --- .../src/ts-compiler.handle.ts | 29 +++++++++---------- .../src/Checker/checker-pool.handle.ts | 9 +++--- .../src/incremental-diff.workflow.ts | 21 ++++---------- .../src/run/incremental-reuse.cell.ts | 2 +- .../stryker-js/src/run/mutation-test.cell.ts | 10 ++----- packages/stryker-js/src/verdict-semantics.ts | 4 +-- 6 files changed, 28 insertions(+), 47 deletions(-) diff --git a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts index 4d6ced993..37763ef1c 100644 --- a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts +++ b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts @@ -230,17 +230,8 @@ const isSupportedTypeScriptVersion = (version: string) => { return numeric && isNewerOrSame(parsed, minimumTypeScriptVersion) } -const readTypescriptVersion = Effect.fnUntraced(function*(rt: TSCompilerRuntime) { - const packagePath = yield* rt.pathService.fromFileUrl(new URL(import.meta.resolve('typescript/package.json'))) - const text = yield* rt.host.readFileString(packagePath) - return Result.match(S.decodeResult(S.fromJsonString(S.Struct({ version: S.String })))(text), { - onFailure: () => '', - onSuccess: (pkg) => pkg.version, - }) -}) - -const readCheckerVersion = Effect.fnUntraced(function*(rt: TSCompilerRuntime) { - const packagePath = yield* rt.pathService.fromFileUrl(new URL('../package.json', import.meta.url)) +const readPackageVersion = Effect.fnUntraced(function*(rt: TSCompilerRuntime, packageUrl: URL | string) { + const packagePath = yield* rt.pathService.fromFileUrl(new URL(packageUrl)).pipe(Effect.orElseSucceed(() => '')) const text = yield* rt.host.readFileString(packagePath).pipe(Effect.orElseSucceed(() => '')) return Result.match(S.decodeResult(S.fromJsonString(S.Struct({ version: S.String })))(text), { onFailure: () => '', @@ -248,6 +239,12 @@ const readCheckerVersion = Effect.fnUntraced(function*(rt: TSCompilerRuntime) { }) }) +const readTypescriptVersion = (rt: TSCompilerRuntime) => + readPackageVersion(rt, import.meta.resolve('typescript/package.json')) + +const readCheckerVersion = (rt: TSCompilerRuntime) => + readPackageVersion(rt, new URL('../package.json', import.meta.url)) + type JsonValue = S.Schema.Type const isJsonObject = (value: JsonValue): value is Record => @@ -276,6 +273,8 @@ const checkerOptionsJsonOf = (options: Options.StrykerOptions): Effect.Effect): Effect.Effect => @@ -325,8 +324,8 @@ export const programDigest = Effect.fn(SpanTaxonomy.Spans.typescriptCheckerCompi const rt = runtimeOf(self) const programs = yield* programsOf(rt) const state = yield* SynchronizedRef.get(rt.state) - const typescriptVersion = yield* readTypescriptVersion(rt).pipe(Effect.orElseSucceed(() => '')) - const checkerVersion = yield* readCheckerVersion(rt).pipe(Effect.orElseSucceed(() => '')) + const typescriptVersion = yield* readTypescriptVersion(rt) + const checkerVersion = yield* readCheckerVersion(rt) yield* Boolean.match(Boolean.or(typescriptVersion === '', checkerVersion === ''), { onTrue: () => Effect.fail(CompilerFailed.make({ reason: 'program-digest-unavailable', subject: 'toolchain version' })), @@ -347,7 +346,7 @@ export const programDigest = Effect.fn(SpanTaxonomy.Spans.typescriptCheckerCompi const guardTypescriptVersion = (rt: TSCompilerRuntime): Effect.Effect => Effect.flatMap( - readTypescriptVersion(rt).pipe(Effect.orElseSucceed(() => '')), + readTypescriptVersion(rt), (version) => Boolean.match(isSupportedTypeScriptVersion(version), { onFalse: () => Effect.fail(UnsupportedTypeScriptVersionError.make({ version })), diff --git a/packages/stryker-js/src/Checker/checker-pool.handle.ts b/packages/stryker-js/src/Checker/checker-pool.handle.ts index fe7d8df20..db645db9a 100644 --- a/packages/stryker-js/src/Checker/checker-pool.handle.ts +++ b/packages/stryker-js/src/Checker/checker-pool.handle.ts @@ -1,5 +1,3 @@ -import { sha256 } from '@noble/hashes/sha2.js' -import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js' import { Handle } from '@systemfsoftware/effect-cell-types' import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' import type { Mutant } from '@systemfsoftware/stryker-js-plugin-interface' @@ -18,6 +16,7 @@ import * as Stream from 'effect/Stream' import type { CheckerContractBroken } from '../admit-checker-answer.workflow.js' import { StageError } from '../Run.schema.js' +import { sha256HexOf } from '../verdict-semantics.js' import type { CheckerCrash, CheckerResourceService } from './Checker.handle.js' import { checkPlans as checkPlansWithChecker, groupPlans as groupPlansWithChecker } from './Checker.plans.js' import { @@ -33,8 +32,6 @@ export type TypeId = typeof TypeId export type CheckerSlot = { readonly checkerName: string; readonly checker: CheckerResourceService }[] -const hashOf = (content: string): string => bytesToHex(sha256(utf8ToBytes(content))) - export type CheckerPool = Pool.Pool export interface CheckedPlans { @@ -197,7 +194,9 @@ const answeredProgramDigestOf = Effect.fn(SpanTaxonomy.Spans.checkerPoolProgramD handle: CheckerPoolHandle, ) { const lines = yield* Pool.use(CheckerPoolHandle.slot(handle), (slot) => Effect.forEach(slot, digestLineOf)) - return Option.getOrUndefined(Option.map(Option.all(lines), (answered) => hashOf([...answered].sort().join('\n')))) + return Option.getOrUndefined( + Option.map(Option.all(lines), (answered) => sha256HexOf([...answered].sort().join('\n'))), + ) }) const failedElement = ( diff --git a/packages/stryker-js/src/incremental-diff.workflow.ts b/packages/stryker-js/src/incremental-diff.workflow.ts index 82f6fbfbf..d9a1a6e96 100644 --- a/packages/stryker-js/src/incremental-diff.workflow.ts +++ b/packages/stryker-js/src/incremental-diff.workflow.ts @@ -95,32 +95,22 @@ const digestOf = (digest: string | undefined): string => Option.getOrElse(Option const isCompileErrorRecord = (record: PreviousReuseRecord): boolean => record.status === 'CompileError' -const cacheKeyOf = (mutantId: string, closureDigest: string | undefined, components: CacheKeyComponents): string => +const keyOf = (mutantId: string, digest: string | undefined, components: CacheKeyComponents): string => [ mutantId, - digestOf(closureDigest), - String(components.verdictSemanticsVersion), - components.mutantSetPolicy, - components.runInputsDigest, - ].join('\u0000') - -const programKeyOf = (mutantId: string, programDigest: string | undefined, components: CacheKeyComponents): string => - [ - mutantId, - digestOf(programDigest), + digestOf(digest), String(components.verdictSemanticsVersion), components.mutantSetPolicy, components.runInputsDigest, ].join('\u0000') const currentKeyOf = (command: IncrementalDiffCommand, mutantId: Mutant.MutantId): string => - cacheKeyOf(mutantId, command.closureDigestsByMutantId[mutantId], command) + keyOf(mutantId, command.closureDigestsByMutantId[mutantId], command) const matchingProgramKey = (command: IncrementalDiffCommand, record: PreviousReuseRecord): boolean => Boolean.and( digestOf(record.programDigest) !== '', - programKeyOf(record.mutantId, record.programDigest, record) === - programKeyOf(record.mutantId, command.programDigest, command), + keyOf(record.mutantId, record.programDigest, record) === keyOf(record.mutantId, command.programDigest, command), ) const matchingKey = (command: IncrementalDiffCommand, record: RememberedReuseRecord): boolean => @@ -128,8 +118,7 @@ const matchingKey = (command: IncrementalDiffCommand, record: RememberedReuseRec Boolean.not(command.closureAnalysisFailed), Boolean.match(isCompileErrorRecord(record), { onTrue: () => matchingProgramKey(command, record), - onFalse: () => - cacheKeyOf(record.mutantId, record.closureDigest, record) === currentKeyOf(command, record.mutantId), + onFalse: () => keyOf(record.mutantId, record.closureDigest, record) === currentKeyOf(command, record.mutantId), }), ) diff --git a/packages/stryker-js/src/run/incremental-reuse.cell.ts b/packages/stryker-js/src/run/incremental-reuse.cell.ts index 938539901..188cdb950 100644 --- a/packages/stryker-js/src/run/incremental-reuse.cell.ts +++ b/packages/stryker-js/src/run/incremental-reuse.cell.ts @@ -67,7 +67,7 @@ const digestField = (digest: string | undefined) => onSome: (present) => ({ closureDigest: present }), }) -const optionalField = (field: string, value: A | undefined) => +export const optionalField = (field: string, value: A | undefined) => Option.match(Option.fromUndefinedOr(value), { onNone: (): Readonly> => ({}), onSome: (present) => ({ [field]: present }), diff --git a/packages/stryker-js/src/run/mutation-test.cell.ts b/packages/stryker-js/src/run/mutation-test.cell.ts index c1c78d494..651a94920 100644 --- a/packages/stryker-js/src/run/mutation-test.cell.ts +++ b/packages/stryker-js/src/run/mutation-test.cell.ts @@ -37,7 +37,7 @@ import { originalFileFor } from '../Sandbox.handle.js' import type { PooledTestRunnerError } from '../TestRunner.schema.js' import { IdGenerator } from '../Worker.service.js' import type { DryRunDone } from './dry-run.cell.js' -import { readIncrementalReuse } from './incremental-reuse.cell.js' +import { optionalField, readIncrementalReuse } from './incremental-reuse.cell.js' import { mutantRunCell } from './mutant-run.cell.js' import { announceSettledMutant, makeCheckpointWriter, reportingInputOf, type RunContext } from './mutant-run.js' import { planMutationTest } from './mutation-test-plan.cell.js' @@ -103,12 +103,6 @@ const writeMutationTestDryRunOnly = Effect.fn(SpanTaxonomy.Spans.mutationTestDry return { results: [], verdict: null } }) -const programDigestFieldOf = (programDigest: string | undefined): Record => - Option.match(Option.fromUndefinedOr(programDigest), { - onNone: (): Record => ({}), - onSome: (present) => ({ programDigest: present }), - }) - const proceedPipeline = Effect.fnUntraced(function*(raw: MutationTestRaw) { const prev = raw.prev const testRunnerCapacity = prev.concurrency.testRunners + prev.concurrency.checkers @@ -269,7 +263,7 @@ const proceedPipeline = Effect.fnUntraced(function*(raw: MutationTestRaw) { ...reportingInputOf({ prev, env, results: allResults }), closureDigestsByMutantId: reuse.closureDigestsByMutantId, timeoutEvidenceByMutantId: reuse.timeoutEvidenceByMutantId, - ...programDigestFieldOf(reuse.programDigest), + ...optionalField('programDigest', reuse.programDigest), }) yield* Fiber.await(checkerRelease) const doneNow = yield* Clock.currentTimeMillis diff --git a/packages/stryker-js/src/verdict-semantics.ts b/packages/stryker-js/src/verdict-semantics.ts index e6982dec6..6e05d4fd1 100644 --- a/packages/stryker-js/src/verdict-semantics.ts +++ b/packages/stryker-js/src/verdict-semantics.ts @@ -44,7 +44,7 @@ const LOCKFILE_NAMES: readonly string[] = [ const NODE_MAJOR = globalThis.process.versions.node.split('.')[0] ?? '' -const hashOf = (content: string): string => bytesToHex(sha256(utf8ToBytes(content))) +export const sha256HexOf = (content: string): string => bytesToHex(sha256(utf8ToBytes(content))) const isJsonObject = (value: Json): value is JsonObject => Match.record(value) @@ -121,7 +121,7 @@ export const packageManifestInputOf = (content: string): Effect.Effect - hashOf([inputs.optionsFingerprint, inputs.packageManifest, inputs.lockfile, inputs.nodeMajor].join('\u0000')) + sha256HexOf([inputs.optionsFingerprint, inputs.packageManifest, inputs.lockfile, inputs.nodeMajor].join('\u0000')) const existsAsOption = ( fs: FileSystem.FileSystem, From b1efbd798fad2882a0903d59564505e26fd4cb46 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:48:48 +0000 Subject: [PATCH 12/16] test(repo): pin the new closure-analysis refusal count The reuse-refusal objects these scenarios compare gained a closureAnalysisFailed count, so each zero-refusal fixture now names it Verdict-Semantics: unchanged --- .../tests/dry-run-coverage-across-roots.integration.test.ts | 1 + packages/stryker-js/tests/incremental-reuse.integration.test.ts | 1 + packages/stryker-js/tests/plan-shard-reuse.integration.test.ts | 1 + .../stryker-js/tests/plan-stream-location.integration.test.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/packages/stryker-js/tests/dry-run-coverage-across-roots.integration.test.ts b/packages/stryker-js/tests/dry-run-coverage-across-roots.integration.test.ts index 020adbe5e..12f2efe67 100644 --- a/packages/stryker-js/tests/dry-run-coverage-across-roots.integration.test.ts +++ b/packages/stryker-js/tests/dry-run-coverage-across-roots.integration.test.ts @@ -161,6 +161,7 @@ const ZERO_REFUSALS = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + closureAnalysisFailed: 0, programChanged: 0, flakyDependency: 0, timeoutUnreproduced: 0, diff --git a/packages/stryker-js/tests/incremental-reuse.integration.test.ts b/packages/stryker-js/tests/incremental-reuse.integration.test.ts index 0d53bd0e9..9d9af5b88 100644 --- a/packages/stryker-js/tests/incremental-reuse.integration.test.ts +++ b/packages/stryker-js/tests/incremental-reuse.integration.test.ts @@ -157,6 +157,7 @@ const ZERO_REFUSALS = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + closureAnalysisFailed: 0, programChanged: 0, flakyDependency: 0, timeoutUnreproduced: 0, diff --git a/packages/stryker-js/tests/plan-shard-reuse.integration.test.ts b/packages/stryker-js/tests/plan-shard-reuse.integration.test.ts index cdcce1b23..e588128bd 100644 --- a/packages/stryker-js/tests/plan-shard-reuse.integration.test.ts +++ b/packages/stryker-js/tests/plan-shard-reuse.integration.test.ts @@ -173,6 +173,7 @@ const NO_REFUSALS: RunEvent.ReuseRefusals = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + closureAnalysisFailed: 0, programChanged: 0, timeoutUnreproduced: 0, flakyDependency: 0, diff --git a/packages/stryker-js/tests/plan-stream-location.integration.test.ts b/packages/stryker-js/tests/plan-stream-location.integration.test.ts index 82f7fb9d9..c31da1f9b 100644 --- a/packages/stryker-js/tests/plan-stream-location.integration.test.ts +++ b/packages/stryker-js/tests/plan-stream-location.integration.test.ts @@ -152,6 +152,7 @@ const NO_REFUSALS: RunEvent.ReuseRefusals = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + closureAnalysisFailed: 0, programChanged: 0, timeoutUnreproduced: 0, flakyDependency: 0, From a677aeda25fe1cdeddb399dda2abbd01fc76d98e Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:49:41 +0000 Subject: [PATCH 13/16] chore(repo): regenerate the stream contract with the refusal count The published stream schema gains the optional closureAnalysisFailed count that the reuse report now emits alongside the other refusal counts Verdict-Semantics: unchanged --- .../stryker-js-cli-contract/contract/stream.schema.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/stryker-js-cli-contract/contract/stream.schema.json b/packages/stryker-js-cli-contract/contract/stream.schema.json index 5fa1f973d..94a64387e 100644 --- a/packages/stryker-js-cli-contract/contract/stream.schema.json +++ b/packages/stryker-js-cli-contract/contract/stream.schema.json @@ -280,6 +280,10 @@ "type": "integer", "minimum": 0 }, + "closureAnalysisFailed": { + "type": "integer", + "minimum": 0 + }, "programChanged": { "type": "integer", "minimum": 0 @@ -780,6 +784,10 @@ "type": "integer", "minimum": 0 }, + "closureAnalysisFailed": { + "type": "integer", + "minimum": 0 + }, "programChanged": { "type": "integer", "minimum": 0 From 9211add68bbf93cd9cf4bafcfd03e83b79149a9d Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 16:55:46 +0000 Subject: [PATCH 14/16] refactor(repo): satisfy the cell and effect lint rules The digest helpers now branch through Option and Boolean match, so no function exceeds the complexity budget, the key-order canonicaliser sorts schema keys without assertions, and the closure walker resolves extends candidates without a loop. The optional-field builder moves to the reuse module as a dual export, which keeps it out of a cell file and off the pipeable-signature rule Verdict-Semantics: unchanged --- .../src/ts-compiler.handle.ts | 134 +++++++++++------- .../tests/program-digest.integration.test.ts | 4 +- .../src/run/incremental-reuse.cell.ts | 8 +- .../stryker-js/src/run/incremental-reuse.ts | 13 ++ .../stryker-js/src/run/mutation-test.cell.ts | 3 +- 5 files changed, 102 insertions(+), 60 deletions(-) diff --git a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts index 37763ef1c..e591e01dd 100644 --- a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts +++ b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts @@ -247,20 +247,31 @@ const readCheckerVersion = (rt: TSCompilerRuntime) => type JsonValue = S.Schema.Type +const isNonNullObjectOf = (value: JsonValue): boolean => typeof value === 'object' && value !== null + const isJsonObject = (value: JsonValue): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) + isNonNullObjectOf(value) && !Array.isArray(value) + +const isJsonArray = (value: JsonValue): value is ReadonlyArray => + typeof value === 'object' && Array.isArray(value) + +const canonicalEntriesOf = (object: Record): string => + Arr.map( + Arr.sort(Object.keys(object), Order.String), + (key) => `${JSON.stringify(key)}:${canonicalJsonOf(object[key] ?? null)}`, + ).join(',') + +const canonicalObjectOf = (value: JsonValue): string => + Option.match(Option.liftPredicate(value, isJsonObject), { + onNone: () => JSON.stringify(value), + onSome: (object) => `{${canonicalEntriesOf(object)}}`, + }) const canonicalJsonOf = (value: JsonValue): string => - Array.isArray(value) - ? `[${value.map(canonicalJsonOf).join(',')}]` - : isJsonObject(value) - ? `{${ - Object.keys(value) - .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalJsonOf(value[key] as JsonValue)}`) - .join(',') - }}` - : JSON.stringify(value) + Option.match(Option.liftPredicate(value, isJsonArray), { + onNone: () => canonicalObjectOf(value), + onSome: (array) => `[${Arr.map(array, canonicalJsonOf).join(',')}]`, + }) const checkerOptionsJsonOf = (options: Options.StrykerOptions): Effect.Effect => Option.match(Option.fromUndefinedOr(options['typescriptChecker']), { @@ -397,10 +408,11 @@ const TS_CONFIG_JSON_EXTENSION = '.json' const NODE_MODULES_DIRECTORY = 'node_modules' -const tsConfigExtendsOf = (config: TsConfigDocument): ReadonlyArray => { - const declared = config['extends'] - return declared === undefined ? [] : typeof declared === 'string' ? [declared] : [...declared] -} +const tsConfigExtendsOf = (config: TsConfigDocument): ReadonlyArray => + Option.match(Option.fromUndefinedOr(config['extends']), { + onNone: (): ReadonlyArray => [], + onSome: (declared) => (typeof declared === 'string' ? [declared] : [...declared]), + }) const ancestorDirectoriesOf = (pathService: Path.Path, from: string): ReadonlyArray => { const directories: Array = [from] @@ -445,51 +457,73 @@ const isFileOf = (rt: TSCompilerRuntime, fileName: string): Effect.Effect false), ) +const firstExistingOf = ( + rt: TSCompilerRuntime, + candidates: ReadonlyArray, +): Effect.Effect, never> => + Option.match(Arr.head(candidates), { + onNone: () => Effect.succeedNone, + onSome: (candidate) => + Effect.flatMap(isFileOf(rt, candidate), (present) => + Boolean.match(present, { + onTrue: () => Effect.succeedSome(candidate), + onFalse: () => firstExistingOf(rt, Arr.drop(candidates, 1)), + })), + }) + const resolveTsConfigExtends = ( rt: TSCompilerRuntime, fromDirName: string, specifier: string, ): Effect.Effect => - Effect.gen(function*() { - const candidates = tsConfigExtendsCandidatesOf(rt.pathService, fromDirName, specifier) - for (const candidate of candidates) { - if (yield* isFileOf(rt, candidate)) { - return candidate - } - } - return yield* Effect.fail( - CompilerFailed.make({ reason: 'program-digest-unavailable', subject: `tsconfig extends "${specifier}"` }), - ) + Effect.flatMap( + firstExistingOf(rt, tsConfigExtendsCandidatesOf(rt.pathService, fromDirName, specifier)), + (found) => + Effect.fromOption(found, () => + CompilerFailed.make({ reason: 'program-digest-unavailable', subject: `tsconfig extends "${specifier}"` })), + ) + +const extendedConfigsOf = ( + rt: TSCompilerRuntime, + fileName: string, +): Effect.Effect, CompilerError> => + Effect.flatMap( + readTsConfigText(rt, HashMap.empty(), fileName), + (jsonText) => + Result.match(parseTsConfig(fileName, jsonText), { + onFailure: (failure) => Effect.fail(failure), + onSuccess: (config) => + Effect.forEach( + tsConfigExtendsOf(config), + (specifier) => resolveTsConfigExtends(rt, rt.pathService.dirname(fileName), specifier), + ), + }), + ) + +const visitTsConfigChain = ( + rt: TSCompilerRuntime, + pending: ReadonlyArray, + visited: HashSet.HashSet, + chain: ReadonlyArray, +): Effect.Effect, CompilerError> => + Option.match(Arr.head(pending), { + onNone: () => Effect.succeed(chain), + onSome: (fileName) => + Boolean.match(HashSet.has(visited, fileName), { + onTrue: () => visitTsConfigChain(rt, Arr.drop(pending, 1), visited, chain), + onFalse: () => + Effect.flatMap(extendedConfigsOf(rt, fileName), (extended) => + visitTsConfigChain(rt, [...Arr.drop(pending, 1), ...extended], HashSet.add(visited, fileName), [ + ...chain, + fileName, + ])), + }), }) const tsConfigChainOf = ( rt: TSCompilerRuntime, seeds: ReadonlyArray, -): Effect.Effect, CompilerError> => - Effect.gen(function*() { - const visited = new Set() - const chain: Array = [] - const pending: Array = [...seeds] - while (pending.length > 0) { - const fileName = pending.pop() as string - if (visited.has(fileName)) { - continue - } - visited.add(fileName) - chain.push(fileName) - const jsonText = yield* readTsConfigText(rt, HashMap.empty(), fileName) - const parsed = parseTsConfig(fileName, jsonText) - if (Result.isFailure(parsed)) { - return yield* Effect.fail(parsed.failure) - } - const extended = yield* Effect.forEach( - tsConfigExtendsOf(parsed.success), - (specifier) => resolveTsConfigExtends(rt, rt.pathService.dirname(fileName), specifier), - ) - pending.push(...extended) - } - return chain - }) +): Effect.Effect, CompilerError> => visitTsConfigChain(rt, seeds, HashSet.empty(), []) interface TsConfigWalk { readonly overrides: HashMap.HashMap diff --git a/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts b/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts index 6e0574b8d..52528664d 100644 --- a/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts +++ b/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts @@ -134,7 +134,7 @@ const appendComment = (directory: string, file: string): Effect.Effect>, + checkerOptions?: Readonly>, ): Effect.Effect => Effect.gen(function*() { const pathService = yield* Path.Path @@ -151,7 +151,7 @@ const digestOf = ( const digestOutcomeOf = ( directory: string, - checkerOptions?: Readonly>, + checkerOptions?: Readonly>, ): Effect.Effect, never, never> => Effect.exit(digestOf(directory, checkerOptions).pipe(Effect.provide(FILE_PORTS))) diff --git a/packages/stryker-js/src/run/incremental-reuse.cell.ts b/packages/stryker-js/src/run/incremental-reuse.cell.ts index 188cdb950..9238ff0b9 100644 --- a/packages/stryker-js/src/run/incremental-reuse.cell.ts +++ b/packages/stryker-js/src/run/incremental-reuse.cell.ts @@ -37,7 +37,7 @@ import { reportTestIds, ResolveReportTestIds } from '../report-test-ids.workflow import { StageError } from '../Run.schema.js' import type { TestCoverage } from '../test-coverage.schema.js' import { runInputsDigestOf, VERDICT_SEMANTICS_VERSION } from '../verdict-semantics.js' -import { incrementalReportTextsOf } from './incremental-reuse.js' +import { incrementalReportTextsOf, optionalField } from './incremental-reuse.js' const hashOf = (content: string): string => bytesToHex(sha256(utf8ToBytes(content))) @@ -67,12 +67,6 @@ const digestField = (digest: string | undefined) => onSome: (present) => ({ closureDigest: present }), }) -export const optionalField = (field: string, value: A | undefined) => - Option.match(Option.fromUndefinedOr(value), { - onNone: (): Readonly> => ({}), - onSome: (present) => ({ [field]: present }), - }) - const optionalListField = (field: string, value: readonly string[] | undefined) => Option.match(Option.fromUndefinedOr(value), { onNone: (): Readonly> => ({}), diff --git a/packages/stryker-js/src/run/incremental-reuse.ts b/packages/stryker-js/src/run/incremental-reuse.ts index 6f501a72e..9946abf0e 100644 --- a/packages/stryker-js/src/run/incremental-reuse.ts +++ b/packages/stryker-js/src/run/incremental-reuse.ts @@ -3,9 +3,22 @@ import * as Arr from 'effect/Array' import * as Boolean from 'effect/Boolean' import * as Effect from 'effect/Effect' import * as FileSystem from 'effect/FileSystem' +import { dual } from 'effect/Function' import * as Option from 'effect/Option' import * as Path from 'effect/Path' +export const optionalField: { + (field: string, value: A | undefined): Record + (value: A | undefined): (field: string) => Record +} = dual( + 2, + (field: string, value: A | undefined): Record => + Option.match(Option.fromUndefinedOr(value), { + onNone: (): Record => ({}), + onSome: (present) => ({ [field]: present }), + }), +) + export interface IncrementalSourcesInput { readonly basePath: string readonly options: Options.StrykerOptions diff --git a/packages/stryker-js/src/run/mutation-test.cell.ts b/packages/stryker-js/src/run/mutation-test.cell.ts index 651a94920..ddeb49619 100644 --- a/packages/stryker-js/src/run/mutation-test.cell.ts +++ b/packages/stryker-js/src/run/mutation-test.cell.ts @@ -37,7 +37,8 @@ import { originalFileFor } from '../Sandbox.handle.js' import type { PooledTestRunnerError } from '../TestRunner.schema.js' import { IdGenerator } from '../Worker.service.js' import type { DryRunDone } from './dry-run.cell.js' -import { optionalField, readIncrementalReuse } from './incremental-reuse.cell.js' +import { readIncrementalReuse } from './incremental-reuse.cell.js' +import { optionalField } from './incremental-reuse.js' import { mutantRunCell } from './mutant-run.cell.js' import { announceSettledMutant, makeCheckpointWriter, reportingInputOf, type RunContext } from './mutant-run.js' import { planMutationTest } from './mutation-test-plan.cell.js' From 137563a362643926a803cbf0ed6f8e3c3a64d51f Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 17:19:47 +0000 Subject: [PATCH 15/16] 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": { From ef64cb529c2188e736f300f63afe1d9c30088ac9 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 18:33:09 +0000 Subject: [PATCH 16/16] ci(repo): rerun CI after two e2e lanes hit the lane limit on slow runners Run 37664668214 timed out e2e sabotage and rest-1 at 19m59s with no failing test, on a tree identical to d82f75880, which passed in run 37658299516 Verdict-Semantics: unchanged