Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fc8a3b3
docs(brainstorms): requirements for the TypeScript 7 native checker unit
systemfsoftware-maker Oct 9, 2026
745275b
docs(plans): implementation plan for the TypeScript 7 native checker …
systemfsoftware-maker Oct 9, 2026
63f9ad0
docs(plans): apply review dispositions and the four-layer stack
systemfsoftware-maker Oct 9, 2026
444c4ec
docs(stryker-js-typescript-checker): state the TypeScript 7 floor and…
systemfsoftware-maker Oct 9, 2026
cc1291c
docs(plans): record the root rulings on OQ-P5, OQ-P8 and OQ-P9
systemfsoftware-maker Oct 9, 2026
e59fbd5
docs(plans): record the agentic-quality standing rule in R9, U9, U10 …
systemfsoftware-maker Oct 9, 2026
43ffa7f
fix(stryker-js-typescript-checker): render each mutant's diagnostics …
systemfsoftware-maker Oct 10, 2026
1e36936
feat(stryker-js-typescript-checker): report snapshot updates, re-spli…
systemfsoftware-maker Oct 10, 2026
09236ee
Merge remote-tracking branch 'origin/main' into stryker/tsgo-checker
systemfsoftware-maker Oct 10, 2026
37918ad
test(stryker-js-typescript-checker): clear the two lint errors layer …
systemfsoftware-maker Oct 10, 2026
a2d1c22
Merge remote-tracking branch 'origin/main' into stryker/tsgo-checker
systemfsoftware-maker Oct 10, 2026
58e43d6
Merge remote-tracking branch 'origin/main' into stryker/tsgo-checker
systemfsoftware-maker Oct 10, 2026
f69ece3
Merge remote-tracking branch 'origin/main' into stryker/tsgo-checker
systemfsoftware-maker Oct 10, 2026
1c1de94
Merge remote-tracking branch 'origin/main' into stryker/tsgo-checker
systemfsoftware-maker Oct 10, 2026
8e7d073
Merge remote-tracking branch 'origin/main' into stryker/tsgo-checker
systemfsoftware-maker Oct 10, 2026
02d991e
Merge remote-tracking branch 'origin/main' into stryker/tsgo-checker
systemfsoftware-maker Oct 10, 2026
e22a2fa
Merge remote-tracking branch 'origin/main' into stryker/tsgo-checker
systemfsoftware-maker Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/typescript-checker-check-counts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@systemfsoftware/stryker-js-typescript-checker": patch
---

With OpenTelemetry enabled, each `typescript-checker.compiler.check` span now reports how much compiler work the check took: `typescript.snapshot_updates.count`, `typescript.resplices.count`, `typescript.tce_builds.count`, `typescript.tce.ms` and `typescript.importer_shortcut.count`, versioned by `typescript.counts.schema_version` (currently `1`).
5 changes: 5 additions & 0 deletions .changeset/typescript-checker-own-positions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@systemfsoftware/stryker-js-typescript-checker": patch
---

A `CompileError` reason now names the line and column where the error sits in that mutant's own code. When several mutants were checked together, an earlier mutant's reason could point at a position computed from a later mutant's code, so the reported line was wrong.
7 changes: 7 additions & 0 deletions .changeset/typescript-checker-ts7-only-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@systemfsoftware/stryker-js-typescript-checker": patch
---

The README now states the TypeScript floor the checker enforces: TypeScript `>=7.0.0`, not 5.x. Older compilers were already refused at startup.

Correction to the 8.1.0 notes: files that import a mutated file are not checked "only when the mutant changes what that file exports". They are re-checked whenever the mutated file has no compile errors of its own, whatever the mutant changed.

Large diffs are not rendered by default.

628 changes: 628 additions & 0 deletions docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/stryker-js-typescript-checker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](../../LICENSE)
[![npm version](https://img.shields.io/npm/v/@systemfsoftware/stryker-js-typescript-checker.svg)](https://www.npmjs.com/package/@systemfsoftware/stryker-js-typescript-checker)

TypeScript checker plugin for Stryker mutation testing — provides native support for TypeScript 5.x through 7.x.
TypeScript checker plugin for Stryker mutation testing, built on the native TypeScript 7 compiler.

## Why Use a Type Checker Plugin?

Expand All @@ -14,7 +14,7 @@ The TypeScript checker intercepts mutants _before_ they reach the test runner. I
## Prerequisites

- Node.js `>=22.18.0`
- TypeScript `>=5.0.0`
- TypeScript `>=7.0.0`
- `@systemfsoftware/stryker-js` `>=5.0.0`

## Install
Expand Down
39 changes: 14 additions & 25 deletions packages/stryker-js-typescript-checker/src/Checker.cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { Sandwich } from '@systemfsoftware/effect-cell-types'
import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract'
import { ErrorText } from '@systemfsoftware/stryker-js-instrumenter'
import { Checker, Mutant } from '@systemfsoftware/stryker-js-plugin-interface'
import * as Arr from 'effect/Array'
import * as Effect from 'effect/Effect'
import * as Option from 'effect/Option'

import { checkMutants } from './check-mutants.workflow.js'
import { CheckMutantsCommand } from './Checker.schema.js'
import { CheckMutantsInput, MutantVerdict, tceFieldOf } from './CheckMutants.schema.js'
import type { CompilerError } from './Compiler.schema.js'
import { check, describeDiagnostics, type MutantCheck, type TSCompiler } from './ts-compiler.handle.js'
import { check, type MutantCheck } from './ts-compiler.handle.js'
import { TypeScriptCompiler } from './ts-compiler.service.js'

type CheckRefusalCause = CompilerError | string
Expand All @@ -25,37 +26,25 @@ const refuse = (

export type CheckMutantsRead = (typeof CheckMutantsInput)['Encoded']

const verdictsOf = (
compiler: TSCompiler,
checked: ReadonlyArray<MutantCheck>,
): Effect.Effect<ReadonlyArray<MutantVerdict>> =>
Effect.forEach(
checked,
(entry) =>
Effect.map(
describeDiagnostics(compiler, entry.diagnostics),
(diagnostics) =>
MutantVerdict.make({
id: entry.mutantId,
diagnostics: [...diagnostics],
...tceFieldOf(entry.tce),
}),
),
{ concurrency: 1 },
)
const verdictsOf = (checked: ReadonlyArray<MutantCheck>): ReadonlyArray<MutantVerdict> =>
Arr.map(checked, (entry) =>
MutantVerdict.make({
id: entry.mutantId,
diagnostics: [...entry.diagnostics],
...tceFieldOf(entry.tce),
}))

export const checkCell = Sandwich.named(SpanTaxonomy.Spans.typescriptCheckerCheckMutants.name)((
command: CheckMutantsCommand,
) =>
Effect.flatMap(
TypeScriptCompiler,
(compiler) =>
Effect.flatMap(check(compiler, [...command.mutants]), (checked) =>
Effect.map(
verdictsOf(compiler, checked),
(verdicts): CheckMutantsRead =>
CheckMutantsInput.make({ mutants: [...command.mutants], verdicts: [...verdicts] }),
)),
Effect.map(
check(compiler, [...command.mutants]),
(checked): CheckMutantsRead =>
CheckMutantsInput.make({ mutants: [...command.mutants], verdicts: [...verdictsOf(checked)] }),
),
).pipe(
Effect.mapError((cause) => refuse({ mutantIds: command.mutants.map((mutant) => mutant.id), cause })),
)
Expand Down
83 changes: 55 additions & 28 deletions packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { offsetAt } from '@systemfsoftware/stryker-js-instrumenter'
import { Checker, type Options } from '@systemfsoftware/stryker-js-plugin-interface'
import * as Arr from 'effect/Array'
import * as Boolean from 'effect/Boolean'
import * as Duration from 'effect/Duration'
import * as Effect from 'effect/Effect'
import * as FileSystem from 'effect/FileSystem'
import { dual } from 'effect/Function'
Expand All @@ -16,6 +17,7 @@ import * as Option from 'effect/Option'
import * as Order from 'effect/Order'
import * as Path from 'effect/Path'
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner'
import * as Ref from 'effect/Ref'
import * as Result from 'effect/Result'
import * as S from 'effect/Schema'
import * as SynchronizedRef from 'effect/SynchronizedRef'
Expand Down Expand Up @@ -129,6 +131,15 @@ interface CompilerState {
readonly tsconfigFile: string
}

interface CheckTally {
readonly snapshotUpdates: number
readonly resplices: number
readonly tceBuilds: number
readonly tceMs: number
}

const emptyTally: CheckTally = { snapshotUpdates: 0, resplices: 0, tceBuilds: 0, tceMs: 0 }

interface TSCompilerRuntime {
readonly options: Options.StrykerOptions
readonly host: FileSystem.FileSystem
Expand All @@ -137,6 +148,7 @@ interface TSCompilerRuntime {
readonly files: TSFiles
readonly sourceFileSystem: TSFileSystem
readonly state: SynchronizedRef.SynchronizedRef<CompilerState>
readonly tally: Ref.Ref<CheckTally>
}

const TSCompiler = Handle.make<object, TSCompilerRuntime>()(TypeId)
Expand Down Expand Up @@ -189,6 +201,7 @@ export const make: {
allTSConfigFiles: HashSet.fromIterable([tsconfigFile]),
tsconfigFile,
})
const tally = yield* Ref.make(emptyTally)
return TSCompiler.make({}, {
options,
host: services.host,
Expand All @@ -197,6 +210,7 @@ export const make: {
files,
sourceFileSystem: tsFileSystem(files),
state,
tally,
})
}),
)
Expand Down Expand Up @@ -858,7 +872,11 @@ const parenthesizedSpliceOf = (
fileName: string,
changedFiles: ReadonlyArray<string>,
): Effect.Effect<void, CompilerFailed> =>
Effect.flatMap(applyMutant(rt, mutant, '(' + mutant.replacement + ')'), () => refreshSnapshot(rt, changedFiles))
Effect.flatMap(applyMutant(rt, mutant, '(' + mutant.replacement + ')'), () =>
Effect.andThen(
Ref.update(rt.tally, (tally) => ({ ...tally, resplices: tally.resplices + 1 })),
refreshSnapshot(rt, changedFiles),
))

const applyMutant = (
rt: TSCompilerRuntime,
Expand Down Expand Up @@ -906,6 +924,7 @@ const updateSnapshot = Effect.fnUntraced(function*(
)
yield* Effect.promise(() => initialized.snapshot.dispose())
yield* SynchronizedRef.update(rt.state, (prev) => ({ ...prev, snapshot: next }))
yield* Ref.update(rt.tally, (tally) => ({ ...tally, snapshotUpdates: tally.snapshotUpdates + 1 }))
})

const refreshSnapshot = (rt: TSCompilerRuntime, changedFiles: ReadonlyArray<string>): Effect.Effect<void> =>
Expand All @@ -918,15 +937,15 @@ const refreshSnapshot = (rt: TSCompilerRuntime, changedFiles: ReadonlyArray<stri
}),
)

const annotateDiagnosticSample = (diagnostics: readonly Diagnostic[]): Effect.Effect<void> =>
const annotateDiagnosticSample = (diagnostics: readonly DiagnosticDecoded[]): Effect.Effect<void> =>
Boolean.match(diagnostics.length === 0, {
onTrue: () => Effect.void,
onFalse: () =>
Effect.annotateCurrentSpan({
'typescript.diagnostics.sample': Arr
.map(
Arr.take(diagnostics, 10),
(diagnostic) => diagnostic.fileName + ':' + diagnostic.code + ': ' + diagnostic.text,
(diagnostic) => String(diagnostic.fileName) + ':' + diagnostic.code + ': ' + diagnostic.text,
)
.join('; '),
}),
Expand Down Expand Up @@ -1365,7 +1384,7 @@ interface OwnedSourceFile {

export interface MutantCheck {
readonly mutantId: Checker.CheckerMutantWire['id']
readonly diagnostics: ReadonlyArray<Diagnostic>
readonly diagnostics: ReadonlyArray<DiagnosticDecoded>
readonly tce?: TceClassification
}

Expand Down Expand Up @@ -1432,44 +1451,31 @@ const affectedFileNamesOf = (
return HashSet.fromIterable(Arr.map(affected, (affectedFile) => affectedFile.fileName))
}

const importerErrorsOfMutant = (
state: CompilerState,
projects: ReadonlyArray<Project>,
mutant: Checker.CheckerMutantWire,
fileName: string,
): Effect.Effect<MutantCheck> =>
Effect.map(
importerErrorsOf(projects, affectedFileNamesOf(state, [fileName])),
(diagnostics): MutantCheck => ({ mutantId: mutant.id, diagnostics }),
)

const beyondOwnErrorsOf = (
state: CompilerState,
projects: ReadonlyArray<Project>,
mutant: Checker.CheckerMutantWire,
owned: OwnedSourceFile,
fileName: string,
): Effect.Effect<MutantCheck> =>
): Effect.Effect<ReadonlyArray<Diagnostic>> =>
Boolean.match(declaresGlobalScope(owned.sourceFile), {
onTrue: () =>
Effect.map(wholeProgramErrorsOf(projects), (diagnostics): MutantCheck => ({ mutantId: mutant.id, diagnostics })),
onFalse: () => importerErrorsOfMutant(state, projects, mutant, fileName),
onTrue: () => wholeProgramErrorsOf(projects),
onFalse: () => importerErrorsOf(projects, affectedFileNamesOf(state, [fileName])),
})

const checkedIn = (
state: CompilerState,
projects: ReadonlyArray<Project>,
mutant: Checker.CheckerMutantWire,
owned: OwnedSourceFile,
fileName: string,
): Effect.Effect<MutantCheck> =>
): Effect.Effect<ReadonlyArray<Diagnostic>> =>
Effect.flatMap(ownErrorsOf(owned, fileName), (own) =>
Boolean.match(own.length > 0, {
onTrue: () => Effect.succeed<MutantCheck>({ mutantId: mutant.id, diagnostics: own }),
onFalse: () => beyondOwnErrorsOf(state, projects, mutant, owned, fileName),
onTrue: () => Effect.succeed<ReadonlyArray<Diagnostic>>(own),
onFalse: () => beyondOwnErrorsOf(state, projects, owned, fileName),
}))

const checkOne = (
self: TSCompiler,
rt: TSCompilerRuntime,
state: CompilerState,
mutant: Checker.CheckerMutantWire,
Expand All @@ -1490,10 +1496,12 @@ const checkOne = (
})
const projects = yield* projectsOf(rt)
const owned = yield* projectOfFile(projects, fileName)
return yield* Option.match(owned, {
onNone: () => Effect.succeed<MutantCheck>({ mutantId: mutant.id, diagnostics: [] }),
onSome: (found) => checkedIn(state, projects, mutant, found, fileName),
const diagnostics = yield* Option.match(owned, {
onNone: (): Effect.Effect<ReadonlyArray<Diagnostic>> => Effect.succeed([]),
onSome: (found) => checkedIn(state, projects, found, fileName),
})
const rendered = yield* describeDiagnostics(self, diagnostics)
return { mutantId: mutant.id, diagnostics: rendered }
})

interface CheckAccumulator {
Expand Down Expand Up @@ -1564,6 +1572,17 @@ const emitForFile = (
Effect.provideService(FileSystem.FileSystem, rt.host),
Effect.provideService(Path.Path, rt.pathService),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, rt.spawner),
Effect.timed,
Effect.flatMap(([elapsed, emitted]) =>
Effect.as(
Ref.update(rt.tally, (tally) => ({
...tally,
tceBuilds: tally.tceBuilds + 1,
tceMs: tally.tceMs + Duration.toMillis(elapsed),
})),
emitted,
)
),
)

const tceScriptOf = <A>(file: Option.Option<A>, fileName: string): Result.Result<A, CompilerFailed> =>
Expand Down Expand Up @@ -1650,6 +1669,7 @@ export const check: {
'stryker.mutants.count': mutants.length,
'stryker.mutants.ids': Arr.map(mutants, (mutant) => mutant.id).join(','),
})
yield* Ref.set(rt.tally, emptyTally)
const state = yield* SynchronizedRef.get(rt.state)
yield* resetMutatedFiles(rt, state.lastMutants)
const batchFileNames = Arr.dedupe(Arr.map(mutants, (mutant) => resolveFileName(rt, mutant.fileName)))
Expand All @@ -1659,7 +1679,7 @@ export const check: {
(): CheckAccumulator => ({ previous: Option.none(), results: [] }),
(previous, mutant) =>
Effect.map(
checkOne(rt, state, mutant, Option.toArray(previous.previous)),
checkOne(self, rt, state, mutant, Option.toArray(previous.previous)),
(result): CheckAccumulator => ({ previous: Option.some(mutant), results: [...previous.results, result] }),
),
)
Expand All @@ -1672,11 +1692,18 @@ export const check: {
const failed = Arr.filter(checked, (entry) => entry.diagnostics.length > 0)
const equivalentToOriginal = Arr.filter(checked, (entry) => entry.tce === 'original').length
const duplicateAtSite = Arr.filter(checked, (entry) => entry.tce === 'sibling').length
const tally = yield* Ref.get(rt.tally)
yield* Effect.annotateCurrentSpan({
'typescript.diagnostics.count': Arr.reduce(checked, 0, (total, entry) => total + entry.diagnostics.length),
'typescript.compile_errors.count': failed.length,
'typescript.tce.equivalent_to_original.count': equivalentToOriginal,
'typescript.tce.duplicate_at_site.count': duplicateAtSite,
'typescript.counts.schema_version': 1,
'typescript.snapshot_updates.count': tally.snapshotUpdates,
'typescript.resplices.count': tally.resplices,
'typescript.tce_builds.count': tally.tceBuilds,
'typescript.tce.ms': tally.tceMs,
'typescript.importer_shortcut.count': 0,
})
yield* annotateDiagnosticSample(Arr.flatten(Arr.map(failed, (entry) => entry.diagnostics)))
return checked
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export function pad(a: number): void {
let _b = a + 1
_b = _b + 1
_b = _b + 1
_b = _b + 1
}

export function keep(): string {
return 'kept'
}

export function also(): void {
pad(2)
}

export const label = keep()

export const first = 1
export const second = 2
export const broken: number = 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"strict": true,
"module": "esnext",
"moduleResolution": "bundler",
"target": "es2022",
"noEmit": true,
"skipLibCheck": true,
"types": []
},
"include": ["*.ts"]
}
Loading
Loading