From 0232ab0d2cfa5b9b92d928817866094a366bf52b Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 06:11:41 +0000 Subject: [PATCH 1/4] feat(repo): report each test file's evaluated modules on the dry run The vitest runner collects Vitest's per-worker module execution record at the end of each dry-run test file and returns it as testFileModules, keyed by the absolute test file path with node: builtins dropped. The complete DryRunResult now carries the optional field so an incremental run can close a test file's closure from the modules it actually evaluated Verdict-Semantics: unchanged --- .../dry-run-collects-evaluated-modules.md | 5 + .../dry-run-reports-evaluated-modules.md | 5 + .../src/TestRunner.schema.ts | 2 + .../sandbox/stryker-setup.ts | 18 +- .../src/VitestRunner.service.ts | 23 ++- .../src/VitestRuntime.handle.ts | 3 + .../src/drivers/vitest-node.ts | 6 + ...ry-run-module-evidence.integration.test.ts | 175 ++++++++++++++++++ .../typings/vitest-setup.d.ts | 1 + 9 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 .changeset/dry-run-collects-evaluated-modules.md create mode 100644 .changeset/dry-run-reports-evaluated-modules.md create mode 100644 packages/stryker-js-vitest-runner/tests/dry-run-module-evidence.integration.test.ts diff --git a/.changeset/dry-run-collects-evaluated-modules.md b/.changeset/dry-run-collects-evaluated-modules.md new file mode 100644 index 000000000..e12704ded --- /dev/null +++ b/.changeset/dry-run-collects-evaluated-modules.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js-vitest-runner": patch +--- + +The dry run now reports the modules each test file evaluated, reading Vitest's per-worker module execution record at the end of every test file and returning it on the dry run's result as `testFileModules`. The evidence is gathered only on the dry run, so mutant runs pay nothing extra, and it is keyed by the absolute test file path. diff --git a/.changeset/dry-run-reports-evaluated-modules.md b/.changeset/dry-run-reports-evaluated-modules.md new file mode 100644 index 000000000..b2ede4285 --- /dev/null +++ b/.changeset/dry-run-reports-evaluated-modules.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js-plugin-interface": patch +--- + +A complete dry run can now carry `testFileModules`, a record from each test file's path to the modules that file evaluated while it ran, so an incremental run can key a test file's closure on the modules it actually loaded instead of on every file a dynamic import might reach. diff --git a/packages/stryker-js-plugin-interface/src/TestRunner.schema.ts b/packages/stryker-js-plugin-interface/src/TestRunner.schema.ts index d4f4585b7..372f63028 100644 --- a/packages/stryker-js-plugin-interface/src/TestRunner.schema.ts +++ b/packages/stryker-js-plugin-interface/src/TestRunner.schema.ts @@ -42,6 +42,7 @@ export const DryRunResultSchema = S.Union([ tests: S.Array(TestResultSchema), mutantCoverage: S.optionalKey(MutantCoverageSchema), globalTestInputs: S.String.pipe(S.Array, S.optionalKey), + testFileModules: S.optionalKey(S.Record(S.String, S.String.pipe(S.Array))), }), S.Struct({ status: S.Literal('timeout'), reason: S.optionalKey(S.String) }), S.Struct({ status: S.Literal('error'), errorMessage: S.String }), @@ -129,6 +130,7 @@ export interface CompleteDryRunResult { readonly tests: readonly TestResult[] readonly mutantCoverage?: MutantCoverage readonly globalTestInputs?: readonly string[] + readonly testFileModules?: Readonly> readonly status: 'complete' } diff --git a/packages/stryker-js-vitest-runner/sandbox/stryker-setup.ts b/packages/stryker-js-vitest-runner/sandbox/stryker-setup.ts index 6763c8306..f36c16a40 100644 --- a/packages/stryker-js-vitest-runner/sandbox/stryker-setup.ts +++ b/packages/stryker-js-vitest-runner/sandbox/stryker-setup.ts @@ -12,11 +12,23 @@ const ns: Types.Mutable = globalThis[globalNames (globalThis[globalNamespace] = {}) interface SuiteWithTaskMeta { - meta: { hitCount?: number; mutantCoverage?: Mutant.MutantCoverage } + meta: { + hitCount?: number + mutantCoverage?: Mutant.MutantCoverage + testFileModules?: string[] + } } ns.hitLimit = inject('hitLimit') +const evaluatedModuleKeysOf = (): string[] | undefined => { + const workerState = Reflect.get(globalThis, '__vitest_worker__') + if (typeof workerState !== 'object' || workerState === null) return undefined + const info = 'moduleExecutionInfo' in workerState ? workerState.moduleExecutionInfo : undefined + if (!(info instanceof Map)) return undefined + return [...info.keys()].filter((key): key is string => typeof key === 'string' && !key.startsWith('node:')) +} + const registerMutantRunHooks = () => { beforeAll(() => { ns.hitCount = 0 @@ -51,6 +63,10 @@ const registerDryRunHooks = () => { afterAll(({}, suite: SuiteWithTaskMeta) => { suite.meta.mutantCoverage = ns.mutantCoverage + const evaluatedModules = evaluatedModuleKeysOf() + if (evaluatedModules !== undefined) { + suite.meta.testFileModules = evaluatedModules + } }) } diff --git a/packages/stryker-js-vitest-runner/src/VitestRunner.service.ts b/packages/stryker-js-vitest-runner/src/VitestRunner.service.ts index 3137c6675..b270fdb40 100644 --- a/packages/stryker-js-vitest-runner/src/VitestRunner.service.ts +++ b/packages/stryker-js-vitest-runner/src/VitestRunner.service.ts @@ -35,6 +35,7 @@ import { metaOf, reportAllKillersOf, start, + testFileModulesOf, } from './VitestRuntime.handle.js' import { VitestSession, type VitestSessionInput } from './VitestSession.service.js' @@ -285,6 +286,17 @@ const makeRunner = Effect.fn(SpanTaxonomy.Spans.vitestRunnerMake.name)(function* }) }) + const readTestFileModules = Effect.gen(function*() { + const self = yield* runtime.pipe(Effect.mapError((cause) => new CoverageDecodeFailed({ cause }))) + const entries = files(self).flatMap((file) => + Option.match(testFileModulesOf(file), { + onNone: () => [], + onSome: (modules) => [[file.filepath, [...modules]] as const], + }) + ) + return entries.length > 0 ? Object.fromEntries(entries) : undefined + }) + const collectRaw = Effect.fn(SpanTaxonomy.Spans.vitestRunnerCollectRaw.name)(function*(filter: RunFilter) { const self = yield* runtime const options = yield* vitestOptions @@ -384,6 +396,7 @@ const makeRunner = Effect.fn(SpanTaxonomy.Spans.vitestRunnerMake.name)(function* const completeDryRun = Effect.fn(SpanTaxonomy.Spans.vitestRunnerCompleteDryRun.name)( function*(tests: readonly TestRunner.TestResult[]) { const mutantCoverage = yield* readMutantCoverage.pipe(Effect.mapError(asRunnerFailure('dryRun'))) + const testFileModules = yield* readTestFileModules.pipe(Effect.mapError(asRunnerFailure('dryRun'))) const globalTestInputs = yield* runtime.pipe( Effect.map((self) => self.globalTestInputs), Effect.mapError(asRunnerFailure('dryRun')), @@ -391,14 +404,22 @@ const makeRunner = Effect.fn(SpanTaxonomy.Spans.vitestRunnerMake.name)(function* yield* Effect.annotateCurrentSpan({ 'stryker.vitest.test_count': tests.length, 'stryker.vitest.has_mutant_coverage': mutantCoverage !== undefined, + 'stryker.vitest.test_file_modules_count': testFileModules === undefined + ? 0 + : Object.keys(testFileModules).length, + }) + const evidence = Option.match(Option.fromNullishOr(testFileModules), { + onNone: (): Partial => ({}), + onSome: (modules): Partial => ({ testFileModules: modules }), }) return Option.match(Option.fromNullishOr(mutantCoverage), { - onNone: (): TestRunner.DryRunResult => ({ status: 'complete', tests, globalTestInputs }), + onNone: (): TestRunner.DryRunResult => ({ status: 'complete', tests, globalTestInputs, ...evidence }), onSome: (coverage): TestRunner.DryRunResult => ({ status: 'complete', tests, mutantCoverage: coverage, globalTestInputs, + ...evidence, }), }) }, diff --git a/packages/stryker-js-vitest-runner/src/VitestRuntime.handle.ts b/packages/stryker-js-vitest-runner/src/VitestRuntime.handle.ts index 0e9012b88..53f13b8cc 100644 --- a/packages/stryker-js-vitest-runner/src/VitestRuntime.handle.ts +++ b/packages/stryker-js-vitest-runner/src/VitestRuntime.handle.ts @@ -26,6 +26,7 @@ import { screenshotFailuresOff, setSetupFiles, setupFilesOf, + testFileModulesOf as testFileModulesDriverOf, type VitestValue, } from './drivers/vitest-node.js' import { type StrykerNamespace, type TestRunnerPhase } from './VitestRunner.schema.js' @@ -236,6 +237,8 @@ export const externalErrorText = (self: VitestRuntime): string => { export const metaOf = (file: A): VitestValue => metaDriverOf(file) +export const testFileModulesOf = (file: A): Option.Option => testFileModulesDriverOf(file) + export const dedupeFilesByName = (files: readonly A[]): Record => Object.fromEntries(files.map((file) => [fileKeyOf(file), file])) diff --git a/packages/stryker-js-vitest-runner/src/drivers/vitest-node.ts b/packages/stryker-js-vitest-runner/src/drivers/vitest-node.ts index 5516c55e7..869854ae5 100644 --- a/packages/stryker-js-vitest-runner/src/drivers/vitest-node.ts +++ b/packages/stryker-js-vitest-runner/src/drivers/vitest-node.ts @@ -23,6 +23,12 @@ export const fileKeyOf = (file: A): string => textFieldOf(file, 'projectName' export const metaOf = (file: A): VitestValue => Option.getOrUndefined(propertyOf(file, 'meta')) +export const testFileModulesOf = (file: A): Option.Option => + Option.map( + Option.filter(propertyOf(metaOf(file), 'testFileModules'), Array.isArray), + (entries) => entries.filter(Predicate.isString), + ) + export const errorCodeOf = (cause: A): Option.Option => Option.filter(propertyOf(cause, 'code'), Predicate.isString) diff --git a/packages/stryker-js-vitest-runner/tests/dry-run-module-evidence.integration.test.ts b/packages/stryker-js-vitest-runner/tests/dry-run-module-evidence.integration.test.ts new file mode 100644 index 000000000..2f9b4efb8 --- /dev/null +++ b/packages/stryker-js-vitest-runner/tests/dry-run-module-evidence.integration.test.ts @@ -0,0 +1,175 @@ +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Configuration, Engine, Plugin } from '@systemfsoftware/stryker-js' +import { TestRunner } from '@systemfsoftware/stryker-js-plugin-interface' +import { strykerPlugins as vitestRunnerPlugins } from '@systemfsoftware/stryker-js-vitest-runner' +import * as Arr from 'effect/Array' +import * as Effect from 'effect/Effect' +import * as FileSystem from 'effect/FileSystem' +import * as Option from 'effect/Option' +import * as Path from 'effect/Path' + +const Feature = makeFeature({ it }) + +const PACKAGE_ROOT = decodeURIComponent(new URL('..', import.meta.url).pathname) + +const DYNAMIC_SPEC = 'src/dynamic-user.spec.ts' +const STATIC_SPEC = 'src/static-only.spec.ts' +const RUNTIME_ONLY = 'src/runtime-only.ts' + +const PROJECT_FILES: Readonly> = { + 'package.json': '{\n "name": "dynamic-evidence-fixture",\n "private": true,\n "type": "module"\n}\n', + [RUNTIME_ONLY]: 'export const value = 7\n', + [DYNAMIC_SPEC]: [ + "import { expect, test } from 'vitest'", + '', + "test('loads the runtime-only module', async () => {", + " const specifier = './runtime-only.js'", + ' const loaded = await import(/* @vite-ignore */ specifier)', + ' expect(loaded.value).toBe(7)', + '})', + '', + ].join('\n'), + [STATIC_SPEC]: [ + "import { expect, test } from 'vitest'", + '', + "test('never loads the runtime-only module', () => {", + ' expect(1).toBe(1)', + '})', + '', + ].join('\n'), +} + +interface RunOverride { + readonly pool?: 'threads' +} + +const contextFor = ( + options: Plugin.TestRunnerBuildContext['options'], + root: string, + override: RunOverride, +): Plugin.TestRunnerBuildContext => ({ + options: { + ...options, + testRunner: { + plugin: 'vitest', + options: { + related: false, + ...Option.match(Option.fromNullishOr(override.pool), { + onNone: () => ({}), + onSome: (pool) => ({ pool }), + }), + }, + }, + }, + fileDescriptions: {}, + sandboxWorkingDirectory: root, + idGenerator: { next: Effect.succeed(1) }, + retire: Effect.void, + testFiles: [], +}) + +const vitestChildRunner = (context: Plugin.TestRunnerBuildContext) => + Arr.head(vitestRunnerPlugins).pipe( + Option.map((runner) => + Plugin.makeChildProcessTestRunner({ + options: context.options, + fileDescriptions: context.fileDescriptions, + sandboxWorkingDirectory: context.sandboxWorkingDirectory, + workerEntrypoint: runner.workerEntry, + idGenerator: context.idGenerator, + }) + ), + Option.getOrElse(() => Effect.die(new Error('the vitest runner plugin descriptor is missing'))), + ) + +const withProject = ( + use: (root: string) => Effect.Effect, +): Effect.Effect => + Effect.acquireUseRelease( + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fs.realPath(yield* fs.makeTempDirectory({ prefix: 'dynamic-evidence-' })) + yield* fs.makeDirectory(path.join(root, 'src'), { recursive: true }) + yield* Effect.forEach(Object.entries(PROJECT_FILES), ([name, content]) => + fs.writeFileString(path.join(root, name), content)) + yield* fs.symlink(path.join(PACKAGE_ROOT, 'node_modules'), path.join(root, 'node_modules')) + return root + }), + use, + (root) => + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(root, { recursive: true, force: true })), + ).pipe(Effect.orDie) + +const runDryRun = ( + override: RunOverride, +): Effect.Effect< + { readonly root: string; readonly result: TestRunner.DryRunResult }, + never, + FileSystem.FileSystem | Path.Path +> => + withProject((root) => + Effect.gen(function*() { + const options = yield* Configuration.createDefaultOptions + const context = contextFor(options, root, override) + const runner = yield* Plugin.buildTestRunner(context, vitestChildRunner(context)) + const result = yield* runner.dryRun({ timeout: 60_000, coverageAnalysis: 'perTest', disableBail: true }) + return { root, result } + }) + ).pipe(Effect.provide(Engine.nodePlatformLayer), Effect.scoped, Effect.orDie) + +const evidenceOf = ( + outcome: { readonly root: string; readonly result: TestRunner.DryRunResult }, +): Effect.Effect< + { + readonly status: TestRunner.DryRunResult['status'] + readonly dynamicHasRuntimeOnly: boolean + readonly staticHasRuntimeOnly: boolean + }, + never, + Path.Path +> => + Effect.gen(function*() { + const path = yield* Path.Path + const modules = outcome.result.status === 'complete' ? outcome.result.testFileModules : undefined + const runtimeOnly = path.join(outcome.root, RUNTIME_ONLY) + return { + status: outcome.result.status, + dynamicHasRuntimeOnly: modules?.[path.join(outcome.root, DYNAMIC_SPEC)]?.includes(runtimeOnly) ?? false, + staticHasRuntimeOnly: modules?.[path.join(outcome.root, STATIC_SPEC)]?.includes(runtimeOnly) ?? false, + } + }) + +const EXPECTED = { status: 'complete' as const, dynamicHasRuntimeOnly: true, staticHasRuntimeOnly: false } + +Feature('Reporting the modules each test file evaluated during the dry run') + .withLayer(Engine.nodePlatformLayer) + .live('each scenario starts a real vitest runner worker over a real project on disk') + .body(({ scenario }) => { + scenario( + 'A runtime import is evidence for the file that ran it and for no other', + Gherkin.Do.pipe( + Given('a project whose only runtime import lives in one test file')( + 'project', + () => Effect.succeed({ dynamic: DYNAMIC_SPEC, static: STATIC_SPEC }), + ), + When('the dry run completes')('outcome', () => runDryRun({}).pipe(Effect.flatMap(evidenceOf))), + Then('only that test file names the runtime-only module')((s, expect) => expect(s.outcome).toEqual(EXPECTED)), + ), + ) + + scenario( + 'The vm runner pool reports the same evidence', + Gherkin.Do.pipe( + Given('a project whose only runtime import lives in one test file')( + 'project', + () => Effect.succeed({ dynamic: DYNAMIC_SPEC, static: STATIC_SPEC }), + ), + When('the dry run completes on the standby threads pool')( + 'outcome', + () => runDryRun({ pool: 'threads' }).pipe(Effect.flatMap(evidenceOf)), + ), + Then('only that test file names the runtime-only module')((s, expect) => expect(s.outcome).toEqual(EXPECTED)), + ), + ) + }) diff --git a/packages/stryker-js-vitest-runner/typings/vitest-setup.d.ts b/packages/stryker-js-vitest-runner/typings/vitest-setup.d.ts index 5019bebf0..11a4f6911 100644 --- a/packages/stryker-js-vitest-runner/typings/vitest-setup.d.ts +++ b/packages/stryker-js-vitest-runner/typings/vitest-setup.d.ts @@ -16,5 +16,6 @@ declare module 'vitest' { interface TaskMeta { hitCount: number | undefined mutantCoverage: Mutant.MutantCoverage | undefined + testFileModules: string[] | undefined } } From b2493e602c819e877618beff768d1a92a2045888 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 06:12:53 +0000 Subject: [PATCH 2/4] docs(repo): declare the dry-run module evidence as minor releases --- .changeset/dry-run-collects-evaluated-modules.md | 4 ++-- .changeset/dry-run-reports-evaluated-modules.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/dry-run-collects-evaluated-modules.md b/.changeset/dry-run-collects-evaluated-modules.md index e12704ded..79c5c0a46 100644 --- a/.changeset/dry-run-collects-evaluated-modules.md +++ b/.changeset/dry-run-collects-evaluated-modules.md @@ -1,5 +1,5 @@ --- -"@systemfsoftware/stryker-js-vitest-runner": patch +"@systemfsoftware/stryker-js-vitest-runner": minor --- -The dry run now reports the modules each test file evaluated, reading Vitest's per-worker module execution record at the end of every test file and returning it on the dry run's result as `testFileModules`. The evidence is gathered only on the dry run, so mutant runs pay nothing extra, and it is keyed by the absolute test file path. +The dry run now reports, as `testFileModules`, the modules each test file evaluated, including modules it imported at runtime through a computed `import()`. Mutant runs are unaffected. On Vitest setups where this information is not available, the field is left out. diff --git a/.changeset/dry-run-reports-evaluated-modules.md b/.changeset/dry-run-reports-evaluated-modules.md index b2ede4285..105be93dd 100644 --- a/.changeset/dry-run-reports-evaluated-modules.md +++ b/.changeset/dry-run-reports-evaluated-modules.md @@ -1,5 +1,5 @@ --- -"@systemfsoftware/stryker-js-plugin-interface": patch +"@systemfsoftware/stryker-js-plugin-interface": minor --- -A complete dry run can now carry `testFileModules`, a record from each test file's path to the modules that file evaluated while it ran, so an incremental run can key a test file's closure on the modules it actually loaded instead of on every file a dynamic import might reach. +A complete dry-run result can carry `testFileModules`: for each test file, the modules it evaluated while it ran. Test runners that know which modules each test file loaded can report them, so incremental runs can key a test file on what it actually loaded. From fdbd27e51a2777195c354f045e2e7ee5c0400ae6 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 06:11:11 +0000 Subject: [PATCH 3/4] feat(repo): let import closure accept dry-run module evidence ImportClosureInput gains an optional observedModules record, and a computed dynamic import no longer leaves a test file's closure open once the runner reports the modules that test file actually evaluated. Observed modules that name existing files join the closure as extra roots scanned and hashed like statically reached files; a key that is not an absolute path to an existing file, a parse error, or an unresolved static specifier still opens it. Nothing supplies the evidence yet, so the layer is inert in production Verdict-Semantics: unchanged --- .changeset/closure-evidence-modules.md | 5 + .../stryker-js/src/import-closure.cell.ts | 170 +++++++++++++++-- .../tests/import-closure.integration.test.ts | 172 +++++++++++++++++- 3 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 .changeset/closure-evidence-modules.md diff --git a/.changeset/closure-evidence-modules.md b/.changeset/closure-evidence-modules.md new file mode 100644 index 000000000..8ccfea461 --- /dev/null +++ b/.changeset/closure-evidence-modules.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js": patch +--- + +Import closure analysis now accepts the modules a dry run observed a test file load, and treats each one that names an existing file as an extra root of that test file's closure. A test file whose computed dynamic import could not be resolved statically then closes around the files it actually loaded, so an edit to one of those files still moves the mutant's verdict while an edit to an unrelated file leaves it in place. diff --git a/packages/stryker-js/src/import-closure.cell.ts b/packages/stryker-js/src/import-closure.cell.ts index 6f47f8d81..e27f1980c 100644 --- a/packages/stryker-js/src/import-closure.cell.ts +++ b/packages/stryker-js/src/import-closure.cell.ts @@ -28,6 +28,7 @@ export interface ImportClosureInput { readonly projectFiles: readonly string[] readonly testFiles: readonly string[] readonly globalInputs?: readonly string[] + readonly observedModules?: Readonly> } export interface TestFileClosureDigest { @@ -62,6 +63,14 @@ interface ModuleScan { readonly contentHash: string readonly dependencies: readonly string[] readonly open: boolean + readonly dynamicOpen: boolean +} + +type OpenMode = 'Combined' | 'Structural' + +interface ObservedEvidence { + readonly roots: readonly string[] + readonly invalid: boolean } interface Extraction { @@ -71,12 +80,12 @@ interface Extraction { interface Scan { readonly specifiers: readonly string[] - readonly open: boolean + readonly dynamicOpen: boolean } interface ScanState { readonly specifiers: HashSet.HashSet - readonly open: boolean + readonly hidden: boolean } interface Resolution { @@ -168,7 +177,7 @@ const MOCK_METHODS: Readonly> = { const LITERAL_TYPES: Readonly> = { Literal: true, StringLiteral: true } const NOTHING: Extraction = { specifier: undefined, hidden: false } -const EMPTY_STATE: ScanState = { specifiers: HashSet.empty(), open: false } +const EMPTY_STATE: ScanState = { specifiers: HashSet.empty(), hidden: false } const EXTERNAL_RESOLUTION: Resolution = { kind: 'External', file: '' } const UNRESOLVED_RESOLUTION: Resolution = { kind: 'Unresolved', file: '' } @@ -267,7 +276,7 @@ const extractionOf = (node: AstNode): Extraction => const mergeState = (state: ScanState, extraction: Extraction): ScanState => ({ specifiers: specifierState(state, extraction), - open: state.open || extraction.hidden, + hidden: state.hidden || extraction.hidden, }) const specifierState = (state: ScanState, extraction: Extraction): HashSet.HashSet => @@ -280,7 +289,7 @@ const walk = (state: ScanState, node: AstNode): ScanState => const scanProgram = (program: AstValue): Scan => { const state = isNode(program) ? walk(EMPTY_STATE, program) : EMPTY_STATE - return { specifiers: [...state.specifiers], open: state.open } + return { specifiers: [...state.specifiers], dynamicOpen: state.hidden } } const oxcModule = Effect.cached(Effect.promise(() => import('oxc-parser'))) @@ -289,7 +298,7 @@ const scanSource = Effect.fnUntraced(function*(content: string, absolute: string const oxc = yield* Effect.flatMap(oxcModule, (load) => load) const parsed = oxc.parseSync(absolute, content, { lang: language }) const scan = scanProgram(parsed.program) - return { specifiers: scan.specifiers, open: scan.open || parsed.errors.length > 0 } + return { specifiers: scan.specifiers, dynamicOpen: scan.dynamicOpen, parseFailed: parsed.errors.length > 0 } }) const extensionOf = (file: string): string => { @@ -588,6 +597,7 @@ const unparsedScan = (loaded: LoadedFile, verdictContent: string): ModuleScan => contentHash: hashOf(verdictContent), dependencies: [], open: INERT_EXTENSIONS[extensionOf(loaded.key)] !== true, + dynamicOpen: false, }) const parsedScan = Effect.fnUntraced(function*( @@ -601,7 +611,8 @@ const parsedScan = Effect.fnUntraced(function*( key: loaded.key, contentHash: hashOf(loaded.content), dependencies: dependencyFiles(resolutions), - open: scan.open || resolutions.some(opensClosure), + open: scan.parseFailed || resolutions.some(opensClosure), + dynamicOpen: scan.dynamicOpen, } }) @@ -624,13 +635,20 @@ const moduleScanOf = Effect.fnUntraced(function*( })) }) -const moduleEntry = (scan: ModuleScan): readonly [string, ImportClosureModule] => [ +const OPEN_COMBINERS: Readonly boolean>> = { + Combined: (scan) => scan.open || scan.dynamicOpen, + Structural: (scan) => scan.open, +} + +const openFor = (mode: OpenMode, scan: ModuleScan): boolean => OPEN_COMBINERS[mode](scan) + +const moduleEntry = (mode: OpenMode) => (scan: ModuleScan): readonly [string, ImportClosureModule] => [ scan.key, - { dependencies: scan.dependencies, open: scan.open }, + { dependencies: scan.dependencies, open: openFor(mode, scan) }, ] -const modulesOf = (scanned: readonly ModuleScan[]): Record => - Object.fromEntries(scanned.map(moduleEntry)) +const modulesOf = (mode: OpenMode, scanned: readonly ModuleScan[]): Record => + Object.fromEntries(scanned.map(moduleEntry(mode))) const digestLine = (scan: ModuleScan): string => `${scan.key}\u0000${scan.contentHash}` @@ -639,15 +657,20 @@ const projectDigestOf = (scanned: readonly ModuleScan[]): string => hashOf(scann const sortedKeys = (roots: Roots, files: readonly string[]): readonly string[] => [...HashSet.fromIterable(files.map((file) => keyOf(roots, file)))].sort() +const commandGlobalInputs = (roots: Roots, input: ImportClosureInput): readonly string[] => + sortedKeys(roots, input.globalInputs ?? []) + const commandOf = ( input: ImportClosureInput, roots: Roots, scanned: readonly ModuleScan[], + testFiles: readonly string[], + mode: OpenMode, ): ImportClosureCommand => ImportClosureCommand.make({ - modules: modulesOf(scanned), - globalInputs: sortedKeys(roots, input.globalInputs ?? []), - testFiles: sortedKeys(roots, input.testFiles), + modules: modulesOf(mode, scanned), + globalInputs: commandGlobalInputs(roots, input), + testFiles, }) const contentHashOf = ( @@ -734,6 +757,7 @@ const leafScanOf = Effect.fnUntraced(function*(roots: Roots, file: string) { contentHash: hashOf(loaded.content), dependencies: [], open: false, + dynamicOpen: false, } }) @@ -756,22 +780,130 @@ const hashGlobalInputsOf = Effect.fnUntraced(function*(roots: Roots, keys: reado return Arr.getSomes(scans) }) +const infoIsFile = (type: string | undefined): boolean => type === 'File' + +const statTypeOf = Effect.fnUntraced(function*(fs: FileSystem.FileSystem, path: Path.Path, value: string) { + if (!path.isAbsolute(value)) return undefined + return yield* fs.stat(value).pipe(Effect.map((info) => info.type), Effect.orElseSucceed(() => undefined)) +}) + +const observedRootOf = Effect.fnUntraced(function*(roots: Roots, value: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const type = yield* statTypeOf(fs, path, value) + return infoIsFile(type) ? Option.some(keyOf(roots, value)) : Option.none() +}) + +const observedTestEvidenceOf = Effect.fnUntraced(function*(roots: Roots, keys: readonly string[]) { + const observed = yield* Effect.forEach(keys, (key) => observedRootOf(roots, key), { concurrency: CONCURRENCY }) + return { roots: Arr.getSomes(observed), invalid: observed.some(Option.isNone) } +}) + +const observedEntries = (input: ImportClosureInput): readonly (readonly [string, readonly string[]])[] => + Object.entries(input.observedModules ?? {}) + +const observedEvidenceOf = Effect.fnUntraced(function*(roots: Roots, input: ImportClosureInput) { + const resolved = yield* Effect.forEach( + observedEntries(input), + ([testFile, keys]) => + observedTestEvidenceOf(roots, keys).pipe( + Effect.map((entry) => [keyOf(roots, testFile), entry] as const), + ), + { concurrency: CONCURRENCY }, + ) + return new Map(resolved) +}) + +const unknownKeys = (known: HashSet.HashSet, keys: readonly string[]): readonly string[] => + [...HashSet.fromIterable(keys)].filter((key) => !HashSet.has(known, key)) + +const observedScansOf = Effect.fnUntraced(function*( + roots: Roots, + files: HashSet.HashSet, + memo: MutableHashMap.MutableHashMap, + known: readonly ModuleScan[], + keys: readonly string[], +) { + const pending = unknownKeys(scannedKeysOf(known), keys) + const sourceKeys = pending.filter((key) => !isNodeModulesKey(key)) + const nodeKeys = pending.filter(isNodeModulesKey) + const sourceScans = sourceKeys.length === 0 ? [] : yield* scanReachable(roots, files, memo, sourceKeys) + const leafScans = yield* Effect.forEach( + nodeKeys, + (key) => Effect.option(leafScanOf(roots, key)), + { concurrency: CONCURRENCY }, + ) + return [...sourceScans, ...Arr.getSomes(leafScans)] +}) + +const augmentModule = (member: ImportClosureModule, entry: ObservedEvidence): ImportClosureModule => ({ + dependencies: [...HashSet.fromIterable([...member.dependencies, ...entry.roots])].sort(), + open: member.open || entry.invalid, +}) + +const augmentedModules = ( + modules: Record, + evidence: ReadonlyMap, +): Record => + [...evidence].reduce((augmented, [testFile, entry]) => { + const member = augmented[testFile] + return member === undefined ? augmented : { ...augmented, [testFile]: augmentModule(member, entry) } + }, { ...modules }) + +const evidenceCommandOf = ( + input: ImportClosureInput, + roots: Roots, + scanned: readonly ModuleScan[], + evidence: ReadonlyMap, + testFiles: readonly string[], +): ImportClosureCommand => + ImportClosureCommand.make({ + modules: augmentedModules(modulesOf('Structural', scanned), evidence), + globalInputs: commandGlobalInputs(roots, input), + testFiles, + }) + +const closuresOf = (command: ImportClosureCommand): readonly TestFileClosure[] => + Result.getOrElse(importClosure(command), (unreachable: never) => unreachable) + export const analyzeImportClosure = Effect.fnUntraced(function*( input: ImportClosureInput, ): Effect.fn.Return { const roots = yield* readRoots(input) + const testKeys = sortedKeys(roots, input.testFiles) const seeds = [...HashSet.fromIterable(input.projectFiles.map((file) => keyOf(roots, file)))] const files = HashSet.fromIterable(seeds) const memo = MutableHashMap.empty() const reachable = yield* scanReachable(roots, files, memo, seeds) const globalScans = yield* hashGlobalInputsOf(roots, globalInputKeysOf(roots, input, reachable)) - const scanned = [...reachable, ...globalScans] - const projectDigest = projectDigestOf(scanned) + const baseScanned = [...reachable, ...globalScans] + const projectDigest = projectDigestOf(baseScanned) + const baseHashes = MutableHashMap.fromIterable(baseScanned.map((scan) => [scan.key, scan.contentHash] as const)) + const evidence = yield* observedEvidenceOf(roots, input) + const unevidenced = testKeys.filter((key) => !evidence.has(key)) + const evidenced = testKeys.filter((key) => evidence.has(key)) + const baseClosures = closuresOf(commandOf(input, roots, baseScanned, unevidenced, 'Combined')) + if (evidenced.length === 0) { + return { + closures: baseClosures.map((closure) => closureWithDigest(baseHashes, projectDigest, closure)), + projectDigest, + } + } + const baseKeys = scannedKeysOf(baseScanned) + const observedScans = (yield* observedScansOf( + roots, + files, + memo, + baseScanned, + Arr.dedupe([...evidence.values()].flatMap((entry) => entry.roots)), + )).filter((scan) => !HashSet.has(baseKeys, scan.key)) + const scanned = [...baseScanned, ...observedScans] const hashes = MutableHashMap.fromIterable(scanned.map((scan) => [scan.key, scan.contentHash] as const)) - const closures = Result.getOrElse( - importClosure(commandOf(input, roots, scanned)), - (unreachable: never) => unreachable, + const evidenceClosures = closuresOf(evidenceCommandOf(input, roots, scanned, evidence, evidenced)) + const byTestFile = new Map( + [...baseClosures, ...evidenceClosures].map((closure) => [closure.testFile, closure] as const), ) + const closures = testKeys.flatMap((key) => Option.toArray(Option.fromUndefinedOr(byTestFile.get(key)))) return { closures: closures.map((closure) => closureWithDigest(hashes, projectDigest, closure)), projectDigest, diff --git a/packages/stryker-js/tests/import-closure.integration.test.ts b/packages/stryker-js/tests/import-closure.integration.test.ts index 6c45cf5c3..5df938ead 100644 --- a/packages/stryker-js/tests/import-closure.integration.test.ts +++ b/packages/stryker-js/tests/import-closure.integration.test.ts @@ -23,6 +23,7 @@ interface FixtureSpec { readonly testFiles: readonly string[] readonly globalInputs?: readonly string[] readonly changed?: FixtureFiles + readonly observedModules?: Readonly> } interface Observation { @@ -76,6 +77,40 @@ const writeFixture = (spec: FixtureSpec): Effect.Effect => Effect.ignore(FileSystem.FileSystem.pipe(Effect.flatMap((fs) => fs.remove(root, { recursive: true })))) +const OBSERVED_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ + +const observedModulesOf = ( + path: Path.Path, + root: string, + observed: Readonly> | undefined, +): { readonly observedModules?: Readonly> } => + observed === undefined + ? {} + : { + observedModules: Object.fromEntries( + Object.entries(observed).map(([testFile, keys]) => [ + path.join(root, testFile), + keys.map((key) => OBSERVED_SCHEME.test(key) ? key : path.join(root, key)), + ]), + ), + } + +interface ClosureInput { + readonly rootDir: string + readonly projectFiles: readonly string[] + readonly testFiles: readonly string[] + readonly globalInputs: readonly string[] + readonly observedModules?: Readonly> +} + +const inputOf = (path: Path.Path, root: string, spec: FixtureSpec): ClosureInput => ({ + rootDir: root, + projectFiles: Object.keys({ ...VITEST_STUB, ...spec.files }).filter((file) => !file.startsWith('node_modules/')), + testFiles: spec.testFiles.map((file) => path.join(root, file)), + globalInputs: (spec.globalInputs ?? []).map((file) => path.join(root, file)), + ...observedModulesOf(path, root, spec.observedModules), +}) + const observe = ( root: string, spec: FixtureSpec, @@ -83,15 +118,7 @@ const observe = ( Effect.gen(function*() { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const projectFiles = Object.keys({ ...VITEST_STUB, ...spec.files }).filter( - (file) => !file.startsWith('node_modules/'), - ) - const input = { - rootDir: root, - projectFiles, - testFiles: spec.testFiles.map((file) => path.join(root, file)), - globalInputs: (spec.globalInputs ?? []).map((file) => path.join(root, file)), - } + const input = inputOf(path, root, spec) const before = yield* analyzeImportClosure(input) yield* Effect.forEach( Object.entries(spec.changed ?? {}), @@ -99,7 +126,29 @@ const observe = ( { discard: true }, ) const after = yield* analyzeImportClosure(input) - return { before, after, projectFiles } + return { before, after, projectFiles: input.projectFiles } + }) + +interface EvidenceObservation { + readonly before: ImportClosureAnalysis + readonly afterObserved: ImportClosureAnalysis + readonly afterUnreached: ImportClosureAnalysis +} + +const observeEvidence = ( + root: string, + spec: FixtureSpec, +): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const input = inputOf(path, root, spec) + const before = yield* analyzeImportClosure(input) + yield* fs.writeFileString(path.join(root, 'src/plugin.ts'), 'export const plugin = 2\n') + const afterObserved = yield* analyzeImportClosure(input) + yield* fs.writeFileString(path.join(root, 'src/other.ts'), 'export const other = 1\n') + const afterUnreached = yield* analyzeImportClosure(input) + return { before, afterObserved, afterUnreached } }) const closureOf = (analysis: ImportClosureAnalysis, testFile: string) => @@ -162,6 +211,31 @@ const NON_LITERAL: FixtureSpec = { changed: { 'src/unused.ts': 'export const unused = 1\n' }, } +const DYNAMIC_EVIDENCE: FixtureSpec = { + files: { + 'src/plugin.ts': 'export const plugin = 1\n', + 'src/other.ts': 'export const other = 0\n', + 'test/plugin.test.ts': + "import { test } from 'vitest'\nconst name = '../src/plugin.js'\nexport const load = () => import(name)\ntest('plugin', () => { load })\n", + }, + testFiles: ['test/plugin.test.ts'], +} + +const WITH_EVIDENCE: FixtureSpec = { + ...DYNAMIC_EVIDENCE, + observedModules: { 'test/plugin.test.ts': ['src/plugin.ts'] }, +} + +const WITH_DATA_EVIDENCE: FixtureSpec = { + ...DYNAMIC_EVIDENCE, + observedModules: { 'test/plugin.test.ts': ['data:text/javascript,export default 1'] }, +} + +const WITH_MISSING_EVIDENCE: FixtureSpec = { + ...DYNAMIC_EVIDENCE, + observedModules: { 'test/plugin.test.ts': ['src/gone.ts'] }, +} + const LITERALS: FixtureSpec = { files: { 'src/lazy.ts': 'export const lazy = 1\n', @@ -679,4 +753,82 @@ Feature('Mapping a test file to the import closure it can reach') ), ), ) + + scenario( + 'A computed dynamic import with no observed modules opens the closure', + Gherkin.Do.pipe( + Given('a project whose test file imports a module by a computed name')( + 'root', + () => writeFixture(DYNAMIC_EVIDENCE), + ), + When('the closure is analyzed without runtime evidence')( + 'observation', + (s) => observe(s.root, DYNAMIC_EVIDENCE).pipe(Effect.ensuring(removeDirectory(s.root))), + ), + Then('the closure is open')((s, expect) => + expect({ open: openOf(s.observation.before, 'test/plugin.test.ts') }).toEqual({ open: true }) + ), + ), + ) + + scenario( + 'Observed modules from the dry run close the closure around the file they name', + Gherkin.Do.pipe( + Given('a project whose dynamic import target a dry run observed')('root', () => writeFixture(WITH_EVIDENCE)), + When('the closure is analyzed, then the observed file changes, then an unrelated file changes')( + 'observation', + (s) => observeEvidence(s.root, WITH_EVIDENCE).pipe(Effect.ensuring(removeDirectory(s.root))), + ), + Then('the observed file joins a closed closure and only its change moves the digest')( + (s, expect) => + expect({ + files: filesOf(s.observation.before, 'test/plugin.test.ts'), + open: openOf(s.observation.before, 'test/plugin.test.ts'), + observedChangeMoved: digestOf(s.observation.before, 'test/plugin.test.ts') !== + digestOf(s.observation.afterObserved, 'test/plugin.test.ts'), + unreachedChangeMoved: digestOf(s.observation.afterObserved, 'test/plugin.test.ts') !== + digestOf(s.observation.afterUnreached, 'test/plugin.test.ts'), + }).toEqual({ + files: ['src/plugin.ts', 'test/plugin.test.ts'], + open: false, + observedChangeMoved: true, + unreachedChangeMoved: false, + }), + ), + ), + ) + + scenario( + 'An observed key that is not a file path keeps the closure open', + Gherkin.Do.pipe( + Given('a project whose dynamic import target a dry run reported as a data url')( + 'root', + () => writeFixture(WITH_DATA_EVIDENCE), + ), + When('the closure is analyzed with that evidence')( + 'observation', + (s) => observe(s.root, WITH_DATA_EVIDENCE).pipe(Effect.ensuring(removeDirectory(s.root))), + ), + Then('the closure is open')((s, expect) => + expect({ open: openOf(s.observation.before, 'test/plugin.test.ts') }).toEqual({ open: true }) + ), + ), + ) + + scenario( + 'An observed key naming a file no longer on disk keeps the closure open', + Gherkin.Do.pipe( + Given('a project whose dry run observed a file since removed')( + 'root', + () => writeFixture(WITH_MISSING_EVIDENCE), + ), + When('the closure is analyzed with that evidence')( + 'observation', + (s) => observe(s.root, WITH_MISSING_EVIDENCE).pipe(Effect.ensuring(removeDirectory(s.root))), + ), + Then('the closure is open')((s, expect) => + expect({ open: openOf(s.observation.before, 'test/plugin.test.ts') }).toEqual({ open: true }) + ), + ), + ) }) From 3bffb772cd488c47fb9d667d48ebf7ee4a26c23e Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Wed, 7 Oct 2026 06:13:07 +0000 Subject: [PATCH 4/4] docs(repo): declare the closure evidence input as a minor release --- .changeset/closure-evidence-modules.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/closure-evidence-modules.md b/.changeset/closure-evidence-modules.md index 8ccfea461..3aea50098 100644 --- a/.changeset/closure-evidence-modules.md +++ b/.changeset/closure-evidence-modules.md @@ -1,5 +1,5 @@ --- -"@systemfsoftware/stryker-js": patch +"@systemfsoftware/stryker-js": minor --- -Import closure analysis now accepts the modules a dry run observed a test file load, and treats each one that names an existing file as an extra root of that test file's closure. A test file whose computed dynamic import could not be resolved statically then closes around the files it actually loaded, so an edit to one of those files still moves the mutant's verdict while an edit to an unrelated file leaves it in place. +`ImportClosure.analyzeImportClosure` accepts `observedModules`: for each test file, the modules a dry run saw it load. Each one that names an existing file joins that test file's closure. A test file that reaches a computed dynamic `import()` then closes around the files it actually loaded, instead of falling back to the whole project. A key that names no existing file keeps the closure open.