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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/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.
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
Loading
Loading