diff --git a/.changeset/checker-reports-program-digest.md b/.changeset/checker-reports-program-digest.md new file mode 100644 index 000000000..bc124f563 --- /dev/null +++ b/.changeset/checker-reports-program-digest.md @@ -0,0 +1,17 @@ +--- +"@systemfsoftware/stryker-js-plugin-interface": major +"@systemfsoftware/stryker-js-typescript-checker": minor +--- + +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 +(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. + +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 new file mode 100644 index 000000000..9b7f473d8 --- /dev/null +++ b/.changeset/compile-error-verdicts-reusable.md @@ -0,0 +1,17 @@ +--- +"@systemfsoftware/stryker-js": major +"@systemfsoftware/stryker-js-cli-contract": minor +--- + +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), 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/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..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,14 @@ "type": "integer", "minimum": 0 }, + "closureAnalysisFailed": { + "type": "integer", + "minimum": 0 + }, + "programChanged": { + "type": "integer", + "minimum": 0 + }, "timeoutUnreproduced": { "type": "integer", "minimum": 0 @@ -776,6 +784,14 @@ "type": "integer", "minimum": 0 }, + "closureAnalysisFailed": { + "type": "integer", + "minimum": 0 + }, + "programChanged": { + "type": "integer", + "minimum": 0 + }, "timeoutUnreproduced": { "type": "integer", "minimum": 0 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..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,8 @@ 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, 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/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/__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..e591e01dd 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' @@ -225,18 +230,134 @@ 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) +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: () => '', onSuccess: (pkg) => pkg.version, }) }) +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 isNonNullObjectOf = (value: JsonValue): boolean => typeof value === 'object' && value !== null + +const isJsonObject = (value: JsonValue): value is Record => + 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 => + 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']), { + 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 DIGEST_READ_CONCURRENCY = 32 + +const readProgramFiles = ( + rt: TSCompilerRuntime, + root: string, + 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: normalizeFileName(rt.pathService.relative(root, fileName)), + digest: sha256HexOf(content), + }), + ), + { concurrency: DIGEST_READ_CONCURRENCY }, + ) + +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) + const checkerVersion = yield* readCheckerVersion(rt) + 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 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( + IdentifyProgramCommand.make({ typescriptVersion, checkerVersion, checkerOptionsJson, sourceFiles, tsconfigs }), + ) +}) + 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 })), @@ -283,6 +404,127 @@ const referencedProjectsOf = ( ) }) +const TS_CONFIG_JSON_EXTENSION = '.json' + +const NODE_MODULES_DIRECTORY = 'node_modules' + +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] + 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 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.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> => visitTsConfigChain(rt, seeds, HashSet.empty(), []) + 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 new file mode 100644 index 000000000..52528664d --- /dev/null +++ b/packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts @@ -0,0 +1,372 @@ +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 Exit from 'effect/Exit' +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 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'", + '', + '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 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() + yield* Effect.forEach( + Object.entries(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, + 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 + 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 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 = ( + 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, + files?: Readonly>, +): Effect.Effect => + Effect.gen(function*() { + const workspace = yield* writeWorkspace(files) + 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') + .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 }) + ), + ), + ) + + 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 }) + ), + ), + ) + + 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 }) + ), + ), + ) + + 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 })), + ), + ) + }) 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..db645db9a 100644 --- a/packages/stryker-js/src/Checker/checker-pool.handle.ts +++ b/packages/stryker-js/src/Checker/checker-pool.handle.ts @@ -16,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 { @@ -176,6 +177,28 @@ 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) => sha256HexOf([...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..236ca7a9a 100644 --- a/packages/stryker-js/src/IncrementalDiff.schema.ts +++ b/packages/stryker-js/src/IncrementalDiff.schema.ts @@ -14,6 +14,8 @@ export const ReuseRefusalReasonSchema = S.Literals([ 'policyChanged', 'runInputsChanged', 'closureChanged', + 'closureAnalysisFailed', + 'programChanged', 'flakyDependency', 'timeoutUnreproduced', 'noPriorRecord', @@ -40,6 +42,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 +59,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..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 @@ -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,27 @@ describe('incrementalDiff', () => { ) it.prop( - '∀r_RecordWithAKey_≡AFailedClosureAnalysisRunsNamingClosureChanged', + '∀r_RecordWithAKey_≡AFailedClosureAnalysisNamesItselfUnlessTheProgramGateOutranksIt', { 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, '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', + ) + }, ) it.prop( @@ -279,21 +350,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..d9a1a6e96 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 cacheKeyOf = (mutantId: string, closureDigest: string | undefined, components: CacheKeyComponents): string => +const isCompileErrorRecord = (record: PreviousReuseRecord): boolean => record.status === 'CompileError' + +const keyOf = (mutantId: string, digest: string | undefined, components: CacheKeyComponents): string => [ mutantId, - digestOf(closureDigest), + 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) !== '', + keyOf(record.mutantId, record.programDigest, record) === keyOf(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: () => keyOf(record.mutantId, record.closureDigest, record) === currentKeyOf(command, record.mutantId), + }), ) -const closureChanged = (command: IncrementalDiffCommand, record: PreviousReuseRecord): boolean => - Boolean.or( - command.closureAnalysisFailed, +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', + }), }), }) 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..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))) @@ -46,6 +46,8 @@ const emptyRefusalCounts = (): Record => ({ policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + closureAnalysisFailed: 0, + programChanged: 0, flakyDependency: 0, timeoutUnreproduced: 0, noPriorRecord: 0, @@ -65,12 +67,6 @@ const digestField = (digest: string | undefined) => onSome: (present) => ({ closureDigest: present }), }) -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> => ({}), @@ -102,6 +98,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 +340,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 +351,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 +377,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 +416,7 @@ const mutantToRunPart = Effect.fnUntraced(function*( onNone: () => ({}), onSome: (killedBy) => ({ [mutant.id]: [...killedBy] }), }), + programDigest: command.programDigest, }) }) @@ -460,6 +464,7 @@ const rememberedMutantPart = Effect.fnUntraced(function*( closureDigestsByMutantId: command.closureDigestsByMutantId, timeoutEvidenceByMutantId: {}, priorKilledByByMutantId: {}, + programDigest: command.programDigest, }), onSome: (mutant) => Effect.map( @@ -471,6 +476,7 @@ const rememberedMutantPart = Effect.fnUntraced(function*( closureDigestsByMutantId: command.closureDigestsByMutantId, timeoutEvidenceByMutantId: rememberedTimeoutEvidenceOf(decision), priorKilledByByMutantId: {}, + programDigest: command.programDigest, }), ), }) @@ -495,6 +501,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 +513,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 +522,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 +548,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/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 25f225585..ddeb49619 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' @@ -37,6 +38,7 @@ 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 } 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' @@ -134,6 +136,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 +147,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 +178,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 +264,7 @@ const proceedPipeline = Effect.fnUntraced(function*(raw: MutationTestRaw) { ...reportingInputOf({ prev, env, results: allResults }), closureDigestsByMutantId: reuse.closureDigestsByMutantId, timeoutEvidenceByMutantId: reuse.timeoutEvidenceByMutantId, + ...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, 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..bc423305b --- /dev/null +++ b/packages/stryker-js/tests/__fixtures__/program-digesting-checker/worker.mjs @@ -0,0 +1,74 @@ +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' +import { relative, resolve } from 'node:path' + +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, ...(await configFileChain())].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..5e8d3dbd2 --- /dev/null +++ b/packages/stryker-js/tests/compile-error-reuse.integration.test.ts @@ -0,0 +1,412 @@ +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 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' + +interface Workspace { + readonly directory: string +} + +const incrementalFileOf = (directory: string): string => `${directory}/reports/main.json` + +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() + yield* Effect.forEach( + Object.entries(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, + files?: Readonly>, +): Effect.Effect => + Effect.gen(function*() { + const workspace = yield* writeWorkspace(files) + 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) => rewriteFile(inner.directory, TSCONFIG_FILE, CHANGED_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, + }) + ), + ), + ) + + 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, + }) + ), + ), + ) + + 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, + }) + ), + ), + ) + }) 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..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,8 @@ const ZERO_REFUSALS = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + closureAnalysisFailed: 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..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,8 @@ const ZERO_REFUSALS = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + closureAnalysisFailed: 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..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,8 @@ const NO_REFUSALS: RunEvent.ReuseRefusals = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + closureAnalysisFailed: 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..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,8 @@ const NO_REFUSALS: RunEvent.ReuseRefusals = { policyChanged: 0, runInputsChanged: 0, closureChanged: 0, + closureAnalysisFailed: 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)