Skip to content
5 changes: 5 additions & 0 deletions .changeset/closure-dry-run-module-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@systemfsoftware/stryker-js": minor
---

A dry run now records the modules each test file evaluated, and a later incremental run uses them. A test file that loads a module only through a computed dynamic `import()` is keyed to the modules it actually loaded: an edit to one of them re-runs that test file's mutants, while an edit to any file outside them keeps their verdicts. When the runtime target is not a project file, such as a `data:` URL, the test file stays keyed to the whole project and its mutants re-run on any change.
5 changes: 5 additions & 0 deletions .changeset/closure-evidence-modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@systemfsoftware/stryker-js": minor
---

`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.
1 change: 1 addition & 0 deletions packages/stryker-js/src/dry-run-coverage.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const DryRunCoverageSchema = S.Struct({
tests: S.Array(TestRunner.TestResultSchema),
mutantCoverage: S.optional(Mutant.MutantCoverageSchema),
globalTestInputs: S.Array(S.String),
testFileModules: S.optionalKey(S.Record(S.String, S.Array(S.String))),
timeOverheadMs: S.Finite,
flakyTestIds: S.Array(S.String),
flakyMutantIds: S.Array(S.String),
Expand Down
170 changes: 151 additions & 19 deletions packages/stryker-js/src/import-closure.cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface ImportClosureInput {
readonly projectFiles: readonly string[]
readonly testFiles: readonly string[]
readonly globalInputs?: readonly string[]
readonly observedModules?: Readonly<Record<string, readonly string[]>>
}

export interface TestFileClosureDigest {
Expand Down Expand Up @@ -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 {
Expand All @@ -71,12 +80,12 @@ interface Extraction {

interface Scan {
readonly specifiers: readonly string[]
readonly open: boolean
readonly dynamicOpen: boolean
}

interface ScanState {
readonly specifiers: HashSet.HashSet<string>
readonly open: boolean
readonly hidden: boolean
}

interface Resolution {
Expand Down Expand Up @@ -168,7 +177,7 @@ const MOCK_METHODS: Readonly<Record<string, true>> = {
const LITERAL_TYPES: Readonly<Record<string, true>> = { 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: '' }

Expand Down Expand Up @@ -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<string> =>
Expand All @@ -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')))
Expand All @@ -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 => {
Expand Down Expand Up @@ -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*(
Expand All @@ -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,
}
})

Expand All @@ -624,13 +635,20 @@ const moduleScanOf = Effect.fnUntraced(function*(
}))
})

const moduleEntry = (scan: ModuleScan): readonly [string, ImportClosureModule] => [
const OPEN_COMBINERS: Readonly<Record<OpenMode, (scan: ModuleScan) => 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<string, ImportClosureModule> =>
Object.fromEntries(scanned.map(moduleEntry))
const modulesOf = (mode: OpenMode, scanned: readonly ModuleScan[]): Record<string, ImportClosureModule> =>
Object.fromEntries(scanned.map(moduleEntry(mode)))

const digestLine = (scan: ModuleScan): string => `${scan.key}\u0000${scan.contentHash}`

Expand All @@ -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 = (
Expand Down Expand Up @@ -734,6 +757,7 @@ const leafScanOf = Effect.fnUntraced(function*(roots: Roots, file: string) {
contentHash: hashOf(loaded.content),
dependencies: [],
open: false,
dynamicOpen: false,
}
})

Expand All @@ -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<string>()
})

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<string>, keys: readonly string[]): readonly string[] =>
[...HashSet.fromIterable(keys)].filter((key) => !HashSet.has(known, key))

const observedScansOf = Effect.fnUntraced(function*(
roots: Roots,
files: HashSet.HashSet<string>,
memo: MutableHashMap.MutableHashMap<string, Resolution>,
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<string, ImportClosureModule>,
evidence: ReadonlyMap<string, ObservedEvidence>,
): Record<string, ImportClosureModule> =>
[...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<string, ObservedEvidence>,
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<ImportClosureAnalysis, PlatformError, FileSystem.FileSystem | Path.Path> {
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<string, Resolution>()
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,
Expand Down
1 change: 1 addition & 0 deletions packages/stryker-js/src/plan-request.cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ const planProject = (
globalTestInputs: Option.map(coverage, (present) => [...present.globalTestInputs]).pipe(
Option.getOrElse((): ReadonlyArray<string> => []),
),
observedModules: Option.getOrUndefined(Option.map(coverage, (present) => present.testFileModules)),
originalFileOf: (file) => path.resolve(file),
})
const mutants = [
Expand Down
6 changes: 6 additions & 0 deletions packages/stryker-js/src/run/dry-run-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import * as Option from 'effect/Option'
import type { DryRunCoverage } from '../dry-run-coverage.schema.js'
import type { TestCoverage } from '../test-coverage.schema.js'

export const testFileModulesFieldOf = (
modules: Readonly<Record<string, readonly string[]>> | undefined,
): { readonly testFileModules?: Readonly<Record<string, readonly string[]>> } =>
modules === undefined ? {} : { testFileModules: modules }

const testsByIdOf = (result: Readonly<TestRunner.CompleteDryRunResult>) =>
MutableHashMap.fromIterable(result.tests.map((test) => [test.id, test] as const))

Expand Down Expand Up @@ -85,6 +90,7 @@ export const reusedTestCoverage = (coverage: DryRunCoverage): TestCoverage =>
status: 'complete',
tests: [...coverage.tests],
globalTestInputs: [...coverage.globalTestInputs],
...testFileModulesFieldOf(coverage.testFileModules),
...(coverage.mutantCoverage === undefined ? {} : { mutantCoverage: coverage.mutantCoverage }),
},
dryRunCoverage: coverage,
Expand Down
Loading
Loading