From 865c917cb6e7fe221b5d57a2f649df213ed32f81 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Sat, 10 Oct 2026 02:24:20 +0000 Subject: [PATCH 1/5] refactor(stryker-js)!: make Project read-only and return it from sandbox preprocessors Project.files and filesToMutate are ReadonlyMaps (insertion order kept, as MutableHashMap iterated). Sandbox preprocessors take a Project and return a new one; acquireSandbox writes the folded result and the caller's Project is untouched. The tsconfig rewrite moves out of Sandbox.blueprint.ts into rewriteSandboxTsconfig (escape, rewrite and follow decision) and sandboxTsconfigCell (read, parse, follow, write). --- .changeset/immutable-project.md | 5 + packages/stryker-js/src/Project.schema.ts | 91 +++++- packages/stryker-js/src/Sandbox.blueprint.ts | 283 ++---------------- packages/stryker-js/src/Sandbox.schema.ts | 108 +++++-- .../src/mutation-reporting.service.ts | 2 +- packages/stryker-js/src/read-project.cell.ts | 32 +- .../src/rewrite-sandbox-tsconfig.workflow.ts | 135 +++++++++ packages/stryker-js/src/run/dry-run-choice.ts | 3 +- packages/stryker-js/src/run/dry-run.cell.ts | 3 +- .../src/run/incremental-reuse.cell.ts | 2 +- packages/stryker-js/src/run/instrument.ts | 39 ++- .../src/run/mutation-test-plan.cell.ts | 2 +- packages/stryker-js/src/run/prepare.ts | 10 +- .../stryker-js/src/sandbox-tsconfig.cell.ts | 107 +++++++ 14 files changed, 481 insertions(+), 341 deletions(-) create mode 100644 .changeset/immutable-project.md create mode 100644 packages/stryker-js/src/rewrite-sandbox-tsconfig.workflow.ts create mode 100644 packages/stryker-js/src/sandbox-tsconfig.cell.ts diff --git a/.changeset/immutable-project.md b/.changeset/immutable-project.md new file mode 100644 index 000000000..484b3bb5b --- /dev/null +++ b/.changeset/immutable-project.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js": major +--- + +`Engine.Project` is now read-only. Its `files` and `filesToMutate` are `ReadonlyMap` instead of `MutableHashMap`, so read them with `.get`, `.keys()`, `.values()` and `.size`. diff --git a/packages/stryker-js/src/Project.schema.ts b/packages/stryker-js/src/Project.schema.ts index abd293b82..627d76fad 100644 --- a/packages/stryker-js/src/Project.schema.ts +++ b/packages/stryker-js/src/Project.schema.ts @@ -1,5 +1,6 @@ import type { Instrument } from '@systemfsoftware/stryker-js-instrumenter' -import type * as MutableHashMap from 'effect/MutableHashMap' +import * as Arr from 'effect/Array' +import { dual } from 'effect/Function' import type { IncrementalReport } from './IncrementalReport.schema.js' @@ -14,6 +15,90 @@ export interface Project { readonly fileDescriptions: Instrument.FileDescriptions readonly incrementalReport: IncrementalReport | undefined readonly testFiles: readonly string[] - readonly files: MutableHashMap.MutableHashMap - readonly filesToMutate: MutableHashMap.MutableHashMap + readonly files: ReadonlyMap + readonly filesToMutate: ReadonlyMap +} + +const replacedBy = ( + files: ReadonlyMap, + updates: ReadonlyArray, +): ReadonlyMap => + new Map([...files, ...Arr.map(updates, (update) => [update.name, update] as const)]) + +export const withPreprocessedFiles: { + (updates: ReadonlyArray): (project: Project) => Project + (project: Project, updates: ReadonlyArray): Project +} = dual(2, (project: Project, updates: ReadonlyArray): Project => ({ + ...project, + files: replacedBy(project.files, updates), + filesToMutate: replacedBy( + project.filesToMutate, + Arr.filter(updates, (update) => project.filesToMutate.has(update.name)), + ), +})) + +if (import.meta.vitest !== void 0) { + const { it } = await import('@systemfsoftware/vitest') + const S = await import('effect/Schema') + const Option = await import('effect/Option') + + const DrawnFile = S.Struct({ + name: S.String.pipe(S.check(S.isMaxLength(4))), + mutate: S.Boolean, + content: S.UndefinedOr(S.String), + originalContent: S.UndefinedOr(S.String), + }) + const DrawnFiles = S.Array(S.Tuple([DrawnFile, S.Boolean])).pipe(S.check(S.isMaxLength(12))) + const DrawnUpdates = S.Array(S.Tuple([S.Int, S.UndefinedOr(S.String)])).pipe(S.check(S.isMaxLength(12))) + + const projectOf = (drawn: typeof DrawnFiles.Type): Project => { + const unique = Arr.dedupeWith(drawn, ([left], [right]) => left.name === right.name) + return { + fileDescriptions: {}, + incrementalReport: undefined, + testFiles: [], + files: new Map(Arr.map(unique, ([file]) => [file.name, file] as const)), + filesToMutate: new Map( + Arr.map(Arr.filter(unique, ([, mutated]) => mutated), ([file]) => [file.name, file] as const), + ), + } + } + + const updatesOf = (project: Project, drawn: typeof DrawnUpdates.Type): ReadonlyArray => { + const existing = [...project.files.values()] + return Arr.flatMap( + drawn, + ([index, content]) => + Option.match(Arr.get(existing, Math.abs(index) % Math.max(existing.length, 1)), { + onNone: () => [], + onSome: (file) => [{ ...file, content }], + }), + ) + } + + const expectedOf = (updates: ReadonlyArray, original: ProjectFile): ProjectFile => + Option.getOrElse(Arr.findLast(updates, (update) => update.name === original.name), () => original) + + const replacesExactlyTheUpdatedInPlace = ( + before: ReadonlyMap, + after: ReadonlyMap, + updates: ReadonlyArray, + ): boolean => + after.size === before.size && + Arr.every( + Arr.zip([...before], [...after]), + ([[name, original], [afterName, afterFile]]) => afterName === name && afterFile === expectedOf(updates, original), + ) + + it.prop( + '∀updates_PreprocessedFiles_≡LastUpdateWinsInPlaceWithinExistingKeys', + { of: [DrawnFiles, DrawnUpdates], subject: withPreprocessedFiles }, + (subject, [files, drawnUpdates]) => { + const project = projectOf(files) + const updates = updatesOf(project, drawnUpdates) + const merged = subject(project, updates) + return replacesExactlyTheUpdatedInPlace(project.files, merged.files, updates) && + replacesExactlyTheUpdatedInPlace(project.filesToMutate, merged.filesToMutate, updates) + }, + ) } diff --git a/packages/stryker-js/src/Sandbox.blueprint.ts b/packages/stryker-js/src/Sandbox.blueprint.ts index 4b8defedb..e283de6f2 100644 --- a/packages/stryker-js/src/Sandbox.blueprint.ts +++ b/packages/stryker-js/src/Sandbox.blueprint.ts @@ -1,17 +1,14 @@ -import type { JsonValue } from '@std/jsonc' -import { parse } from '@std/jsonc' import { Blueprint } from '@systemfsoftware/effect-cell-types' import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' -import { ErrorText, Format, Instrument } from '@systemfsoftware/stryker-js-instrumenter' -import { Mutant, type Options } from '@systemfsoftware/stryker-js-plugin-interface' -import { Boolean, Predicate, Schema as S } from 'effect' +import { type Format, Instrument } from '@systemfsoftware/stryker-js-instrumenter' +import type { Options } from '@systemfsoftware/stryker-js-plugin-interface' +import { Boolean } from 'effect' import * as Config from 'effect/Config' import * as Context from 'effect/Context' import * as Effect from 'effect/Effect' import * as FileSystem from 'effect/FileSystem' import * as Layer from 'effect/Layer' import * as Match from 'effect/Match' -import * as MutableHashMap from 'effect/MutableHashMap' import * as Option from 'effect/Option' import * as Path from 'effect/Path' import type { PlatformError } from 'effect/PlatformError' @@ -23,9 +20,9 @@ import * as Stream from 'effect/Stream' import { matchesFile } from './FileMatcher.js' import { FileMatcher } from './matching.schema.js' import { ProjectFiles } from './project-files.service.js' -import type { Project, ProjectFile } from './Project.schema.js' +import { type Project, withPreprocessedFiles } from './Project.schema.js' +import { sandboxTsconfigCell } from './sandbox-tsconfig.cell.js' import { make as makeHandle, type SandboxHandle } from './Sandbox.handle.js' -import { ExtendsArraySchema, type TSConfig, TsConfigSchema } from './Sandbox.schema.js' import { StrykerError } from './stryker-error.schema.js' export interface MakeSandboxInput { @@ -39,50 +36,23 @@ export interface MakeSandboxInput { export type FilePreprocessor = ( project: Project, -) => Effect.Effect +) => Effect.Effect -const combinePreprocessors = (preprocessors: readonly FilePreprocessor[]) => (project: Project) => - Effect.forEach(preprocessors, (pre) => pre(project), { discard: true }) +const combinePreprocessors = (preprocessors: readonly FilePreprocessor[]): FilePreprocessor => (project) => + Effect.reduce(preprocessors, () => project, (current, preprocess) => preprocess(current)) -const mergeUpdatedFile = (project: Project, updated: ProjectFile): void => { - MutableHashMap.set(project.files, updated.name, updated) - Option.match(MutableHashMap.get(project.filesToMutate, updated.name), { - onNone: () => undefined, - onSome: () => MutableHashMap.set(project.filesToMutate, updated.name, updated), - }) -} - -const updateOf = (updated: ProjectFile | Option.Option | undefined) => - Match.value(updated).pipe( - Match.when(undefined, () => undefined), - Match.when(Option.isOption, (option) => Option.getOrUndefined(option)), - Match.orElse((file) => file), - ) - -const mergeUpdatedInto = (project: Project) => (updated: ProjectFile | Option.Option | undefined) => { - const file = updateOf(updated) - Option.match(Option.fromUndefinedOr(file), { - onNone: () => undefined, - onSome: (present) => mergeUpdatedFile(project, present), - }) -} - -const makeDisableTypeChecksPreprocessor = ( - options: Options.StrykerOptions, - registry: Format.FormatRegistry, - impl: typeof Instrument.disableTypeChecks, -) => +const makeDisableTypeChecksPreprocessor = (options: Options.StrykerOptions, registry: Format.FormatRegistry) => Effect.fn(SpanTaxonomy.Spans.sandboxPreprocessDisableTypeChecks.name)(function*(project: Project) { const pathService = yield* Path.Path const files = yield* ProjectFiles const matcher = FileMatcher.make({ pattern: options.disableTypeChecks, allowHiddenFiles: true }) - const matched = [...project.files].filter(([name]) => matchesFile(matcher, pathService, name)) - const instrumented = yield* files.readAll(matched.map(([, file]) => file)) + const matched = [...project.files.values()].filter((file) => matchesFile(matcher, pathService, file.name)) + const instrumented = yield* files.readAll(matched) const updates = yield* Effect.forEach( instrumented, ([file, content]) => Effect.map( - impl({ content, mutate: file.mutate, name: file.name }, registry).pipe( + Instrument.disableTypeChecks({ content, mutate: file.mutate, name: file.name }, registry).pipe( Effect.map((instrumentedFile) => instrumentedFile.content), Effect.mapError((cause) => StrykerError.make({ message: 'disableTypeChecks failed', cause })), ), @@ -90,222 +60,19 @@ const makeDisableTypeChecksPreprocessor = ( ), { concurrency: 'unbounded' }, ) - updates.forEach(mergeUpdatedInto(project)) + return withPreprocessedFiles(project, updates) }) -const parseJsonText = (jsonText: string): Effect.Effect => - Effect.try({ - try: () => parse(jsonText.replace(/^\uFEFF/, '')), - catch: (cause) => Option.getOrElse(Option.map(ErrorText.errorTextOf(cause), (rendered) => rendered.text), () => ''), - }) - -const tsConfigShapeOf = (parsed: JsonValue): Option.Option => - Option.filter(Option.some(parsed), S.is(TsConfigSchema)) - -const parseTsConfig = (fileName: string, jsonText: string): Effect.Effect => - Effect.flatMap(parseJsonText(jsonText), (parsed) => - Effect.fromOption( - tsConfigShapeOf(parsed), - () => `parsed to ${JSON.stringify(parsed)}, which does not match the tsconfig shape this package consumes`, - )) - -const makeTSConfigPreprocessor = (options: Options.StrykerOptions, basePath: string): FilePreprocessor => { - const rewriteReferenceOrKeep = (reference: string, tsconfigFileName: string, pathService: Path.Path) => - Match.value(tryRewriteReference(reference, tsconfigFileName, pathService, basePath)).pipe( - Match.when(Predicate.isString, (rewritten) => rewritten), - Match.orElse(() => reference), - ) - - const isStringArray = (value: unknown): value is readonly string[] => - Array.isArray(value) && value.every((entry) => typeof entry === 'string') - - const rewriteFileArrayProperty = ( - config: TSConfig, - tsconfigFileName: string, - prop: 'exclude' | 'files' | 'include', - pathService: Path.Path, - ): void => { - const value = config[prop] - Option.match(Option.filter(Option.fromUndefinedOr(value), isStringArray), { - onSome: (entries) => { - config[prop] = entries.map((entry) => rewriteReferenceOrKeep(entry, tsconfigFileName, pathService)) - }, - onNone: () => undefined, - }) - } - - const rewriteFileArrayProperties = Effect.fn(SpanTaxonomy.Spans.sandboxTsconfigRewriteFileArrays.name)(function*( - config: TSConfig, - tsconfigFile: ProjectFile, - tsconfigFileName: string, - pathService: Path.Path, - ) { - rewriteFileArrayProperty(config, tsconfigFileName, 'include', pathService) - rewriteFileArrayProperty(config, tsconfigFileName, 'exclude', pathService) - rewriteFileArrayProperty(config, tsconfigFileName, 'files', pathService) - const rewritten = yield* S.encodeEffect(S.fromJsonString(TsConfigSchema, { space: 2 }))( - config, - ).pipe(Effect.orDie) - Object.assign(tsconfigFile, { content: rewritten }) - }) - - const rewriteTSConfigFile = ( - project: Project, - tsconfigFileName: string, - pathService: Path.Path, - ): Effect.Effect => - Effect.flatMap(ProjectFiles, (files) => - Option.match(MutableHashMap.get(project.files, tsconfigFileName), { - onNone: () => Effect.void, - onSome: (tsconfigFile) => - Effect.flatMap( - files.read(tsconfigFile), - (content) => - Effect.matchEffect(parseTsConfig(tsconfigFileName, content), { - onFailure: (reason) => - Effect.logWarning( - `Could not rewrite tsconfig file "${tsconfigFileName}": ${reason}. Its extends, project references, and file array properties were not rewritten for the sandbox, so this file still points at paths outside it.`, - ), - onSuccess: (config) => - Effect.all( - [ - rewriteExtends(config, tsconfigFileName, pathService, project), - rewriteProjectReferences(config, tsconfigFileName, pathService, project), - ], - { discard: true }, - ).pipe( - Effect.flatMap(() => - rewriteFileArrayProperties(config, tsconfigFile, tsconfigFileName, pathService) - ), - ), - }), - ), - })) - - const rewriteExtendsEntry = ( - config: TSConfig, - extend: string, - tsconfigFileName: string, - pathService: Path.Path, - project: Project, - ): Effect.Effect => - Match.value(tryRewriteReference(extend, tsconfigFileName, pathService, basePath)).pipe( - Match.when(Predicate.isString, (rewritten) => Effect.succeed(rewritten)), - Match.orElse(() => - rewriteTSConfigFile( - project, - pathService.resolve(pathService.dirname(tsconfigFileName), extend), - pathService, - ).pipe(Effect.as(extend)) - ), - ) - - const rewriteSingleExtends = ( - config: TSConfig, - extend: string, - tsconfigFileName: string, - pathService: Path.Path, - project: Project, - ): Effect.Effect => - Effect.flatMap(rewriteExtendsEntry(config, extend, tsconfigFileName, pathService, project), (rewritten) => { - config.extends = rewritten - return Effect.void - }) - - const rewriteExtendsArray = ( - config: TSConfig, - extendEntries: readonly string[], - tsconfigFileName: string, - pathService: Path.Path, - project: Project, - ): Effect.Effect => - Effect.forEach(extendEntries, (entry) => rewriteExtendsEntry(config, entry, tsconfigFileName, pathService, project)) - .pipe( - Effect.flatMap((rewritten) => { - config.extends = rewritten - return Effect.void - }), - ) - - const rewriteExtends = ( - config: TSConfig, - tsconfigFileName: string, - pathService: Path.Path, - project: Project, - ): Effect.Effect => - Match.value(config.extends).pipe( - Match.when(Predicate.isString, (extend) => - rewriteSingleExtends(config, extend, tsconfigFileName, pathService, project)), - Match.when(S.is(ExtendsArraySchema), (extendEntries) => - rewriteExtendsArray(config, extendEntries, tsconfigFileName, pathService, project)), - Match.orElse(() => - Effect.void - ), - ) - - const referencedTsConfigPath = (referencePath: string) => - Boolean.match(referencePath.endsWith('.json'), { - onTrue: () => referencePath, - onFalse: () => `${referencePath}/tsconfig.json`, - }) - - const rewriteReference = ( - ref: { path: string }, - originTSConfigFileName: string, - pathService: Path.Path, - project: Project, - ): Effect.Effect => - Match.value(tryRewriteReference(ref.path, originTSConfigFileName, pathService, basePath)).pipe( - Match.when(Predicate.isString, (rewritten) => { - ref.path = rewritten - return Effect.void - }), - Match.orElse(() => - rewriteTSConfigFile( - project, - pathService.resolve(pathService.dirname(originTSConfigFileName), referencedTsConfigPath(ref.path)), - pathService, - ) +const makeTSConfigPreprocessor = (options: Options.StrykerOptions, basePath: string): FilePreprocessor => (project) => + Boolean.match(options.inPlace, { + onTrue: () => Effect.succeed(project), + onFalse: () => + Effect.flatMap( + Path.Path, + (pathService) => + sandboxTsconfigCell.run({ project, fileName: pathService.resolve(options.tsconfigFile), basePath }), ), - ) - - const rewriteProjectReferences = ( - config: TSConfig, - originTSConfigFileName: string, - pathService: Path.Path, - project: Project, - ): Effect.Effect => - Option.match(Option.fromUndefinedOr(config.references), { - onNone: () => Effect.void, - onSome: (references) => - Effect.forEach(references, (ref) => rewriteReference(ref, originTSConfigFileName, pathService, project)).pipe( - Effect.asVoid, - ), - }) - - return (project) => - Boolean.match(options.inPlace, { - onTrue: () => Effect.void, - onFalse: () => - Effect.flatMap(Path.Path, (pathService) => - rewriteTSConfigFile(project, pathService.resolve(options.tsconfigFile), pathService)), - }) -} - -const tryRewriteReference = ( - reference: string, - originTSConfigFileName: string, - pathService: Path.Path, - basePath: string, -) => { - const fileName = pathService.resolve(pathService.dirname(originTSConfigFileName), reference) - const relativeToSandbox = pathService.relative(basePath, fileName) - return Boolean.match(relativeToSandbox.startsWith('..'), { - onTrue: () => - ['..', '..', Option.getOrElse(S.decodeOption(Mutant.CanonicalFileName)(reference), () => reference)].join('/'), - onFalse: () => false as const, }) -} const createPreprocessor = ( options: Options.StrykerOptions, @@ -313,7 +80,7 @@ const createPreprocessor = ( registry: Format.FormatRegistry, ): FilePreprocessor => combinePreprocessors([ - makeDisableTypeChecksPreprocessor(options, registry, Instrument.disableTypeChecks), + makeDisableTypeChecksPreprocessor(options, registry), makeTSConfigPreprocessor(options, basePath), ]) @@ -666,13 +433,13 @@ const acquireSandbox = Effect.fn(SpanTaxonomy.Spans.sandboxAcquire.name)(functio createPreprocessor(options, basePath, spec.formatRegistry), ...spec.preprocessors, ]) - yield* preprocessor(project).pipe( + const preprocessed = yield* preprocessor(project).pipe( Effect.mapError((cause) => StrykerError.make({ message: 'Sandbox preprocessor failed', cause })), ) const files = yield* ProjectFiles const entries = yield* Boolean.match(options.inPlace, { - onTrue: () => files.writeAllInPlace([...project.files], { backupDirectory, basePath }), - onFalse: () => files.writeAllToSandbox([...project.files], { workingDirectory, basePath }), + onTrue: () => files.writeAllInPlace(preprocessed.files, { backupDirectory, basePath }), + onFalse: () => files.writeAllToSandbox(preprocessed.files, { workingDirectory, basePath }), }) const fileMap = toFileMap(entries) diff --git a/packages/stryker-js/src/Sandbox.schema.ts b/packages/stryker-js/src/Sandbox.schema.ts index 130b960d9..bebeed992 100644 --- a/packages/stryker-js/src/Sandbox.schema.ts +++ b/packages/stryker-js/src/Sandbox.schema.ts @@ -1,40 +1,92 @@ -/** - * Sandbox.schema — declarations for the Sandbox capability. - * - * Holds the tsconfig shape that the sandbox preprocessor reads and rewrites. - */ - -import { Schema as S } from 'effect' +import * as Arr from 'effect/Array' +import * as Match from 'effect/Match' +import * as Option from 'effect/Option' +import * as Predicate from 'effect/Predicate' +import * as S from 'effect/Schema' const JsonRecord = S.Record(S.String, S.Unknown) -/** - * The tsconfig shape this package reads and rewrites in the sandbox. - * - * The index signature keeps every key the schema does not declare — including - * `compilerOptions` and `$schema` — so unknown keys survive the write-back untouched. - * `S.mutableKey` keeps the fields the preprocessor rewrites writable (v4's `mutable` - * only covers arrays), and `S.mutable` keeps the parsed arrays themselves mutable so - * `TSConfigPreprocessor` can assign rewritten values back onto the config. - */ +const Entries = S.String.pipe(S.Array, S.optionalKey) + export const TsConfigSchema = S.StructWithRest( S.Struct({ - extends: S.mutableKey(S.optional(S.Union([S.String, S.Array(S.String)]))), - references: S.mutableKey( - S.optional( - S.mutable( - S.Array(S.StructWithRest(S.Struct({ path: S.mutableKey(S.String) }), [JsonRecord])), - ), - ), - ), - files: S.mutableKey(S.optional(S.mutable(S.Array(S.String)))), - include: S.mutableKey(S.optional(S.mutable(S.Array(S.String)))), - exclude: S.mutableKey(S.optional(S.mutable(S.Array(S.String)))), + extends: S.optionalKey(S.Union([S.String, S.Array(S.String)])), + references: S.StructWithRest(S.Struct({ path: S.String }), [JsonRecord]).pipe(S.Array, S.optionalKey), + files: Entries, + include: Entries, + exclude: Entries, }), [JsonRecord], ) export type TSConfig = S.Schema.Type -/** A tsconfig `extends` entry list: the array form of `extends`. */ export const ExtendsArraySchema = S.Array(S.String) + +const entriesOf = (entries: ReadonlyArray | undefined): ReadonlyArray => entries ?? [] + +export const extendsEntriesOf = (config: TSConfig): ReadonlyArray => + Match.value(config.extends).pipe( + Match.when(Predicate.isUndefined, () => []), + Match.when(Predicate.isString, (entry) => [entry]), + Match.when(S.is(ExtendsArraySchema), (entries) => entries), + Match.exhaustive, + ) + +export const referencePathsOf = (config: TSConfig): ReadonlyArray => + Option.match(Option.fromUndefinedOr(config.references), { + onNone: () => [], + onSome: (references) => Arr.map(references, (reference) => reference.path), + }) + +export const referencedEntriesOf = (config: TSConfig): ReadonlyArray => [ + ...entriesOf(config.include), + ...entriesOf(config.exclude), + ...entriesOf(config.files), + ...extendsEntriesOf(config), + ...referencePathsOf(config), +] + +if (import.meta.vitest !== void 0) { + const { it } = await import('@systemfsoftware/vitest') + + const Drawn = S.Array(S.String).pipe(S.check(S.isMaxLength(6))) + const DrawnExtends = S.Union([S.String, Drawn]) + const DrawnConfig = S.Struct({ + include: S.optionalKey(Drawn), + exclude: S.optionalKey(Drawn), + files: S.optionalKey(Drawn), + extends: S.optionalKey(DrawnExtends), + references: S.optionalKey(Drawn), + }) + type DrawnConfig = typeof DrawnConfig.Type + + const referencesOf = (paths: ReadonlyArray | undefined): Pick => + Option.match(Option.fromUndefinedOr(paths), { + onNone: () => ({}), + onSome: (present) => ({ references: Arr.map(present, (path) => ({ path, prepend: true })) }), + }) + + const configOf = ({ references, ...rest }: DrawnConfig): TSConfig => ({ + ...rest, + ...referencesOf(references), + compilerOptions: { strict: true }, + }) + + const extendsListOf = (drawn: DrawnConfig['extends']): ReadonlyArray => [drawn ?? []].flat() + + const sameSequence = (left: ReadonlyArray, right: ReadonlyArray): boolean => + left.length === right.length && Arr.every(Arr.zip(left, right), ([entry, other]) => entry === other) + + it.prop( + '∀config_ExtendsEntries_≡EveryExtendsEntryInOrder', + { of: [DrawnConfig], subject: extendsEntriesOf }, + (subject, [drawn]) => sameSequence(subject(configOf(drawn)), extendsListOf(drawn.extends)), + ) + + it.prop( + '∀config_ReferencePaths_≡EveryReferencePathInOrder', + { of: [DrawnConfig], subject: referencePathsOf }, + (subject, [drawn]) => sameSequence(subject(configOf(drawn)), entriesOf(drawn.references)), + ) +} diff --git a/packages/stryker-js/src/mutation-reporting.service.ts b/packages/stryker-js/src/mutation-reporting.service.ts index 6c56b643a..7fedc2125 100644 --- a/packages/stryker-js/src/mutation-reporting.service.ts +++ b/packages/stryker-js/src/mutation-reporting.service.ts @@ -271,7 +271,7 @@ const uniqueNames = (names: readonly (string | undefined)[]): readonly string[] const partitionByFile = (files: Project['files'], fileNames: readonly string[]) => { const [present, missing] = Arr.separate( Arr.map(fileNames, (fileName) => - Option.match(MutableHashMap.get(files, fileName), { + Option.match(Option.fromUndefinedOr(files.get(fileName)), { onNone: () => Result.fail(fileName), onSome: (file) => Result.succeed(file), })), diff --git a/packages/stryker-js/src/read-project.cell.ts b/packages/stryker-js/src/read-project.cell.ts index 8ae8b7173..69bbae7c2 100644 --- a/packages/stryker-js/src/read-project.cell.ts +++ b/packages/stryker-js/src/read-project.cell.ts @@ -7,7 +7,6 @@ import * as Effect from 'effect/Effect' import * as Equivalence from 'effect/Equivalence' import * as FileSystem from 'effect/FileSystem' import * as Match from 'effect/Match' -import * as MutableHashMap from 'effect/MutableHashMap' import * as Option from 'effect/Option' import * as Path from 'effect/Path' import type { PlatformError } from 'effect/PlatformError' @@ -424,29 +423,26 @@ export interface ReadProjectDone { readonly incrementalReportDiscard?: IncrementalReportDiscard | undefined } -const addProjectFile = ( - files: MutableHashMap.MutableHashMap, - filesToMutate: MutableHashMap.MutableHashMap, - name: string, - desc: { readonly mutate: Instrument.MutateDescription }, -): void => { - const file: ProjectFile = { name, mutate: desc.mutate, content: undefined, originalContent: undefined } - MutableHashMap.set(files, name, file) - const settable = [filesToMutate].filter(() => desc.mutate !== false) - settable.forEach((target) => MutableHashMap.set(target, name, file)) - const removable = [filesToMutate].filter(() => desc.mutate === false) - removable.forEach((target) => MutableHashMap.remove(target, name)) -} +const projectFileOf = (name: string, desc: { readonly mutate: Instrument.MutateDescription }): ProjectFile => ({ + name, + mutate: desc.mutate, + content: undefined, + originalContent: undefined, +}) const makeProject = ( fileDescriptions: Instrument.FileDescriptions, incrementalReport?: IncrementalReport, testFiles: readonly string[] = [], ): Project => { - const files: MutableHashMap.MutableHashMap = MutableHashMap.empty() - const filesToMutate: MutableHashMap.MutableHashMap = MutableHashMap.empty() - Object.entries(fileDescriptions).forEach(([name, desc]) => addProjectFile(files, filesToMutate, name, desc)) - return { fileDescriptions, incrementalReport, testFiles, files, filesToMutate } + const files = Object.entries(fileDescriptions).map(([name, desc]) => projectFileOf(name, desc)) + return { + fileDescriptions, + incrementalReport, + testFiles, + files: new Map(files.map((file) => [file.name, file] as const)), + filesToMutate: new Map(files.filter((file) => file.mutate !== false).map((file) => [file.name, file] as const)), + } } const projectOf = ({ diff --git a/packages/stryker-js/src/rewrite-sandbox-tsconfig.workflow.ts b/packages/stryker-js/src/rewrite-sandbox-tsconfig.workflow.ts new file mode 100644 index 000000000..1c2b35e27 --- /dev/null +++ b/packages/stryker-js/src/rewrite-sandbox-tsconfig.workflow.ts @@ -0,0 +1,135 @@ +import { Workflow } from '@systemfsoftware/effect-cell-types' +import * as Arr from 'effect/Array' +import * as Boolean from 'effect/Boolean' +import * as HashMap from 'effect/HashMap' +import * as Match from 'effect/Match' +import * as Option from 'effect/Option' +import * as Predicate from 'effect/Predicate' +import * as Record from 'effect/Record' +import * as Result from 'effect/Result' +import * as S from 'effect/Schema' + +import { + ExtendsArraySchema, + extendsEntriesOf, + referencePathsOf, + type TSConfig, + TsConfigSchema, +} from './Sandbox.schema.js' + +const RewriteSandboxTsconfigTypeId: unique symbol = Symbol.for('@systemfsoftware/stryker-js/RewriteSandboxTsconfig') +type RewriteSandboxTsconfigTypeId = typeof RewriteSandboxTsconfigTypeId + +export const TsconfigMissing = S.TaggedStruct('TsconfigMissing', {}) + +export const TsconfigUnparsable = S.TaggedStruct('TsconfigUnparsable', { reason: S.String }) + +export const TsconfigParsed = S.TaggedStruct('TsconfigParsed', { + config: TsConfigSchema, + relativeToBasePath: S.HashMap(S.String, S.String), +}) + +export class RewriteSandboxTsconfigCommand + extends S.TaggedClass()('RewriteSandboxTsconfigCommand', { + tsconfig: S.Union([TsconfigMissing, TsconfigUnparsable, TsconfigParsed]), + }) +{ + static readonly [Workflow.InstrumentationBrand] = {} as const +} + +export class TsconfigSkipped extends S.TaggedClass()('TsconfigSkipped', {}) { + readonly [RewriteSandboxTsconfigTypeId] = RewriteSandboxTsconfigTypeId +} + +export class TsconfigKept extends S.TaggedClass()('TsconfigKept', { reason: S.String }) { + readonly [RewriteSandboxTsconfigTypeId] = RewriteSandboxTsconfigTypeId +} + +export class TsconfigRewritten extends S.TaggedClass()('TsconfigRewritten', { + config: TsConfigSchema, + follow: S.Array(S.String), +}) { + readonly [RewriteSandboxTsconfigTypeId] = RewriteSandboxTsconfigTypeId +} + +export const RewriteSandboxTsconfigDecision = S.Union([TsconfigSkipped, TsconfigKept, TsconfigRewritten]) +export type RewriteSandboxTsconfigDecision = typeof RewriteSandboxTsconfigDecision.Type + +type Parsed = typeof TsconfigParsed.Type +type FileArrayKey = 'exclude' | 'files' | 'include' + +const escapes = (parsed: Parsed, entry: string): boolean => + Option.exists(HashMap.get(parsed.relativeToBasePath, entry), (relative) => relative.startsWith('..')) + +const escapedReferenceOf = (entry: string): string => `../../${entry.replaceAll('\\', '/')}` + +const rewriteOf = (parsed: Parsed) => (entry: string): string => + Boolean.match(escapes(parsed, entry), { onTrue: () => escapedReferenceOf(entry), onFalse: () => entry }) + +const followedOf = (parsed: Parsed, entries: ReadonlyArray): ReadonlyArray => + Arr.filter(entries, (entry) => !escapes(parsed, entry)) + +const referencedTsconfigOf = (referencePath: string): string => + Boolean.match(referencePath.endsWith('.json'), { + onTrue: () => referencePath, + onFalse: () => `${referencePath}/tsconfig.json`, + }) + +const rewrittenFileArray = ( + parsed: Parsed, + key: K, +): { readonly [P in K]?: ReadonlyArray } => + Option.match(Option.fromUndefinedOr(parsed.config[key]), { + onNone: () => ({}), + onSome: (entries) => Record.singleton(key, Arr.map(entries, rewriteOf(parsed))), + }) + +const rewrittenExtends = (parsed: Parsed): Pick => + Match.value(parsed.config.extends).pipe( + Match.when(Predicate.isUndefined, () => ({})), + Match.when(Predicate.isString, (entry) => ({ extends: rewriteOf(parsed)(entry) })), + Match.when(S.is(ExtendsArraySchema), (entries) => ({ extends: Arr.map(entries, rewriteOf(parsed)) })), + Match.exhaustive, + ) + +const rewrittenReferences = (parsed: Parsed): Pick => + Option.match(Option.fromUndefinedOr(parsed.config.references), { + onNone: () => ({}), + onSome: (references) => ({ + references: Arr.map(references, (reference) => ({ ...reference, path: rewriteOf(parsed)(reference.path) })), + }), + }) + +const rewrittenOf = (parsed: Parsed): TsconfigRewritten => + TsconfigRewritten.make({ + config: { + ...parsed.config, + ...rewrittenFileArray(parsed, 'include'), + ...rewrittenFileArray(parsed, 'exclude'), + ...rewrittenFileArray(parsed, 'files'), + ...rewrittenExtends(parsed), + ...rewrittenReferences(parsed), + }, + follow: [ + ...followedOf(parsed, extendsEntriesOf(parsed.config)), + ...Arr.map(followedOf(parsed, referencePathsOf(parsed.config)), referencedTsconfigOf), + ], + }) + +const decide = (command: RewriteSandboxTsconfigCommand): Result.Result => + Result.succeed( + Match.value(command.tsconfig).pipe( + Match.tagsExhaustive({ + TsconfigMissing: (): RewriteSandboxTsconfigDecision => TsconfigSkipped.make({}), + TsconfigUnparsable: ({ reason }): RewriteSandboxTsconfigDecision => TsconfigKept.make({ reason }), + TsconfigParsed: (parsed): RewriteSandboxTsconfigDecision => rewrittenOf(parsed), + }), + ), + ) + +export const rewriteSandboxTsconfig = Workflow.make({ + command: RewriteSandboxTsconfigCommand, + decision: RewriteSandboxTsconfigDecision, + error: S.Never, + decide, +}) diff --git a/packages/stryker-js/src/run/dry-run-choice.ts b/packages/stryker-js/src/run/dry-run-choice.ts index dfcf9af32..069be7505 100644 --- a/packages/stryker-js/src/run/dry-run-choice.ts +++ b/packages/stryker-js/src/run/dry-run-choice.ts @@ -4,7 +4,6 @@ import type { Mutant, Options } from '@systemfsoftware/stryker-js-plugin-interfa import * as Arr from 'effect/Array' import * as Effect from 'effect/Effect' import * as FileSystem from 'effect/FileSystem' -import * as MutableHashMap from 'effect/MutableHashMap' import * as Option from 'effect/Option' import * as Path from 'effect/Path' import * as Result from 'effect/Result' @@ -72,7 +71,7 @@ export const testClosureDigestOf = Effect.fnUntraced(function*( const analysis = yield* Effect.option( analyzeImportClosure({ rootDir, - projectFiles: Arr.dedupe([...MutableHashMap.keys(target.project.files), ...target.project.testFiles]), + projectFiles: Arr.dedupe([...target.project.files.keys(), ...target.project.testFiles]), testFiles: [...target.project.testFiles], globalInputs: [...globalInputs], ...(observedModules === undefined ? {} : { observedModules }), diff --git a/packages/stryker-js/src/run/dry-run.cell.ts b/packages/stryker-js/src/run/dry-run.cell.ts index 16cb4005b..02eaa152c 100644 --- a/packages/stryker-js/src/run/dry-run.cell.ts +++ b/packages/stryker-js/src/run/dry-run.cell.ts @@ -8,7 +8,6 @@ import * as EffectDuration from 'effect/Duration' import * as Effect from 'effect/Effect' import * as FileSystem from 'effect/FileSystem' import * as Match from 'effect/Match' -import * as MutableHashMap from 'effect/MutableHashMap' import * as MutableHashSet from 'effect/MutableHashSet' import * as Option from 'effect/Option' import * as Path from 'effect/Path' @@ -106,7 +105,7 @@ const workerSpawnOf = ( const mutatableFilesOf = (command: InstrumentDone): readonly string[] => { const mutated = MutableHashSet.fromIterable(command.mutants.map((mutant) => mutant.fileName)) - return [...MutableHashMap.keys(command.project.filesToMutate)].filter((name) => MutableHashSet.has(mutated, name)) + return [...command.project.filesToMutate.keys()].filter((name) => MutableHashSet.has(mutated, name)) } const buildDryRunFiles = (command: InstrumentDone) => diff --git a/packages/stryker-js/src/run/incremental-reuse.cell.ts b/packages/stryker-js/src/run/incremental-reuse.cell.ts index c65163ace..3de57ce33 100644 --- a/packages/stryker-js/src/run/incremental-reuse.cell.ts +++ b/packages/stryker-js/src/run/incremental-reuse.cell.ts @@ -294,7 +294,7 @@ const closureAnalysisOf = ( Effect.option( analyzeImportClosure({ rootDir: input.basePath, - projectFiles: Arr.dedupe([...MutableHashMap.keys(input.project.files), ...input.project.testFiles]), + projectFiles: Arr.dedupe([...input.project.files.keys(), ...input.project.testFiles]), testFiles: closureTestFilesOf(input), globalInputs: input.globalTestInputs.map((file) => input.originalFileOf(file)), ...(input.observedModules === undefined ? {} : { observedModules: input.observedModules }), diff --git a/packages/stryker-js/src/run/instrument.ts b/packages/stryker-js/src/run/instrument.ts index 095b38453..fc146329b 100644 --- a/packages/stryker-js/src/run/instrument.ts +++ b/packages/stryker-js/src/run/instrument.ts @@ -4,7 +4,6 @@ import { Boolean } from 'effect' import * as Array from 'effect/Array' import * as Effect from 'effect/Effect' import { absurd, dual } from 'effect/Function' -import * as MutableHashMap from 'effect/MutableHashMap' import * as Option from 'effect/Option' import * as Queue from 'effect/Queue' import * as Result from 'effect/Result' @@ -56,16 +55,14 @@ export const offerSkipsIfAny = Effect.fn(SpanTaxonomy.Spans.instrumentOfferSkips }, ) -const mergeInstrumentedFile = (input: { readonly project: Project; readonly file: ProjectFile }): Project => { - const files = MutableHashMap.fromIterable(input.project.files) - MutableHashMap.set(files, input.file.name, input.file) - const filesToMutate = MutableHashMap.fromIterable(input.project.filesToMutate) - Boolean.match(input.file.mutate === false, { - onTrue: () => MutableHashMap.remove(filesToMutate, input.file.name), - onFalse: () => MutableHashMap.set(filesToMutate, input.file.name, input.file), - }) - return { ...input.project, files, filesToMutate } -} +const mergeInstrumentedFile = (input: { readonly project: Project; readonly file: ProjectFile }): Project => ({ + ...input.project, + files: new Map([...input.project.files, [input.file.name, input.file]]), + filesToMutate: Boolean.match(input.file.mutate === false, { + onTrue: () => new Map([...input.project.filesToMutate].filter(([name]) => name !== input.file.name)), + onFalse: () => new Map([...input.project.filesToMutate, [input.file.name, input.file]]), + }), +}) const withInstrumentedFiles = ( project: Project, @@ -77,7 +74,7 @@ const withInstrumentedFiles = ( (current, { name, content }) => Option.getOrElse( Option.map( - MutableHashMap.get(current.files, name), + Option.fromUndefinedOr(current.files.get(name)), (existing) => mergeInstrumentedFile({ project: current, file: { ...existing, content } }), ), () => current, @@ -97,7 +94,7 @@ export const instrumentFiles = Effect.fnUntraced(function*( > { const files = yield* ProjectFiles const filesToMutate = yield* Effect.map( - files.readAll(MutableHashMap.values(command.project.filesToMutate)), + files.readAll(command.project.filesToMutate.values()), (readFiles) => readFiles.map(([file, content]) => ({ content, mutate: file.mutate, name: file.name })), ).pipe( Effect.mapError((cause) => @@ -152,8 +149,8 @@ const projectOf = (seeds: readonly ProjectFile[]): Project => { fileDescriptions: {}, incrementalReport: undefined, testFiles: [], - files: MutableHashMap.fromIterable(uniqueByName.map((seed) => [seed.name, seed] as const)), - filesToMutate: MutableHashMap.fromIterable( + files: new Map(uniqueByName.map((seed) => [seed.name, seed] as const)), + filesToMutate: new Map( uniqueByName.filter((seed) => seed.mutate !== false).map((seed) => [seed.name, seed] as const), ), } @@ -173,21 +170,21 @@ const filesMatchReference = ( initial: readonly ProjectFile[], updates: Map, ): boolean => - MutableHashMap.size(folded.files) === initial.length && + folded.files.size === initial.length && initial.every((file) => - Option.match(MutableHashMap.get(folded.files, file.name), { + Option.match(Option.fromUndefinedOr(folded.files.get(file.name)), { onNone: () => false, onSome: (after) => contentApplied(updates, file, after) && untouchedApartFromContent(file, after), }) ) const filesToMutateMatchReference = (folded: Project, mutatable: readonly ProjectFile[]): boolean => - MutableHashMap.size(folded.filesToMutate) === mutatable.length && + folded.filesToMutate.size === mutatable.length && mutatable.every((file) => - Option.match(MutableHashMap.get(folded.filesToMutate, file.name), { + Option.match(Option.fromUndefinedOr(folded.filesToMutate.get(file.name)), { onNone: () => false, onSome: (after) => - Option.match(MutableHashMap.get(folded.files, file.name), { + Option.match(Option.fromUndefinedOr(folded.files.get(file.name)), { onNone: () => false, onSome: (inFiles) => after.content === inFiles.content, }), @@ -202,7 +199,7 @@ const referenceLawHolds = ( const project = projectOf(seeds) const folded = fold(project, instrumented) const updates = new Map(instrumented.map(({ name, content }) => [name, content])) - const initial = [...MutableHashMap.values(project.files)] + const initial = [...project.files.values()] const mutatable = initial.filter((file) => file.mutate !== false) return filesMatchReference(folded, initial, updates) && filesToMutateMatchReference(folded, mutatable) } diff --git a/packages/stryker-js/src/run/mutation-test-plan.cell.ts b/packages/stryker-js/src/run/mutation-test-plan.cell.ts index d916b09a9..ad5604db4 100644 --- a/packages/stryker-js/src/run/mutation-test-plan.cell.ts +++ b/packages/stryker-js/src/run/mutation-test-plan.cell.ts @@ -92,7 +92,7 @@ const readPlanCommand = Effect.fn(SpanTaxonomy.Spans.mutationTestPlanRead.name)( const sandboxFileByName: Record = Object.fromEntries( yield* sandboxFilesOf({ sandbox: input.sandbox, - fileNames: [...MutableHashMap.keys(input.project.filesToMutate)], + fileNames: [...input.project.filesToMutate.keys()], }), ) const command = planCommandOf( diff --git a/packages/stryker-js/src/run/prepare.ts b/packages/stryker-js/src/run/prepare.ts index f0689a597..acaadb0b7 100644 --- a/packages/stryker-js/src/run/prepare.ts +++ b/packages/stryker-js/src/run/prepare.ts @@ -12,10 +12,8 @@ import * as Clock from 'effect/Clock' import * as Console from 'effect/Console' import * as Effect from 'effect/Effect' import * as FileSystem from 'effect/FileSystem' -import { pipe } from 'effect/Function' import * as HashMap from 'effect/HashMap' import * as Match from 'effect/Match' -import * as MutableHashMap from 'effect/MutableHashMap' import * as Option from 'effect/Option' import * as Path from 'effect/Path' import * as Queue from 'effect/Queue' @@ -229,7 +227,7 @@ export const readPrepare = Effect.fn(SpanTaxonomy.Spans.prepareGather.name)(func now, mode: env.resolvedMode.mode, reporters: [...options.reporters], - fileCount: pipe(command.project.files, MutableHashMap.size), + fileCount: command.project.files.size, env, queue, options, @@ -248,10 +246,10 @@ export const readPrepare = Effect.fn(SpanTaxonomy.Spans.prepareGather.name)(func export const admitPreparedProject = (raw: PrepareRaw): Effect.Effect => Effect.gen(function*() { - const mutateCount = pipe(raw.project.filesToMutate, MutableHashMap.size) + const mutateCount = raw.project.filesToMutate.size yield* announceSummary({ env: raw.env, - summary: `Found ${mutateCount} of ${pipe(raw.project.files, MutableHashMap.size)} file(s) to be mutated.`, + summary: `Found ${mutateCount} of ${raw.project.files.size} file(s) to be mutated.`, }) yield* validateReporterNames( raw.options.reporters, @@ -260,7 +258,7 @@ export const admitPreparedProject = (raw: PrepareRaw): Effect.Effect StageError.make({ stage: 'prepare', reason: cause.message, cause })), ) const admission = admitNonEmptyProject( - NonEmptyProjectCommand.make({ fileCount: pipe(raw.project.files, MutableHashMap.size) }), + NonEmptyProjectCommand.make({ fileCount: raw.project.files.size }), ) const emptyAdmission = Option.filter(Result.getSuccess(admission), S.is(ProjectEmpty)) const emptyProjectGuard: Result.Result = Option.getOrElse( diff --git a/packages/stryker-js/src/sandbox-tsconfig.cell.ts b/packages/stryker-js/src/sandbox-tsconfig.cell.ts new file mode 100644 index 000000000..3e923a907 --- /dev/null +++ b/packages/stryker-js/src/sandbox-tsconfig.cell.ts @@ -0,0 +1,107 @@ +import type { JsonValue } from '@std/jsonc' +import { parse } from '@std/jsonc' +import { type Cell, Sandwich } from '@systemfsoftware/effect-cell-types' +import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' +import { ErrorText } from '@systemfsoftware/stryker-js-instrumenter' +import * as Arr from 'effect/Array' +import * as Effect from 'effect/Effect' +import * as HashMap from 'effect/HashMap' +import * as Option from 'effect/Option' +import * as Path from 'effect/Path' +import type { PlatformError } from 'effect/PlatformError' +import * as S from 'effect/Schema' + +import { ProjectFiles } from './project-files.service.js' +import { type Project, type ProjectFile, withPreprocessedFiles } from './Project.schema.js' +import { rewriteSandboxTsconfig, RewriteSandboxTsconfigCommand } from './rewrite-sandbox-tsconfig.workflow.js' +import { referencedEntriesOf, type TSConfig, TsConfigSchema } from './Sandbox.schema.js' +import { StrykerError } from './stryker-error.schema.js' + +export interface SandboxTsconfigInput { + readonly project: Project + readonly fileName: string + readonly basePath: string +} + +const parseJsonText = (jsonText: string): Effect.Effect => + Effect.try({ + try: () => parse(jsonText.replace(/^\uFEFF/, '')), + catch: (cause) => Option.getOrElse(Option.map(ErrorText.errorTextOf(cause), (rendered) => rendered.text), () => ''), + }) + +const parseTsConfig = (jsonText: string): Effect.Effect => + Effect.flatMap(parseJsonText(jsonText), (parsed) => + Effect.fromOption( + Option.filter(Option.some(parsed), S.is(TsConfigSchema)), + () => `parsed to ${JSON.stringify(parsed)}, which does not match the tsconfig shape this package consumes`, + )) + +const relativeToBasePathOf = (config: TSConfig, input: SandboxTsconfigInput, pathService: Path.Path) => + HashMap.fromIterable( + Arr.map(referencedEntriesOf(config), (entry) => + [ + entry, + pathService.relative(input.basePath, pathService.resolve(pathService.dirname(input.fileName), entry)), + ] as const), + ) + +const tsconfigOf = (content: string, input: SandboxTsconfigInput, pathService: Path.Path) => + Effect.match(parseTsConfig(content), { + onFailure: (reason): typeof RewriteSandboxTsconfigCommand.Encoded['tsconfig'] => ({ + _tag: 'TsconfigUnparsable', + reason, + }), + onSuccess: (config): typeof RewriteSandboxTsconfigCommand.Encoded['tsconfig'] => ({ + _tag: 'TsconfigParsed', + config, + relativeToBasePath: relativeToBasePathOf(config, input, pathService), + }), + }) + +const readSandboxTsconfig = Effect.fnUntraced(function*(input: SandboxTsconfigInput) { + const pathService = yield* Path.Path + const files = yield* ProjectFiles + const file = Option.fromUndefinedOr(input.project.files.get(input.fileName)) + const tsconfig = yield* Option.match(file, { + onNone: () => Effect.succeed({ _tag: 'TsconfigMissing' }), + onSome: (present) => Effect.flatMap(files.read(present), (content) => tsconfigOf(content, input, pathService)), + }) + return { _tag: 'RewriteSandboxTsconfigCommand' as const, tsconfig, input, file } +}) + +const withRewrittenContent = (project: Project, file: Option.Option, content: string): Project => + withPreprocessedFiles(project, Option.toArray(Option.map(file, (present) => ({ ...present, content })))) + +const followAll = (follow: ReadonlyArray, input: SandboxTsconfigInput, pathService: Path.Path) => + Effect.reduce(follow, () => input.project, (project, entry) => + sandboxTsconfigCell.run({ + project, + fileName: pathService.resolve(pathService.dirname(input.fileName), entry), + basePath: input.basePath, + })) + +export const sandboxTsconfigCell: Cell.Cell< + SandboxTsconfigInput, + Project, + PlatformError | StrykerError, + Path.Path | ProjectFiles +> = Sandwich.named(SpanTaxonomy.Spans.sandboxTsconfigRewriteFileArrays.name)(readSandboxTsconfig) + .decide(rewriteSandboxTsconfig) + .write({ + TsconfigSkipped: (_decision, raw) => Effect.succeed(raw.input.project), + TsconfigKept: ({ reason }, raw) => + Effect.as( + Effect.logWarning( + `Could not rewrite tsconfig file "${raw.input.fileName}": ${reason}. Its extends, project references, and file array properties were not rewritten for the sandbox, so this file still points at paths outside it.`, + ), + raw.input.project, + ), + TsconfigRewritten: ({ config, follow }, raw) => + Effect.gen(function*() { + const followed = yield* followAll(follow, raw.input, yield* Path.Path) + const content = yield* S.encodeEffect(S.fromJsonString(TsConfigSchema, { space: 2 }))(config).pipe(Effect.orDie) + return withRewrittenContent(followed, raw.file, content) + }), + CommandRejected: ({ issue }) => + Effect.fail(StrykerError.make({ message: `Could not decide the sandbox rewrite of a tsconfig file: ${issue}` })), + }) From 3be4785d99759c3a4e2a9539039c17dfef3d4396 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Sat, 10 Oct 2026 02:24:27 +0000 Subject: [PATCH 2/5] test(stryker-js): grade the sandbox tsconfig rewrite decision Laws over drawn TsConfigSchema values: escaping entries in all five positions move two levels up and others stay; the follow list is the non-escaping extends then the non-escaping references' tsconfigs; keys outside the five survive encoding; each read outcome maps to its decision. --- ...sandbox-tsconfig.workflow.property.test.ts | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 packages/stryker-js/src/__tests__/rewrite-sandbox-tsconfig.workflow.property.test.ts diff --git a/packages/stryker-js/src/__tests__/rewrite-sandbox-tsconfig.workflow.property.test.ts b/packages/stryker-js/src/__tests__/rewrite-sandbox-tsconfig.workflow.property.test.ts new file mode 100644 index 000000000..4c4a36e81 --- /dev/null +++ b/packages/stryker-js/src/__tests__/rewrite-sandbox-tsconfig.workflow.property.test.ts @@ -0,0 +1,143 @@ +import { describe, it } from '@systemfsoftware/vitest' +import * as Arr from 'effect/Array' +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 Result from 'effect/Result' +import * as S from 'effect/Schema' + +import { + rewriteSandboxTsconfig, + RewriteSandboxTsconfigCommand, + type RewriteSandboxTsconfigDecision, + TsconfigParsed, + TsconfigRewritten, + TsconfigSkipped, +} from '../rewrite-sandbox-tsconfig.workflow.js' +import { referencedEntriesOf, type TSConfig, TsConfigSchema } from '../Sandbox.schema.js' + +const rewrittenKeys: ReadonlyArray = ['include', 'exclude', 'files', 'extends', 'references', 'path'] + +const escapingOf = (config: TSConfig, bits: ReadonlyArray): HashSet.HashSet => + HashSet.fromIterable( + Arr.filter(referencedEntriesOf(config), (_entry, index) => bits[index % bits.length] === true), + ) + +const commandOf = (config: TSConfig, escaping: HashSet.HashSet): RewriteSandboxTsconfigCommand => + RewriteSandboxTsconfigCommand.make({ + tsconfig: TsconfigParsed.make({ + config, + relativeToBasePath: HashMap.fromIterable( + Arr.map( + referencedEntriesOf(config), + (entry) => [entry, HashSet.has(escaping, entry) ? '../outside' : 'inside'] as const, + ), + ), + }), + }) + +const expectedEntryOf = (escaping: HashSet.HashSet) => (entry: string): string => + HashSet.has(escaping, entry) ? `../../${entry.split('\\').join('/')}` : entry + +const listOf = (value: string | ReadonlyArray | undefined): ReadonlyArray => [value ?? []].flat() + +const sameJson = (left: A, right: A): boolean => JSON.stringify(left) === JSON.stringify(right) + +const rewrittenOf = (decision: RewriteSandboxTsconfigDecision) => + Match.value(decision).pipe( + Match.tag('TsconfigRewritten', (rewritten) => Option.some(rewritten)), + Match.tag('TsconfigSkipped', 'TsconfigKept', () => Option.none()), + Match.exhaustive, + ) + +const decisionOf = ( + subject: typeof rewriteSandboxTsconfig, + config: TSConfig, + escaping: HashSet.HashSet, +) => Option.flatMap(Result.getSuccess(subject(commandOf(config, escaping))), rewrittenOf) + +const entriesRewritten = (config: TSConfig, rewritten: TSConfig, escaping: HashSet.HashSet): boolean => { + const expected = (entries: ReadonlyArray | undefined) => entries?.map(expectedEntryOf(escaping)) + return sameJson(rewritten.include, expected(config.include)) && + sameJson(rewritten.exclude, expected(config.exclude)) && + sameJson(rewritten.files, expected(config.files)) && + sameJson(listOf(rewritten.extends), expected(listOf(config.extends))) && + sameJson( + rewritten.references?.map((reference) => reference.path), + expected(config.references?.map((reference) => reference.path)), + ) +} + +const referencedTsconfigOf = (path: string): string => path.endsWith('.json') ? path : `${path}/tsconfig.json` + +const otherKeysOf = (config: object): ReadonlyArray => + Object.keys(config).filter((key) => !rewrittenKeys.includes(key)) + +const keepsOtherKeys = (original: object, written: object): boolean => + otherKeysOf(original).every((key) => sameJson(Reflect.get(written, key), Reflect.get(original, key))) + +describe('rewriteSandboxTsconfig', () => { + it.prop( + '∀tsconfig_Rewrite_≡EscapingEntriesTwoLevelsUpOthersUnchanged', + { of: [TsConfigSchema, S.Array(S.Boolean)], subject: rewriteSandboxTsconfig }, + (subject, [config, bits]) => { + const escaping = escapingOf(config, bits) + return Option.exists( + decisionOf(subject, config, escaping), + (decision) => entriesRewritten(config, decision.config, escaping), + ) + }, + ) + + it.prop( + '∀tsconfig_Follow_≡NonEscapingExtendsThenNonEscapingReferenceTsconfigs', + { of: [TsConfigSchema, S.Array(S.Boolean)], subject: rewriteSandboxTsconfig }, + (subject, [config, bits]) => { + const escaping = escapingOf(config, bits) + const kept = (entry: string) => !HashSet.has(escaping, entry) + return Option.exists(decisionOf(subject, config, escaping), (decision) => + sameJson(decision.follow, [ + ...listOf(config.extends).filter(kept), + ...(config.references ?? []).map((reference) => reference.path).filter(kept).map(referencedTsconfigOf), + ])) + }, + ) + + it.prop( + '∀tsconfig_EncodedRewrite_≡KeepsEveryKeyOutsideTheRewrittenFive', + { of: [TsConfigSchema, S.Array(S.Boolean)], subject: rewriteSandboxTsconfig }, + (subject, [config, bits]) => + Option.exists( + decisionOf(subject, config, escapingOf(config, bits)), + (decision) => + Option.exists(S.encodeOption(TsConfigSchema)(decision.config), (encoded) => + keepsOtherKeys(config, encoded) && + Arr.every( + Arr.zip(config.references ?? [], encoded.references ?? []), + ([reference, written]) => keepsOtherKeys(reference, written), + )), + ), + ) + + it.prop( + '∀command_Decision_≡SkippedWhenMissingKeptWithItsReasonWhenUnparsableRewrittenWhenParsed', + { of: [RewriteSandboxTsconfigCommand], subject: rewriteSandboxTsconfig }, + (subject, [command]) => + Result.match(subject(command), { + onFailure: () => false, + onSuccess: (decision) => + Match.value(command.tsconfig).pipe( + Match.tag('TsconfigMissing', () => S.is(TsconfigSkipped)(decision)), + Match.tag('TsconfigUnparsable', ({ reason }) => + Match.value(decision).pipe( + Match.tag('TsconfigKept', (kept) => kept.reason === reason), + Match.tag('TsconfigSkipped', 'TsconfigRewritten', () => false), + Match.exhaustive, + )), + Match.tag('TsconfigParsed', () => S.is(TsconfigRewritten)(decision)), + Match.exhaustive, + ), + }), + ) +}) From 2e132a402f5dea0fc54de76d61e0cce3d72a4a24 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Sat, 10 Oct 2026 04:36:12 +0000 Subject: [PATCH 3/5] refactor(stryker-js): rewrite one tsconfig per cell run and declare ProjectFile as a schema sandboxTsconfigCell now rewrites a single tsconfig and returns the rewritten file plus its follow list; the blueprint walks the follow list depth-first, so a cell never runs another cell (ADR-0001 *.cell.ts row). ProjectFile is a production schema in Project.schema.ts built on the newly exported Instrument.MutateDescriptionSchema, and the withPreprocessedFiles law snapshots the caller's maps, so an in-place merge now fails it. referencedEntriesOf and entriesOf gain in-source laws, and run/instrument.ts keeps its law helpers inside the import.meta.vitest guard. --- .../instrument-mutate-description-schema.md | 5 + ...1-refactor-grab-bag-decomposition-plan.md} | 0 .../etc/stryker-js-instrumenter.api.md | 2 +- .../src/Instrument/mod.ts | 1 + packages/stryker-js/src/Project.schema.ts | 120 +++++++++++------- packages/stryker-js/src/Sandbox.blueprint.ts | 14 +- packages/stryker-js/src/Sandbox.schema.ts | 26 +++- packages/stryker-js/src/run/instrument.ts | 119 +++++++++-------- .../stryker-js/src/sandbox-tsconfig.cell.ts | 29 ++--- 9 files changed, 187 insertions(+), 129 deletions(-) create mode 100644 .changeset/instrument-mutate-description-schema.md rename docs/plans/{2026-10-09-1840-refactor-grab-bag-decomposition-plan.md => 2026-10-10-0431-refactor-grab-bag-decomposition-plan.md} (100%) diff --git a/.changeset/instrument-mutate-description-schema.md b/.changeset/instrument-mutate-description-schema.md new file mode 100644 index 000000000..8339f3cdf --- /dev/null +++ b/.changeset/instrument-mutate-description-schema.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js-instrumenter": minor +--- + +`Instrument.MutateDescriptionSchema` is exported, so a consumer can declare a file's `mutate` field with the same schema the instrumenter uses. diff --git a/docs/plans/2026-10-09-1840-refactor-grab-bag-decomposition-plan.md b/docs/plans/2026-10-10-0431-refactor-grab-bag-decomposition-plan.md similarity index 100% rename from docs/plans/2026-10-09-1840-refactor-grab-bag-decomposition-plan.md rename to docs/plans/2026-10-10-0431-refactor-grab-bag-decomposition-plan.md diff --git a/packages/stryker-js-instrumenter/etc/stryker-js-instrumenter.api.md b/packages/stryker-js-instrumenter/etc/stryker-js-instrumenter.api.md index 06b707d94..e4e047048 100644 --- a/packages/stryker-js-instrumenter/etc/stryker-js-instrumenter.api.md +++ b/packages/stryker-js-instrumenter/etc/stryker-js-instrumenter.api.md @@ -21,7 +21,7 @@ export namespace Format { // @public (undocumented) export namespace Instrument { - export { File, FileDescription, FileDescriptions, InstrumentError, InstrumentFileSkip, InstrumentResult, InstrumenterContext, InstrumenterOptions, MutateDescription, ParserOptions, disableTypeChecks, instrument }; + export { File, FileDescription, FileDescriptions, InstrumentError, InstrumentFileSkip, InstrumentResult, InstrumenterContext, InstrumenterOptions, MutateDescription, MutateDescriptionSchema, ParserOptions, disableTypeChecks, instrument }; } // @public (undocumented) diff --git a/packages/stryker-js-instrumenter/src/Instrument/mod.ts b/packages/stryker-js-instrumenter/src/Instrument/mod.ts index 78ecc6bba..dde455aa9 100644 --- a/packages/stryker-js-instrumenter/src/Instrument/mod.ts +++ b/packages/stryker-js-instrumenter/src/Instrument/mod.ts @@ -4,6 +4,7 @@ export { type FileDescriptions, InstrumentFileSkip, type MutateDescription, + MutateDescriptionSchema, } from '../Instrument.schema.js' export { disableTypeChecks, instrument } from '../Instrument.service.js' export type { File, InstrumenterOptions, InstrumentResult } from '../Instrument.service.js' diff --git a/packages/stryker-js/src/Project.schema.ts b/packages/stryker-js/src/Project.schema.ts index 627d76fad..f0c3349e6 100644 --- a/packages/stryker-js/src/Project.schema.ts +++ b/packages/stryker-js/src/Project.schema.ts @@ -1,15 +1,19 @@ -import type { Instrument } from '@systemfsoftware/stryker-js-instrumenter' +import { Instrument } from '@systemfsoftware/stryker-js-instrumenter' import * as Arr from 'effect/Array' import { dual } from 'effect/Function' +import * as Option from 'effect/Option' +import * as S from 'effect/Schema' import type { IncrementalReport } from './IncrementalReport.schema.js' -export interface ProjectFile extends Instrument.FileDescription { - readonly name: string - readonly mutate: Instrument.MutateDescription - readonly content: string | undefined - readonly originalContent: string | undefined -} +export const ProjectFile = S.Struct({ + name: S.String, + mutate: Instrument.MutateDescriptionSchema, + content: S.UndefinedOr(S.String), + originalContent: S.UndefinedOr(S.String), +}) + +export type ProjectFile = typeof ProjectFile.Type export interface Project { readonly fileDescriptions: Instrument.FileDescriptions @@ -25,31 +29,25 @@ const replacedBy = ( ): ReadonlyMap => new Map([...files, ...Arr.map(updates, (update) => [update.name, update] as const)]) +const heldIn = (files: ReadonlyMap) => (update: ProjectFile): boolean => files.has(update.name) + export const withPreprocessedFiles: { (updates: ReadonlyArray): (project: Project) => Project (project: Project, updates: ReadonlyArray): Project } = dual(2, (project: Project, updates: ReadonlyArray): Project => ({ ...project, files: replacedBy(project.files, updates), - filesToMutate: replacedBy( - project.filesToMutate, - Arr.filter(updates, (update) => project.filesToMutate.has(update.name)), - ), + filesToMutate: replacedBy(project.filesToMutate, Arr.filter(updates, heldIn(project.filesToMutate))), })) if (import.meta.vitest !== void 0) { const { it } = await import('@systemfsoftware/vitest') - const S = await import('effect/Schema') - const Option = await import('effect/Option') - - const DrawnFile = S.Struct({ - name: S.String.pipe(S.check(S.isMaxLength(4))), - mutate: S.Boolean, - content: S.UndefinedOr(S.String), - originalContent: S.UndefinedOr(S.String), - }) - const DrawnFiles = S.Array(S.Tuple([DrawnFile, S.Boolean])).pipe(S.check(S.isMaxLength(12))) - const DrawnUpdates = S.Array(S.Tuple([S.Int, S.UndefinedOr(S.String)])).pipe(S.check(S.isMaxLength(12))) + + const DrawnFiles = S.Array(S.Tuple([ProjectFile, S.Boolean])).pipe(S.check(S.isMaxLength(12))) + const ExistingUpdate = S.Tuple([S.Int, S.UndefinedOr(S.String)]) + const DrawnUpdates = S.Array(S.Union([ExistingUpdate, ProjectFile])).pipe(S.check(S.isMaxLength(12))) + + type Entries = ReadonlyArray const projectOf = (drawn: typeof DrawnFiles.Type): Project => { const unique = Arr.dedupeWith(drawn, ([left], [right]) => left.name === right.name) @@ -64,41 +62,67 @@ if (import.meta.vitest !== void 0) { } } - const updatesOf = (project: Project, drawn: typeof DrawnUpdates.Type): ReadonlyArray => { - const existing = [...project.files.values()] - return Arr.flatMap( - drawn, - ([index, content]) => - Option.match(Arr.get(existing, Math.abs(index) % Math.max(existing.length, 1)), { - onNone: () => [], - onSome: (file) => [{ ...file, content }], - }), + const existingUpdateOf = (existing: Entries, [index, content]: typeof ExistingUpdate.Type) => + Option.map( + Arr.get(existing, Math.abs(index) % Math.max(existing.length, 1)), + ([, file]): ProjectFile => ({ ...file, content }), ) - } - const expectedOf = (updates: ReadonlyArray, original: ProjectFile): ProjectFile => - Option.getOrElse(Arr.findLast(updates, (update) => update.name === original.name), () => original) - - const replacesExactlyTheUpdatedInPlace = ( - before: ReadonlyMap, - after: ReadonlyMap, - updates: ReadonlyArray, - ): boolean => - after.size === before.size && - Arr.every( - Arr.zip([...before], [...after]), - ([[name, original], [afterName, afterFile]]) => afterName === name && afterFile === expectedOf(updates, original), + const updatesOf = (existing: Entries, drawn: typeof DrawnUpdates.Type): ReadonlyArray => + Arr.getSomes( + Arr.map(drawn, (update) => + Option.orElse( + Option.liftPredicate(update, S.is(ProjectFile)), + () => + Option.flatMap(Option.liftPredicate(update, S.is(ExistingUpdate)), (at) => existingUpdateOf(existing, at)), + )), ) + const lastUpdateOf = (updates: ReadonlyArray, name: string): Option.Option => + Arr.findLast(updates, (update) => update.name === name) + + const replacedIn = (before: Entries, updates: ReadonlyArray): Entries => + Arr.map(before, ([name, file]) => [name, Option.getOrElse(lastUpdateOf(updates, name), () => file)] as const) + + const addedTo = (before: Entries, updates: ReadonlyArray): Entries => + Arr.getSomes( + Arr.map( + Arr.dedupe( + Arr.filter(Arr.map(updates, (update) => update.name), (name) => !before.some(([held]) => held === name)), + ), + (name) => Option.map(lastUpdateOf(updates, name), (file) => [name, file] as const), + ), + ) + + const sameEntries = (map: ReadonlyMap, expected: Entries): boolean => { + const actual = [...map] + return actual.length === expected.length && + Arr.every( + Arr.zip(actual, expected), + ([[name, file], [expectedName, expectedFile]]) => name === expectedName && file === expectedFile, + ) + } + it.prop( - '∀updates_PreprocessedFiles_≡LastUpdateWinsInPlaceWithinExistingKeys', + '∀pu_PreprocessedFiles_≡FreshMapsLastUpdateWinsUnknownNamesJoinFilesOnly', { of: [DrawnFiles, DrawnUpdates], subject: withPreprocessedFiles }, (subject, [files, drawnUpdates]) => { const project = projectOf(files) - const updates = updatesOf(project, drawnUpdates) + const filesBefore: Entries = [...project.files] + const mutateBefore: Entries = [...project.filesToMutate] + const updates = updatesOf(filesBefore, drawnUpdates) const merged = subject(project, updates) - return replacesExactlyTheUpdatedInPlace(project.files, merged.files, updates) && - replacesExactlyTheUpdatedInPlace(project.filesToMutate, merged.filesToMutate, updates) + return Arr.every( + [ + sameEntries(project.files, filesBefore), + sameEntries(project.filesToMutate, mutateBefore), + merged.files !== project.files, + merged.filesToMutate !== project.filesToMutate, + sameEntries(merged.files, [...replacedIn(filesBefore, updates), ...addedTo(filesBefore, updates)]), + sameEntries(merged.filesToMutate, replacedIn(mutateBefore, updates)), + ], + (holds) => holds, + ) }, ) } diff --git a/packages/stryker-js/src/Sandbox.blueprint.ts b/packages/stryker-js/src/Sandbox.blueprint.ts index e283de6f2..d67718cd4 100644 --- a/packages/stryker-js/src/Sandbox.blueprint.ts +++ b/packages/stryker-js/src/Sandbox.blueprint.ts @@ -63,14 +63,24 @@ const makeDisableTypeChecksPreprocessor = (options: Options.StrykerOptions, regi return withPreprocessedFiles(project, updates) }) +const rewriteTsconfigTree = ( + project: Project, + fileName: string, + basePath: string, +): Effect.Effect => + Effect.flatMap(sandboxTsconfigCell.run({ project, fileName, basePath }), ({ rewritten, follow }) => + Effect.map( + Effect.reduce(follow, () => project, (current, followed) => rewriteTsconfigTree(current, followed, basePath)), + (followedProject) => withPreprocessedFiles(followedProject, Option.toArray(rewritten)), + )) + const makeTSConfigPreprocessor = (options: Options.StrykerOptions, basePath: string): FilePreprocessor => (project) => Boolean.match(options.inPlace, { onTrue: () => Effect.succeed(project), onFalse: () => Effect.flatMap( Path.Path, - (pathService) => - sandboxTsconfigCell.run({ project, fileName: pathService.resolve(options.tsconfigFile), basePath }), + (pathService) => rewriteTsconfigTree(project, pathService.resolve(options.tsconfigFile), basePath), ), }) diff --git a/packages/stryker-js/src/Sandbox.schema.ts b/packages/stryker-js/src/Sandbox.schema.ts index bebeed992..98fbb2ae6 100644 --- a/packages/stryker-js/src/Sandbox.schema.ts +++ b/packages/stryker-js/src/Sandbox.schema.ts @@ -73,7 +73,7 @@ if (import.meta.vitest !== void 0) { compilerOptions: { strict: true }, }) - const extendsListOf = (drawn: DrawnConfig['extends']): ReadonlyArray => [drawn ?? []].flat() + const listOf = (drawn: string | ReadonlyArray | undefined): ReadonlyArray => [drawn ?? []].flat() const sameSequence = (left: ReadonlyArray, right: ReadonlyArray): boolean => left.length === right.length && Arr.every(Arr.zip(left, right), ([entry, other]) => entry === other) @@ -81,12 +81,32 @@ if (import.meta.vitest !== void 0) { it.prop( '∀config_ExtendsEntries_≡EveryExtendsEntryInOrder', { of: [DrawnConfig], subject: extendsEntriesOf }, - (subject, [drawn]) => sameSequence(subject(configOf(drawn)), extendsListOf(drawn.extends)), + (subject, [drawn]) => sameSequence(subject(configOf(drawn)), listOf(drawn.extends)), ) it.prop( '∀config_ReferencePaths_≡EveryReferencePathInOrder', { of: [DrawnConfig], subject: referencePathsOf }, - (subject, [drawn]) => sameSequence(subject(configOf(drawn)), entriesOf(drawn.references)), + (subject, [drawn]) => sameSequence(subject(configOf(drawn)), listOf(drawn.references)), + ) + + it.prop( + '∀config_ReferencedEntries_≡IncludeExcludeFilesExtendsReferencesInThatOrder', + { of: [DrawnConfig], subject: referencedEntriesOf }, + (subject, [drawn]) => + sameSequence( + subject(configOf(drawn)), + Arr.flatMap([drawn.include, drawn.exclude, drawn.files, drawn.extends, drawn.references], listOf), + ), + ) + + it.prop( + '∀entries_Entries_≡TheEntriesWhenPresentNoneWhenAbsent', + { of: [S.UndefinedOr(Drawn)], subject: entriesOf }, + (subject, [drawn]) => + Option.match(Option.fromUndefinedOr(drawn), { + onNone: () => subject(drawn).length === 0, + onSome: (present) => sameSequence(subject(drawn), present), + }), ) } diff --git a/packages/stryker-js/src/run/instrument.ts b/packages/stryker-js/src/run/instrument.ts index fc146329b..bca628cb5 100644 --- a/packages/stryker-js/src/run/instrument.ts +++ b/packages/stryker-js/src/run/instrument.ts @@ -143,70 +143,69 @@ export const enteringInstrumentPhase: { ), ) -const projectOf = (seeds: readonly ProjectFile[]): Project => { - const uniqueByName = [...new Map(seeds.map((seed) => [seed.name, seed])).values()] - return { - fileDescriptions: {}, - incrementalReport: undefined, - testFiles: [], - files: new Map(uniqueByName.map((seed) => [seed.name, seed] as const)), - filesToMutate: new Map( - uniqueByName.filter((seed) => seed.mutate !== false).map((seed) => [seed.name, seed] as const), - ), +if (import.meta.vitest !== void 0) { + const { it } = await import('@systemfsoftware/vitest') + const { Schema } = await import('effect') + const projectOf = (seeds: readonly ProjectFile[]): Project => { + const uniqueByName = [...new Map(seeds.map((seed) => [seed.name, seed])).values()] + return { + fileDescriptions: {}, + incrementalReport: undefined, + testFiles: [], + files: new Map(uniqueByName.map((seed) => [seed.name, seed] as const)), + filesToMutate: new Map( + uniqueByName.filter((seed) => seed.mutate !== false).map((seed) => [seed.name, seed] as const), + ), + } } -} -const updatedContentOf = (updates: Map, file: ProjectFile) => - Option.getOrElse(Option.fromUndefinedOr(updates.get(file.name)), () => file.content) - -const untouchedApartFromContent = (file: ProjectFile, after: ProjectFile): boolean => - after.mutate === file.mutate && after.originalContent === file.originalContent - -const contentApplied = (updates: Map, file: ProjectFile, after: ProjectFile): boolean => - after.content === updatedContentOf(updates, file) - -const filesMatchReference = ( - folded: Project, - initial: readonly ProjectFile[], - updates: Map, -): boolean => - folded.files.size === initial.length && - initial.every((file) => - Option.match(Option.fromUndefinedOr(folded.files.get(file.name)), { - onNone: () => false, - onSome: (after) => contentApplied(updates, file, after) && untouchedApartFromContent(file, after), - }) - ) - -const filesToMutateMatchReference = (folded: Project, mutatable: readonly ProjectFile[]): boolean => - folded.filesToMutate.size === mutatable.length && - mutatable.every((file) => - Option.match(Option.fromUndefinedOr(folded.filesToMutate.get(file.name)), { - onNone: () => false, - onSome: (after) => - Option.match(Option.fromUndefinedOr(folded.files.get(file.name)), { - onNone: () => false, - onSome: (inFiles) => after.content === inFiles.content, - }), - }) - ) + const updatedContentOf = (updates: Map, file: ProjectFile) => + Option.getOrElse(Option.fromUndefinedOr(updates.get(file.name)), () => file.content) + + const untouchedApartFromContent = (file: ProjectFile, after: ProjectFile): boolean => + after.mutate === file.mutate && after.originalContent === file.originalContent + + const contentApplied = (updates: Map, file: ProjectFile, after: ProjectFile): boolean => + after.content === updatedContentOf(updates, file) + + const filesMatchReference = ( + folded: Project, + initial: readonly ProjectFile[], + updates: Map, + ): boolean => + folded.files.size === initial.length && + initial.every((file) => + Option.match(Option.fromUndefinedOr(folded.files.get(file.name)), { + onNone: () => false, + onSome: (after) => contentApplied(updates, file, after) && untouchedApartFromContent(file, after), + }) + ) -const referenceLawHolds = ( - fold: typeof withInstrumentedFiles, - seeds: readonly ProjectFile[], - instrumented: readonly { readonly name: string; readonly content: string }[], -): boolean => { - const project = projectOf(seeds) - const folded = fold(project, instrumented) - const updates = new Map(instrumented.map(({ name, content }) => [name, content])) - const initial = [...project.files.values()] - const mutatable = initial.filter((file) => file.mutate !== false) - return filesMatchReference(folded, initial, updates) && filesToMutateMatchReference(folded, mutatable) -} + const filesToMutateMatchReference = (folded: Project, mutatable: readonly ProjectFile[]): boolean => + folded.filesToMutate.size === mutatable.length && + mutatable.every((file) => + Option.match(Option.fromUndefinedOr(folded.filesToMutate.get(file.name)), { + onNone: () => false, + onSome: (after) => + Option.match(Option.fromUndefinedOr(folded.files.get(file.name)), { + onNone: () => false, + onSome: (inFiles) => after.content === inFiles.content, + }), + }) + ) -if (import.meta.vitest !== void 0) { - const { it } = await import('@systemfsoftware/vitest') - const { Schema } = await import('effect') + const referenceLawHolds = ( + fold: typeof withInstrumentedFiles, + seeds: readonly ProjectFile[], + instrumented: readonly { readonly name: string; readonly content: string }[], + ): boolean => { + const project = projectOf(seeds) + const folded = fold(project, instrumented) + const updates = new Map(instrumented.map(({ name, content }) => [name, content])) + const initial = [...project.files.values()] + const mutatable = initial.filter((file) => file.mutate !== false) + return filesMatchReference(folded, initial, updates) && filesToMutateMatchReference(folded, mutatable) + } const ProjectSeedSchema = Schema.Struct({ name: Schema.String.pipe(Schema.check(Schema.isMaxLength(32))), diff --git a/packages/stryker-js/src/sandbox-tsconfig.cell.ts b/packages/stryker-js/src/sandbox-tsconfig.cell.ts index 3e923a907..f9a42c41e 100644 --- a/packages/stryker-js/src/sandbox-tsconfig.cell.ts +++ b/packages/stryker-js/src/sandbox-tsconfig.cell.ts @@ -12,7 +12,7 @@ import type { PlatformError } from 'effect/PlatformError' import * as S from 'effect/Schema' import { ProjectFiles } from './project-files.service.js' -import { type Project, type ProjectFile, withPreprocessedFiles } from './Project.schema.js' +import type { Project, ProjectFile } from './Project.schema.js' import { rewriteSandboxTsconfig, RewriteSandboxTsconfigCommand } from './rewrite-sandbox-tsconfig.workflow.js' import { referencedEntriesOf, type TSConfig, TsConfigSchema } from './Sandbox.schema.js' import { StrykerError } from './stryker-error.schema.js' @@ -69,38 +69,37 @@ const readSandboxTsconfig = Effect.fnUntraced(function*(input: SandboxTsconfigIn return { _tag: 'RewriteSandboxTsconfigCommand' as const, tsconfig, input, file } }) -const withRewrittenContent = (project: Project, file: Option.Option, content: string): Project => - withPreprocessedFiles(project, Option.toArray(Option.map(file, (present) => ({ ...present, content })))) +export interface SandboxTsconfigRewrite { + readonly rewritten: Option.Option + readonly follow: ReadonlyArray +} -const followAll = (follow: ReadonlyArray, input: SandboxTsconfigInput, pathService: Path.Path) => - Effect.reduce(follow, () => input.project, (project, entry) => - sandboxTsconfigCell.run({ - project, - fileName: pathService.resolve(pathService.dirname(input.fileName), entry), - basePath: input.basePath, - })) +const unchanged: SandboxTsconfigRewrite = { rewritten: Option.none(), follow: [] } export const sandboxTsconfigCell: Cell.Cell< SandboxTsconfigInput, - Project, + SandboxTsconfigRewrite, PlatformError | StrykerError, Path.Path | ProjectFiles > = Sandwich.named(SpanTaxonomy.Spans.sandboxTsconfigRewriteFileArrays.name)(readSandboxTsconfig) .decide(rewriteSandboxTsconfig) .write({ - TsconfigSkipped: (_decision, raw) => Effect.succeed(raw.input.project), + TsconfigSkipped: () => Effect.succeed(unchanged), TsconfigKept: ({ reason }, raw) => Effect.as( Effect.logWarning( `Could not rewrite tsconfig file "${raw.input.fileName}": ${reason}. Its extends, project references, and file array properties were not rewritten for the sandbox, so this file still points at paths outside it.`, ), - raw.input.project, + unchanged, ), TsconfigRewritten: ({ config, follow }, raw) => Effect.gen(function*() { - const followed = yield* followAll(follow, raw.input, yield* Path.Path) + const pathService = yield* Path.Path const content = yield* S.encodeEffect(S.fromJsonString(TsConfigSchema, { space: 2 }))(config).pipe(Effect.orDie) - return withRewrittenContent(followed, raw.file, content) + return { + rewritten: Option.map(raw.file, (present) => ({ ...present, content })), + follow: Arr.map(follow, (entry) => pathService.resolve(pathService.dirname(raw.input.fileName), entry)), + } }), CommandRejected: ({ issue }) => Effect.fail(StrykerError.make({ message: `Could not decide the sandbox rewrite of a tsconfig file: ${issue}` })), From 7b3a9a3720983ffca37f319710046ac9c612556e Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Sat, 10 Oct 2026 04:36:20 +0000 Subject: [PATCH 4/5] test(stryker-js): pin the sandbox tsconfig rewrite at the engine boundary The workflow property test now derives its expected escapes from the drawn entries instead of the production referencedEntriesOf. A new integration suite runs real mutation runs on a temp project and asserts the exact tsconfig text written into the sandbox, the missing-file skip, the unparsable-file warning and the in-place no-rewrite; it passes unchanged against origin/main d2f018db4. --- ...sandbox-tsconfig.workflow.property.test.ts | 18 +- .../sandbox-tsconfig.integration.test.ts | 318 ++++++++++++++++++ 2 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 packages/stryker-js/tests/sandbox-tsconfig.integration.test.ts diff --git a/packages/stryker-js/src/__tests__/rewrite-sandbox-tsconfig.workflow.property.test.ts b/packages/stryker-js/src/__tests__/rewrite-sandbox-tsconfig.workflow.property.test.ts index 4c4a36e81..3db4fc0b1 100644 --- a/packages/stryker-js/src/__tests__/rewrite-sandbox-tsconfig.workflow.property.test.ts +++ b/packages/stryker-js/src/__tests__/rewrite-sandbox-tsconfig.workflow.property.test.ts @@ -15,13 +15,23 @@ import { TsconfigRewritten, TsconfigSkipped, } from '../rewrite-sandbox-tsconfig.workflow.js' -import { referencedEntriesOf, type TSConfig, TsConfigSchema } from '../Sandbox.schema.js' +import { type TSConfig, TsConfigSchema } from '../Sandbox.schema.js' const rewrittenKeys: ReadonlyArray = ['include', 'exclude', 'files', 'extends', 'references', 'path'] +const listOf = (value: string | ReadonlyArray | undefined): ReadonlyArray => [value ?? []].flat() + +const drawnEntriesOf = (config: TSConfig): ReadonlyArray => [ + ...listOf(config.files), + ...listOf(config.include), + ...listOf(config.exclude), + ...listOf(config.extends), + ...(config.references ?? []).map((reference) => reference.path), +] + const escapingOf = (config: TSConfig, bits: ReadonlyArray): HashSet.HashSet => HashSet.fromIterable( - Arr.filter(referencedEntriesOf(config), (_entry, index) => bits[index % bits.length] === true), + Arr.filter(drawnEntriesOf(config), (_entry, index) => bits[index % bits.length] === true), ) const commandOf = (config: TSConfig, escaping: HashSet.HashSet): RewriteSandboxTsconfigCommand => @@ -30,7 +40,7 @@ const commandOf = (config: TSConfig, escaping: HashSet.HashSet): Rewrite config, relativeToBasePath: HashMap.fromIterable( Arr.map( - referencedEntriesOf(config), + drawnEntriesOf(config), (entry) => [entry, HashSet.has(escaping, entry) ? '../outside' : 'inside'] as const, ), ), @@ -40,8 +50,6 @@ const commandOf = (config: TSConfig, escaping: HashSet.HashSet): Rewrite const expectedEntryOf = (escaping: HashSet.HashSet) => (entry: string): string => HashSet.has(escaping, entry) ? `../../${entry.split('\\').join('/')}` : entry -const listOf = (value: string | ReadonlyArray | undefined): ReadonlyArray => [value ?? []].flat() - const sameJson = (left: A, right: A): boolean => JSON.stringify(left) === JSON.stringify(right) const rewrittenOf = (decision: RewriteSandboxTsconfigDecision) => diff --git a/packages/stryker-js/tests/sandbox-tsconfig.integration.test.ts b/packages/stryker-js/tests/sandbox-tsconfig.integration.test.ts new file mode 100644 index 000000000..97625f203 --- /dev/null +++ b/packages/stryker-js/tests/sandbox-tsconfig.integration.test.ts @@ -0,0 +1,318 @@ +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { Engine } from '@systemfsoftware/stryker-js' +import type { Options } from '@systemfsoftware/stryker-js-plugin-interface' +import * as Boolean from 'effect/Boolean' +import * as Effect from 'effect/Effect' +import * as FileSystem from 'effect/FileSystem' +import * as Layer from 'effect/Layer' +import * as Logger from 'effect/Logger' +import * as Path from 'effect/Path' +import type { PlatformError } from 'effect/PlatformError' +import * as Result from 'effect/Result' +import * as Stdio from 'effect/Stdio' + +const Feature = makeFeature({ it }) + +const TEMP_DIR_NAME = '.stryker-tmp' + +type FixtureFiles = Readonly> + +interface ProjectFixture { + readonly root: string + readonly project: string + readonly warnings: Array +} + +interface RunObservation { + readonly succeeded: boolean + readonly sandboxes: number + readonly sandbox: FixtureFiles + readonly onDisk: FixtureFiles +} + +const SOURCE = 'export const add = (left: number, right: number): number => left + right\n' + +const TEST = [ + "import { expect, test } from 'vitest'", + "import { add } from '../src/math.ts'", + '', + "test('adds one and one', () => {", + ' expect(add(1, 1)).toBe(2)', + '})', + '', +].join('\n') + +const ROOT_TSCONFIG = [ + '// the root config', + '{', + ' "compilerOptions": { "strict": true },', + ' "include": ["src/**/*.ts", "../shared/**/*.ts"],', + ' "extends": "./tsconfig.base.json",', + ' "references": [{ "path": "./packages/a", "prepend": false }, { "path": "../outside-lib" }],', + ' "exclude": ["dist"],', + '}', + '', +].join('\n') + +const BASE_TSCONFIG = '{ "files": ["../shared/types.d.ts", "src/env.d.ts"] }\n' + +const PACKAGE_TSCONFIG = '{ "include": ["src", "../../../vendor/**"] }\n' + +const UNPARSABLE_TSCONFIG = '{ "include": ["../shared/**"\n' + +const WRITTEN_ROOT = [ + '{', + ' "extends": "./tsconfig.base.json",', + ' "references": [', + ' {', + ' "path": "./packages/a",', + ' "prepend": false', + ' },', + ' {', + ' "path": "../../../outside-lib"', + ' }', + ' ],', + ' "include": [', + ' "src/**/*.ts",', + ' "../../../shared/**/*.ts"', + ' ],', + ' "exclude": [', + ' "dist"', + ' ],', + ' "compilerOptions": {', + ' "strict": true', + ' }', + '}', +].join('\n') + +const WRITTEN_BASE = ['{', ' "files": [', ' "../../../shared/types.d.ts",', ' "src/env.d.ts"', ' ]', '}'] + .join('\n') + +const WRITTEN_PACKAGE = ['{', ' "include": [', ' "src",', ' "../../../../../vendor/**"', ' ]', '}'].join('\n') + +const TSCONFIG_TREE: FixtureFiles = { + 'src/math.ts': SOURCE, + 'test/math.test.mjs': TEST, + 'tsconfig.json': ROOT_TSCONFIG, + 'tsconfig.base.json': BASE_TSCONFIG, + 'packages/a/tsconfig.json': PACKAGE_TSCONFIG, +} + +const UNPARSABLE_TREE: FixtureFiles = { ...TSCONFIG_TREE, 'tsconfig.json': UNPARSABLE_TSCONFIG } + +const OPTIONS: Options.PartialStrykerOptions = { + testRunner: 'vm', + testFiles: ['test/**/*.mjs'], + mutate: ['src/**/*.ts'], + reporters: [], + checkers: [], + tempDirName: TEMP_DIR_NAME, + cleanTempDir: false, + symlinkNodeModules: false, +} + +const OUTSIDE: FixtureFiles = { + 'outside-lib/tsconfig.json': '{}\n', + 'shared/types.d.ts': 'export {}\n', +} + +const TSCONFIGS = ['tsconfig.json', 'tsconfig.base.json', 'packages/a/tsconfig.json'] as const + +const tsconfigsOf = (files: FixtureFiles): FixtureFiles => + Object.fromEntries(TSCONFIGS.flatMap((file) => Object.entries(files).filter(([name]) => name === file))) + +const writeFiles = (directory: string, files: FixtureFiles) => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + yield* Effect.forEach( + Object.entries(files), + ([file, content]) => + fs.makeDirectory(path.dirname(path.join(directory, file)), { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(path.join(directory, file), content)), + ), + { discard: true }, + ) + }) + +const writeProject = ( + files: FixtureFiles, +): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fs.realPath(yield* fs.makeTempDirectory({ prefix: 'stryker-sandbox-tsconfig-' })) + const project = path.join(root, 'project') + yield* writeFiles(root, OUTSIDE) + yield* writeFiles(project, files) + return { root, project, warnings: [] } + }) + +const removeProject = (root: string): Effect.Effect => + Effect.ignore(FileSystem.FileSystem.pipe(Effect.flatMap((fs) => fs.remove(root, { recursive: true })))) + +const warningLogger = (fixture: ProjectFixture): Layer.Layer => + Logger.layer([ + Logger.make((entry) => { + if (entry.logLevel === 'Warn') fixture.warnings.push([entry.message].flat().map(String).join(' ')) + }), + ]) + +const runFromProject = ( + fixture: ProjectFixture, + options: Options.PartialStrykerOptions, +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = globalThis.process.cwd() + globalThis.process.chdir(fixture.project) + return previous + }), + () => + Effect.map(Effect.result(Engine.strykerCell({ ...OPTIONS, ...options })), Result.isSuccess).pipe( + Effect.provide(warningLogger(fixture)), + ), + (previous) => + Effect.sync(() => { + globalThis.process.chdir(previous) + }), + ) + +const readPresent = (directory: string, files: ReadonlyArray) => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const present = yield* Effect.filter(files, (file) => fs.exists(path.join(directory, file))) + const contents = yield* Effect.forEach(present, (file) => + Effect.map(fs.readFileString(path.join(directory, file)), (content) => [file, content] as const)) + return Object.fromEntries(contents) + }) + +const sandboxOf = (project: string) => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const tempDir = path.join(project, TEMP_DIR_NAME) + const entries = yield* Boolean.match(yield* fs.exists(tempDir), { + onTrue: () => fs.readDirectory(tempDir), + onFalse: () => Effect.succeed>([]), + }) + return entries.filter((entry) => entry.startsWith('sandbox-')).map((entry) => path.join(tempDir, entry)) + }) + +const observeRun = ( + fixture: ProjectFixture, + files: FixtureFiles, + options: Options.PartialStrykerOptions, +): Effect.Effect => + Effect.gen(function*() { + const succeeded = yield* runFromProject(fixture, options) + const sandboxes = yield* sandboxOf(fixture.project) + const sandbox = yield* Effect.forEach(sandboxes, (directory) => readPresent(directory, TSCONFIGS)) + return { + succeeded, + sandboxes: sandboxes.length, + sandbox: Object.fromEntries(sandbox.flatMap((written) => Object.entries(written))), + onDisk: yield* readPresent(fixture.project, Object.keys(files)), + } + }).pipe(Effect.ensuring(removeProject(fixture.root))) + +const runLayer = Layer.mergeAll(Engine.nodePlatformLayer, Stdio.layerTest({})) + +Feature('Pointing the tsconfig files a mutation run copies into its sandbox back at the project') + .withLayer(runLayer) + .live('the scenarios run real mutation runs inside a real project directory and read the sandbox they leave behind') + .body(({ scenario }) => { + scenario( + 'Entries that leave the project move two levels up and every followed tsconfig is rewritten', + Gherkin.Do.pipe( + Given('a tsconfig that extends one file and references one package inside the project')( + 'project', + () => writeProject(TSCONFIG_TREE), + ), + When('a mutation run keeps its sandbox')( + 'observation', + (s) => observeRun(s.project, TSCONFIG_TREE, { tsconfigFile: 'tsconfig.json' }), + ), + Then('the sandbox holds the text the pre-refactor writer produced and the project keeps its originals')( + (s, expect) => + expect({ + succeeded: s.observation.succeeded, + sandboxes: s.observation.sandboxes, + root: s.observation.sandbox['tsconfig.json'], + base: s.observation.sandbox['tsconfig.base.json'], + package: s.observation.sandbox['packages/a/tsconfig.json'], + onDisk: s.observation.onDisk, + }).toEqual({ + succeeded: true, + sandboxes: 1, + root: WRITTEN_ROOT, + base: WRITTEN_BASE, + package: WRITTEN_PACKAGE, + onDisk: TSCONFIG_TREE, + }), + ), + ), + ) + + scenario( + 'A tsconfig the project does not hold is skipped', + Gherkin.Do.pipe( + Given('a run configured with a tsconfig file that does not exist')( + 'project', + () => writeProject(TSCONFIG_TREE), + ), + When('a mutation run keeps its sandbox')( + 'observation', + (s) => observeRun(s.project, TSCONFIG_TREE, { tsconfigFile: 'tsconfig.missing.json' }), + ), + Then('every tsconfig is copied as it is and nothing about tsconfig warns')( + (s, expect) => + expect({ + sandboxes: s.observation.sandboxes, + sandbox: s.observation.sandbox, + tsconfigWarnings: s.project.warnings.filter((warning) => warning.includes('tsconfig')), + }).toEqual({ sandboxes: 1, sandbox: tsconfigsOf(TSCONFIG_TREE), tsconfigWarnings: [] }), + ), + ), + ) + + scenario( + 'An unparsable tsconfig is copied as it is with a warning', + Gherkin.Do.pipe( + Given('a project whose tsconfig is not valid JSON')('project', () => writeProject(UNPARSABLE_TREE)), + When('a mutation run keeps its sandbox')( + 'observation', + (s) => observeRun(s.project, UNPARSABLE_TREE, { tsconfigFile: 'tsconfig.json' }), + ), + Then('the sandbox copy keeps the original text and one warning names the file')( + (s, expect) => + expect({ + sandboxes: s.observation.sandboxes, + copy: s.observation.sandbox['tsconfig.json'], + warnings: s.project.warnings.filter((warning) => + warning.startsWith(`Could not rewrite tsconfig file "${s.project.project}/tsconfig.json": `) + ).length, + }).toEqual({ sandboxes: 1, copy: UNPARSABLE_TSCONFIG, warnings: 1 }), + ), + ), + ) + + scenario( + 'An in-place run leaves the tsconfig unrewritten', + Gherkin.Do.pipe( + Given('a tsconfig whose entries would leave a sandbox')('project', () => writeProject(TSCONFIG_TREE)), + When('the mutation run works in place')( + 'observation', + (s) => observeRun(s.project, TSCONFIG_TREE, { tsconfigFile: 'tsconfig.json', inPlace: true }), + ), + Then('the run succeeds and every tsconfig on disk keeps its original text')( + (s, expect) => + expect({ + succeeded: s.observation.succeeded, + tsconfigs: tsconfigsOf(s.observation.onDisk), + }).toEqual({ succeeded: true, tsconfigs: tsconfigsOf(TSCONFIG_TREE) }), + ), + ), + ) + }) From 4a81f8bf050a2c39de8b081190d3f1e2b73465e8 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Sat, 10 Oct 2026 04:36:21 +0000 Subject: [PATCH 5/5] docs(plans): supersede the grab-bag plan with the U2 contract as shipped U1 executed against 2026-10-09-1840, so the corrected contract is a new dated plan declaring supersedes: KTD4 records ReadonlyMap with fresh maps and withPreprocessedFiles in Project.schema.ts (ADR-0002) instead of HashMap and a merge workflow; U2 lists the boundary suite, the one-tsconfig cell and the blueprint-owned follow walk. --- ...31-refactor-grab-bag-decomposition-plan.md | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/plans/2026-10-10-0431-refactor-grab-bag-decomposition-plan.md b/docs/plans/2026-10-10-0431-refactor-grab-bag-decomposition-plan.md index 5b39be06c..cdb1ccee1 100644 --- a/docs/plans/2026-10-10-0431-refactor-grab-bag-decomposition-plan.md +++ b/docs/plans/2026-10-10-0431-refactor-grab-bag-decomposition-plan.md @@ -1,7 +1,8 @@ --- title: Decompose the Grab-Bag Modules - Plan type: refactor -date: 2026-10-09 +date: 2026-10-10 +supersedes: docs/plans/2026-10-09-1840-refactor-grab-bag-decomposition-plan.md topic: grab-bag-decomposition artifact_contract: ce-unified-plan/v1 product_contract_source: ce-brainstorm @@ -209,7 +210,7 @@ Entries 1-11 are planned in full as U1-U11 below. Entries 12-31 keep this table; - **KTD1. A pure operation two modules share lives in the schema file of the type it operates on, and both import it.** Checker's `commandFailed` and `plansByIdOf`, for example, go into `Checker/Checker.schema.ts`, which `check.cell.ts` and `group.cell.ts` import. A shared helper whose signature names no type the series declares gets that type, declared in the schema file of its domain; it is never copied. A decision that chooses an outcome stays a workflow (Key Decision on pure operations, ADR-0002). Governs R5. - **KTD2. A workflow may run inside a loop by being called from a cell's read or write.** A per-file or per-directory decision (tsconfig rewrite, directory classification, candidate files) is called from the cell that walks, not wrapped in a cell per item (P16). - **KTD3. When several I/O steps are gated by flags, one `plan-*` workflow decides which steps run, and the cell that owns the steps acts on it.** `plan-sandbox-acquisition` (U3) and `plan-report-persistence` (U8) follow this. One table-driven decision keeps the gates in the mutation population without a workflow per boolean. -- **KTD4. R6 is enforced by type for direct writes.** `Project.files` and `Project.filesToMutate` become immutable `HashMap.HashMap` (`Project.schema.ts:17-18`), `TsConfigSchema` loses `S.mutableKey`/`S.mutable`, and `FilePreprocessor` returns a `Project`. A property assignment or `MutableHashMap.set` on a caller's record then fails to compile (harness rule OP7). `Object.assign` still compiles over `readonly` fields, so U2 removes the one at `Sandbox.blueprint.ts:149` and its verification greps for any other; no new lint or CI check is added (R17). The cost is a wide U2 diff over every `MutableHashMap` read of a `Project`. Governs R6. +- **KTD4. R6 is enforced by type for direct writes.** `Project.files` and `Project.filesToMutate` become `ReadonlyMap` (`Project.schema.ts:17-18`), `TsConfigSchema` loses `S.mutableKey`/`S.mutable`, and `FilePreprocessor` returns a `Project`. Every operation that changes a `Project` builds fresh maps; the merge of preprocessed files is `withPreprocessedFiles` in `Project.schema.ts`, a pure operation over the type in its schema (ADR-0002), so no `merge-preprocessed-files.workflow.ts` exists. A property assignment or `.set` on a caller's record then fails to compile (harness rule OP7). `ReadonlyMap` is a type-level guarantee: a holder of the concrete `Map` could still mutate it, so the `withPreprocessedFiles` law asserts the caller's maps are unchanged and the returned maps are new objects. `Object.assign` still compiles over `readonly` fields, so U2 removes the one at `Sandbox.blueprint.ts:149` and its verification greps for any other; no new lint or CI check is added (R17). Governs R6. - **KTD5. The reporter registry is one typed list in the `Reporter` layer.** Each entry is a `BuiltinReporter` declaring `name`, `stdout` and a factory built from the reporter's own cell (today's `builtin` record at `reporter.service.ts:51-58`). Stdout membership reaches `planReporters` as command data, not a schema literal. The two protocol roles stay schema literals: `progress-stream` (the machine stream) and `clear-text` (its human substitute). Each reporter cell builds its own `ReporterFailed` mapper from its name, so `reporter-failures.ts` goes. Governs R11, R12. - **KTD6. `html` stays host-injected** through `RunEnvironment.builtinReporters` (`bin/main.ts:221`, `Serve/Serve.cell.ts:116`, `run/run-stages.ts:24`) and is not a stdout reporter. The registry admits host entries alongside its own. Governs R13. - **KTD7. Public API breaks are taken with major changesets** (BREAK-1): `Checker.checkGroupedPlans` becomes `Checker.checkGroupedCell` (U1), and `ImportClosure.analyzeImportClosure` becomes the closure cell (U5). Each regenerates `packages/stryker-js/etc/stryker-js.api.md`. @@ -321,14 +322,14 @@ Paths are under `packages/stryker-js/` unless stated. Every unit runs `pnpm lint **Files:** -- Modify `src/Project.schema.ts` (`files` and `filesToMutate` become `HashMap.HashMap`; add a `ProjectFile` schema for workflow commands), `src/read-project.cell.ts:428-449`, `src/run/instrument.ts:59-67,155-158,184-207`, `src/Sandbox.blueprint.ts:40-318`, `src/Sandbox.schema.ts:14-35`, the `Project` readers in P15 (`src/mutation-reporting.service.ts:271-280`, `src/run/dry-run-choice.ts`, `src/run/dry-run.cell.ts`, `src/run/incremental-reuse.cell.ts`, `src/run/mutation-test-plan.cell.ts`, `src/run/prepare.ts`), `etc/stryker-js.api.md` (`Engine.Project` and `Engine.ProjectFile` change, `etc/stryker-js.api.md:19`), and a `.changeset` entry (major). -- Create `src/merge-preprocessed-files.workflow.ts`, `src/rewrite-sandbox-tsconfig.workflow.ts`, `src/disable-type-checks.cell.ts`, `src/sandbox-tsconfig.cell.ts`, `src/__tests__/merge-preprocessed-files.workflow.property.test.ts`, `src/__tests__/rewrite-sandbox-tsconfig.workflow.property.test.ts`. +- Modify `src/Project.schema.ts` (`files` and `filesToMutate` become `ReadonlyMap`; a production `ProjectFile` schema; `withPreprocessedFiles` with its laws), `src/read-project.cell.ts:428-449`, `src/run/instrument.ts:59-67,155-158,184-207`, `src/Sandbox.blueprint.ts:40-318` (the disableTypeChecks preprocessor and the tsconfig follow walk stay here until U3 splits the blueprint), `src/Sandbox.schema.ts:14-35` (with laws for `referencedEntriesOf` and `entriesOf`), the `Project` readers in P15 (`src/mutation-reporting.service.ts:271-280`, `src/run/dry-run-choice.ts`, `src/run/dry-run.cell.ts`, `src/run/incremental-reuse.cell.ts`, `src/run/mutation-test-plan.cell.ts`, `src/run/prepare.ts`), `etc/stryker-js.api.md` (`Engine.Project` and `Engine.ProjectFile` change, `etc/stryker-js.api.md:19`), and a `.changeset` entry (major). +- Create `src/rewrite-sandbox-tsconfig.workflow.ts`, `src/sandbox-tsconfig.cell.ts`, `src/__tests__/rewrite-sandbox-tsconfig.workflow.property.test.ts`, `tests/sandbox-tsconfig.integration.test.ts`. **Approach:** - `FilePreprocessor` takes and returns a `Project`; `combinePreprocessors` folds instead of looping for effect. `acquireSandbox` writes the folded project and hands nothing back to its caller (P15). -- `mergePreprocessedFiles` decides, from the project's file list, its mutate set and the updated files, the new `files` and `filesToMutate`: an updated file replaces its entry in `files`, and in `filesToMutate` only when it was already there (`Sandbox.blueprint.ts:47-53`). `disable-type-checks.cell.ts` reads the matched files and runs `Instrument.disableTypeChecks` (read), decides with the merge, and returns the new `Project` (write). -- `rewriteSandboxTsconfig` decides one tsconfig's rewrite. Its command carries the decoded config, `basePath`, and every reference the read resolved to an absolute path (resolution needs `Path.Path`, so it stays on the read side). The workflow decides which references escape `basePath` from those resolved paths, with the test the rewriter applies today (`:295-308`), so the escape classification is a mutated decision. The decision is the rewritten config plus the extends entries and project references to follow, with `/tsconfig.json` appended to a reference unless it ends in `.json` (`Sandbox.blueprint.ts:246-250`). Escaping references become `../../` plus the canonical name (`:295-308`). `sandbox-tsconfig.cell.ts` reads and parses the file, decides, writes a new `ProjectFile`, and recurses into the follow list from its write (KTD2). A parse failure logs the same warning as `:166-168`. +- `withPreprocessedFiles` (`Project.schema.ts`) returns a `Project` with fresh maps: an updated file replaces its entry in `files`, and in `filesToMutate` only when it was already there (`Sandbox.blueprint.ts:47-53`); an update whose name `files` lacks is added to `files` only, as `MutableHashMap.set` did. The disableTypeChecks preprocessor reads the matched files, runs `Instrument.disableTypeChecks`, and returns `withPreprocessedFiles(project, updates)`. +- `rewriteSandboxTsconfig` decides one tsconfig's rewrite. Its command carries the decoded config and, for every referenced entry, its path relative to `basePath` (resolution needs `Path.Path`, so it stays on the cell's read). The workflow decides which entries escape (`startsWith('..')`, the test at `:295-308`) and rewrites each escaping string in `include`, `exclude`, `files`, `extends` and `references[].path` to `../../` plus its canonical name. The decision is the rewritten config plus the follow list: the non-escaping extends entries and references, with `/tsconfig.json` appended to a reference unless it ends in `.json` (`Sandbox.blueprint.ts:246-250`). `sandbox-tsconfig.cell.ts` rewrites one tsconfig: it reads and parses the file, decides, and returns the rewritten `ProjectFile` and the resolved follow list; a parse failure logs the warning from `:166-168` and returns nothing to follow. The blueprint walks the follow list depth-first and rewrites followed files before the parent's write, the pre-refactor order; a cell never runs another cell. - The `&&` and ternary helpers moved into workflows are restated with `Option`/`Match` to meet complexity 1 (P2). - Operations over `TSConfig` and `ProjectFile` that a cell and a workflow both use go in `Sandbox.schema.ts` and `Project.schema.ts` (KTD1); none is copied. @@ -336,13 +337,15 @@ Paths are under `packages/stryker-js/` unless stated. Every unit runs `pnpm lint **Test scenarios:** -- `mergePreprocessedFiles` (AE2): for a drawn project and drawn updates naming existing files, each updated name maps to its update in `files`; it is in `filesToMutate` iff it was before; every other entry is identical; no name is added. Fails if an update is added to `filesToMutate` unconditionally (the `onNone` arm at `:50`). +- `withPreprocessedFiles` (AE2, in-source law in `Project.schema.ts`): for a drawn project and drawn updates, including names `files` lacks, the caller's maps keep the entries snapshotted before the call, the returned maps are new objects, each updated name maps to its last update in `files`, it is in `filesToMutate` iff it was before, and every other entry is identical. Fails on an in-place merge, or if an update is added to `filesToMutate` unconditionally (the `onNone` arm at `:50`). +- `referencedEntriesOf` and `entriesOf` (in-source laws in `Sandbox.schema.ts`): the referenced entries are `include`, `exclude`, `files`, `extends` and reference paths in that order, an absent array contributing nothing. - `rewriteSandboxTsconfig`, escape law: the draw holds resolved reference paths both inside and outside a drawn `basePath`. A string in `include`, `exclude`, `files`, `extends` or `references[].path` becomes `../../` plus its canonical name iff its resolved path escapes `basePath`, and every other string is unchanged. Fails if `files` is skipped, a non-escaping entry is rewritten, or an escaping one is kept. - `rewriteSandboxTsconfig`, shape law: keys outside those five round-trip unchanged (the index signature at `Sandbox.schema.ts:14-19`), and an array holding a non-string is left as it was (`:119-120`). Fails if `compilerOptions` is dropped. - `rewriteSandboxTsconfig`, follow law: the follow list is exactly the non-escaping extends entries and the non-escaping references, with the `.json` rule applied. Fails if an escaping entry is followed or the `.json` test is inverted. Targets the survivors `5d7353b99a24bd51` and `0e47ce21d936380f` (`Sandbox.schema.ts:26,34`), unless the schema edit removes those lines, in which case the PR reports them as deleted. -- Integration, unchanged: `tests/instrument-stage.integration.test.ts`, `tests/framework-run.integration.test.ts` (disableTypeChecks fixtures), `tests/compile-error-reuse.integration.test.ts` (tsconfig, `inPlace`), `tests/clean-temp-dir.integration.test.ts`, `tests/plan-without-sandbox.integration.test.ts`, and the e2e enterprise-monorepo lane (`tsconfigFile: 'tsconfig.json'`). No integration suite asserts a rewritten tsconfig directly, so the follow and escape laws are the primary guard. +- Boundary, new: `tests/sandbox-tsconfig.integration.test.ts` runs mutation runs in-process through the published `Engine.strykerCell` (the test spawns nothing; the engine starts its own vm-runner worker, as in every existing run suite) on a real temp project whose tsconfig extends and references files inside and outside it. It asserts the exact text written into the sandbox for the root and followed tsconfigs, the missing-file skip, the unparsable-file warning, and that an in-place run leaves every tsconfig unrewritten. Tests may not import `src/` (test-discipline), so the engine is the narrowest surface that reaches the cell. The schema-derived command generator emits `TsconfigMissing`, `TsconfigUnparsable` and `TsconfigParsed`, so the property test covers all three arms. +- Integration, unchanged: `tests/instrument-stage.integration.test.ts`, `tests/framework-run.integration.test.ts` (disableTypeChecks fixtures), `tests/compile-error-reuse.integration.test.ts` (tsconfig, `inPlace`), `tests/clean-temp-dir.integration.test.ts`, `tests/plan-without-sandbox.integration.test.ts`, and the e2e enterprise-monorepo lane (`tsconfigFile: 'tsconfig.json'`). -**Verification:** no `MutableHashMap` in `Project.schema.ts`; no `Object.assign` or property assignment on a `Project`, `ProjectFile` or decoded `TSConfig` in `src/`. +**Verification:** no `MutableHashMap` in `Project.schema.ts`; no `Object.assign` or property assignment on a `Project`, `ProjectFile` or decoded `TSConfig` in `src/`. `tests/sandbox-tsconfig.integration.test.ts` runs real mutation runs (vm runner) on a temp project and asserts the sandbox tsconfig text, the missing-file skip, the unparsable warning and the in-place no-rewrite; it passes unchanged against `origin/main`. ### U3. Sandbox acquisition cells (PR 3)