A Standard Schema compliant, functional validation library, paired with an AOT type compiler that materializes inferred types into static .ts files.
Status: the runtime, the AOT compiler (sidecar and experimental in-place rewrite), watch mode, the experimental JSON Schema emitter, and the Vite plugin all work end-to-end and are covered by unit, type-level, and real-checker integration tests. In-place rewrite and JSON Schema output are opt-in/experimental.
tskm is two things that fit together:
-
A runtime validation library. Valibot-style, fully functional, class-free, tree-shakeable, ESM-only, with zero runtime dependencies. Schemas are plain objects created by factory functions. They implement the Standard Schema
~standardinterface, so they interoperate with the Zod / Valibot / ArkType ecosystem (tRPC, TanStack, form libraries). -
An AOT (ahead-of-time) type compiler. Instead of deriving a type from a schema with
z.infer<typeof schema>at every use site, tskm pre-computes it once and writes the fully expanded, concrete type to a real file. The answer comes from the actual TypeScript type checker, queried out of process.
It is a bun-workspaces monorepo: @tskm/core (runtime), @tskm/compiler (AOT codegen + CLI), @tskm/vite (Vite plugin).
z.infer<typeof schema> (and Valibot's InferOutput) is a type-level computation. For deeply nested schemas it makes the editor and tsc re-evaluate heavy conditional/mapped types on every reference. This is a well-known source of slow IDEs and long type checks.
typia attacks the cost from the other side: it generates validators from types at compile time. But it runs as a TypeScript transformer that has to patch the compiler via ts-patch (or an unplugin). That patching is fragile: it breaks on TypeScript upgrades, conflicts with other transformers, must be re-applied on install, and is invisible to the editor. (unplugin-typia was archived in 2025, partly because the native TypeScript port now exposes an IPC API.)
tskm goes the opposite direction: schema → type, not type → validator, and it never patches tsc. A standalone compiler asks the type checker for the resolved output type and writes it out as plain TypeScript. Your build pipeline is untouched, and the generated type is concrete, so consuming it costs the type system nothing.
| typia | tskm | |
|---|---|---|
| direction | type → validator | schema → type |
| integration | ts-patch / transformer |
standalone CLI over IPC |
patches tsc? |
yes | no |
| editor sees it? | no | yes (plain generated .ts) |
| runtime deps | n/a | zero (runtime package) |
Underneath the mechanics sits the actual thesis: data-oriented domain modeling. In tskm the runtime schema (a plain, inspectable object) is the domain model. The static types are compiled artifacts derived from it, never the other way around.
Inference-based libraries share that ambition but cannot hold it at the edges. The moment a model is self-referential (a category tree, an AST, a JSON document), the inferred type collapses, and the library hands the work back to you as a hand-written type plus a GenericSchema<T> annotation. That is type-first creep, exactly what data-first modeling set out to remove.
Because tskm derives types with a compiler instead of inference, the data stays the single source of truth even there: recursive() materializes the self-referential alias with zero hand-written types. Validation, transformation, JSON Schema, and the static types all keep flowing from one place, the data. The AOT compiler is not an optimization bolted onto the library; it is what makes this concept hold end to end.
Concretely, this is the difference. Inference-based recursion makes you write the type first and annotate the schema with it:
import { array, type GenericSchema, lazy, object, string } from "@tskm/core"
// The model has leaked into the type system: a hand-written type,
// then a schema annotated with it.
type CategoryT = { name: string; children: CategoryT[] }
export const categorySchema: GenericSchema<CategoryT> = object({
name: string(),
children: array(lazy(() => categorySchema)),
})With recursive() the schema is the only thing you write, and the compiler materializes the alias:
import { array, object, recursive, string } from "@tskm/core"
export const categorySchema = recursive((self) =>
object({
name: string(),
children: array(self),
}),
)// category.schema.gen.ts (generated)
// AUTO-GENERATED by tskm. Do not edit.
export type Category = {
name: string;
children: Category[]
}Validate at runtime. Schemas are values, and parse / safeParse are standalone functions:
import { object, string, number, array, pipe, minLength, parse, safeParse } from "@tskm/core"
const userSchema = object({
name: pipe(string(), minLength(2)),
age: number(),
tags: array(string()),
})
parse(userSchema, { name: "Ada", age: 36, tags: ["math"] })
// → typed output; throws a TskmError on failure
const result = safeParse(userSchema, { name: "", age: 1, tags: [] })
// → { success: false, issues: [...], warnings: [] } (discriminated result, never throws)The result always carries warnings (non-fatal "warning"-severity issues); a parse that produced only warnings still successes. See Issue severity.
Materialize the type. Run the compiler and import the generated type:
// user.schema.ts (you write this; no `type User = Infer<...>` needed)
import { object, string, number, array, pipe, minLength } from "@tskm/core"
export const userSchema = object({
name: pipe(string(), minLength(2)),
age: number(),
tags: array(string()),
})tskm gen # queries the checker, writes user.schema.gen.ts// user.schema.gen.ts (generated: concrete, zero generic cost)
// AUTO-GENERATED by tskm. Do not edit.
export type User = {
name: string
age: number
tags: string[]
}import type { User } from "./user.schema.gen"Schemas: string number boolean bigint date literal templateLiteral null_ undefined_ any unknown never_ picklist object exactObject array record tuple union discriminatedUnion optional nullable nullish lazy recursive (+ async objectAsync exactObjectAsync arrayAsync unionAsync discriminatedUnionAsync recordAsync).
Actions (via pipe): minLength maxLength length minValue maxValue integer multipleOf email url regex nonEmpty check transform brand readonly (+ checkAsync transformAsync).
Methods: pipe parse safeParse is assert fallback (+ parseAsync safeParseAsync pipeAsync).
Every issue carries a severity of "error" (the default) or "warning". Success is decided by
the absence of any "error": a parse that produced only warnings still succeeds, and the warnings
ride the result. Both safeParse branches expose warnings, so reading it never needs a success
check:
const result = safeParse(schema, input)
result.warnings // readonly Issue[], always present (empty when there are none)A transform reports a non-fatal observation through its context, which keeps the parse
successful while still surfacing the diagnostic:
pipe(
string(),
transform((value: string, ctx) => {
ctx.issue("`title` is deprecated; use `name`", "warning")
return value
}),
)The parse mode governs how the runtime treats issues end to end. The default "report" mode
collects every issue across the whole value; "reject" bails at the first error as a fast
acceptance gate (abortEarly: true is the back-compat alias). isReject(config) reads the active
mode. See examples/ssot for the warning channel in a real schema.
tskm init # write a starter tskm.config.ts
tskm gen # generate sidecar .gen.ts for every included schema
tskm gen --mode inplace # rewrite `type T = Infer<typeof X>` markers in place (experimental)
tskm watch # generate, then re-generate on change
tskm json-schema # emit JSON Schema per schema, via an isolated worker (experimental)In-place mode rewrites the Infer marker in your source instead of writing a sidecar. Before:
import { boolean, type Infer, object, string } from "@tskm/core"
export const configSchema = object({
name: string(),
debug: boolean(),
})
export type Config = Infer<typeof configSchema>After tskm gen --mode inplace, the marker line becomes a guarded block with the concrete type:
// @tskm-gen Config from configSchema #51f1206e
export type Config = {
name: string;
debug: boolean;
}
// @tskm-end ConfigFrom Vite, use @tskm/vite:
// vite.config.ts
import { tskm } from "@tskm/vite"
export default {
plugins: [tskm()],
}The plugin runs the compiler on buildStart and watches during vite dev.
The compiler never reads your schema at runtime. The inferred type only exists in the type system, so it asks the type checker directly:
-
Discover.
oxc-parserscans each source file for exportedconstfactory calls rooted in a configured schema source, and for explicittype T = Infer<typeof X>markers. The scan is purely syntactic. Schema sources come from theschemaSourcesconfig:@tskm/coreis always included, andzod/valibot/arktypeare on by default. tskm imports count as confirmed schemas; consts from external sources stay candidates until step 2 proves them. -
Query. For each schema, the compiler writes a tiny sibling file (
<base>.tskm-query.ts, deleted afterward) next to the source. It declares markers against the schema's output type:import { userSchema } from "./user.schema" type __P<T> = { [K in keyof T]: T[K] } & {} declare const __tskm_raw_0: NonNullable<(typeof userSchema)["~standard"]["types"]>["output"] declare const __tskm_pp_0: __P<NonNullable<(typeof userSchema)["~standard"]["types"]>["output"]>
This is one structural expression for every vendor, tskm included, and it needs no type import: every Standard Schema carries its inferred types on
~standard.types. An external candidate gets one extra any-guarded probe marker first, and a const that turns out not to be a Standard Schema is dropped silently.The compiler then asks the tsgo (Corsa) checker for the type at each marker (
getTypeAtPosition) and renders it fully expanded withtypeToString(no truncation, anonymous structural form). tsgo is Microsoft's native TypeScript port, driven over its IPC API via@corsa-bind/napiand the@typescript/native-previewbinary. The prettified (__P) rendering is used for top-level objects; everything else keeps the raw form, because__Papplied to a top-levelDate,Map,Set, or branded primitive would expand the entire prototype. Because the answer comes from the type system, types produced bytransform, generics, or conditional types all resolve correctly. -
Emit. The resolved type is pretty-printed deterministically and written to a sidecar
*.gen.ts, together with any imports the rendered type needs: vendor brand markers (zod's$brand, valibot'sBrand) and exported self-annotation types of recursive external schemas. An aliased re-export is rebound on import. The temporary query files are deleted, and your source is never modified.Failure is always closed. A schema that resolves to
any/unknown/neverkeeps the previous output, with a diagnostic. A file carrying external schemas is additionally verified against the real checker before it is written; a render that would not compile is reported with the unresolved identifier names, and nothing is overwritten. -
Recursive tskm schemas (
recursive()) take a structural route instead, because the plain query would collapse their self positions toanybefore the checker ever saw them. (External recursive schemas ride the plain query through the library's own exported self annotation; see Standard Schema interop.)Discovery flags
recursive()roots syntactically and routes them to an isolated, SIGKILL-guarded worker. Arecursiveimport from any configured schema source counts, not just a direct@tskm/coreone, so a re-export hub keeps working (see Standard Schema interop). That flag is only a hint: the worker imports the module and is the real authority, walking the runtime schema graph only when the value is a tskmrecursive()(its~standard.vendoris"tskm"). Anything else is skipped with a diagnostic, never emitted wrong. It uses the same identity-keyed cycle guard as the JSON Schema emitter and renders a named self-referential alias directly:export type Category = { name: string children: Category[] }
Transform outputs inside the cycle are recovered with one extra checker query: a one-level unroll of the schema's builder, with a sentinel type at the self positions. The candidate is spliced in only after both a structural data-key cross-check and a bidirectional fixpoint oracle pass. Otherwise the position stays an honest
unknown, with a path-precise diagnostic.
The checker runs as a long-lived process: it opens the project once and is fed incremental file changes, so generating many schemas stays fast. No tsc plugin, no ts-patch, no transformer in your build.
Sidecar / in-place .ts output has no fixed "supported subset". Because the type comes from the real checker, whatever the schema's inferred output type resolves to is what you get, including types produced by transform, generics, and conditional types. The only failure mode is fail-closed: if a schema resolves to any/unknown/never, the previous output is kept and a diagnostic is reported.
The experimental JSON Schema emitter is different: it walks the runtime schema structurally, so it has a documented mapping (and warns on anything it cannot represent). For example, tskm json-schema turns the recursive categorySchema from Why tskm? into a $ref/$defs document:
{
"categorySchema": {
"$ref": "#/$defs/Category",
"$defs": {
"Category": {
"type": "object",
"properties": {
"name": { "type": "string" },
"children": {
"type": "array",
"items": { "$ref": "#/$defs/Category" }
}
},
"required": ["name", "children"],
"additionalProperties": false
}
}
}
}| schema | generated .ts type |
JSON Schema (draft 2020-12) |
|---|---|---|
string number boolean |
string number boolean |
{ "type": "string" } etc. |
bigint |
bigint |
{ "type": "string" } ⚠ |
date |
Date |
{ "type": "string", "format": "date-time" } ⚠ |
literal(x) |
x (non-finite numbers widen to number ⚠) |
{ "const": x } |
templateLiteral(["p_", string()]) |
`p_${string}` (template literal type, not widened to string) |
{ "type": "string", "pattern": ... } |
picklist([...]) |
union of literals | { "enum": [...] } |
null_ undefined_ |
null undefined |
{ "type": "null" } · {} ⚠ |
any unknown never_ |
any unknown never |
{} · {} · { "not": {} } |
object({...}) |
{ k: T } (unknown keys stripped) |
{ "type": "object", "properties", "required", "additionalProperties": false } |
exactObject({...}) / object(_, { rest }) |
{ k: T } (same shape; exact rejects unknown keys, passthrough copies them through) |
additionalProperties: false (exact/strip) · true (passthrough) |
array(T) |
T[] |
{ "type": "array", "items": T } |
record(V) |
{ [key: string]: V } (open index signature; also legal inside recursive aliases) |
{ "type": "object", "additionalProperties": V } |
record(K, V) |
{ [P in K-output]?: V } (templated/picklist key, partial; a regex key widens to string ⚠) |
additionalProperties: V, propertyNames: K |
tuple([A, B]) |
[A, B] |
{ "type": "array", "prefixItems": [A, B], "items": false } |
union([A, B]) |
A | B |
{ "anyOf": [A, B] } |
discriminatedUnion("k", [A, B]) |
A | B (same shape as union; O(1) tag dispatch + .literals/.mapping metadata) |
{ "oneOf": [A, B], "x-tskm-discriminant": "k" } |
optional(T) |
T | undefined; under object(_, { optionalKeys: true }) the key is omittable (k?: T) |
inside object: T, key dropped from required; standalone: T ⚠ (drops undefined) |
nullable(T) |
T | null |
{ "anyOf": [T, { "type": "null" }] } |
nullish(T) |
T | null | undefined |
{ "anyOf": [T, { "type": "null" }] } ⚠ (drops undefined) |
lazy(() => T) |
recursive T (needs a hand-written annotation) |
$ref / $defs |
recursive((self) => ...) |
named self-referential alias, materialized | $ref / $defs (export-named) |
pipe minLength/maxLength/length/nonEmpty |
unchanged | minLength/maxLength (or minItems/maxItems) |
pipe minValue/maxValue/integer/multipleOf |
unchanged | minimum/maximum/"type":"integer"/multipleOf (numeric bases only) |
pipe email/url/regex |
unchanged | format: "email"/format: "uri"/pattern |
pipe transform/brand/check/readonly (+ checkAsync/transformAsync) |
output type (resolved) | ⚠ not representable: warns, keeps the base constraints |
⚠ = lossy or unrepresentable in JSON Schema; the emitter records a warning.
The compiler is not tskm-specific: any Standard Schema library is a type source. Discovery is hybrid. Syntactic candidates come from schemaSources imports, and an any-guarded checker probe confirms each one. The query itself is pure structure (NonNullable<(typeof x)["~standard"]["types"]>["output"]), so the same expression resolves every vendor.
A plain zod file compiles as-is, with no tskm import anywhere:
// user.schema.ts: zod stays your runtime validator
import { z } from "zod"
export const userSchema = z.object({
id: z.string(),
name: z.string(),
nameLength: z.string().transform((s) => s.length),
})
export const userIdSchema = z.string().brand<"UserId">()// user.schema.gen.ts (written by `tskm gen`)
// AUTO-GENERATED by tskm. Do not edit.
import type { $brand } from "zod"
export type User = {
id: string;
name: string;
nameLength: number;
}
export type UserId = string & $brand<"UserId">The generated nameLength is the post-transform output (number), and the branded id keeps zod's $brand marker through a generated import.
schemaSources defaults to ["zod", "valibot", "arktype"]; pass [] to opt out, or list exactly the packages you want:
// tskm.config.ts (optional; the defaults above apply without one)
export default { schemaSources: ["zod"] }| library | minimum version | .ts types |
JSON Schema |
|---|---|---|---|
| tskm | n/a | full | built-in walker |
| zod | ≥ 3.24 (verified on 4.4.3) | full | native (~standard.jsonSchema, spec 1.1) or z.toJSONSchema |
| valibot | ≥ 1.0 (verified on 1.4.1) | full | @valibot/to-json-schema (add it to your project) |
| arktype | ≥ 2.0 (verified on 2.2.0) | full | native (~standard.jsonSchema, spec 1.1) |
anything else with ~standard types |
spec 1.0+ | best effort (same query) | native converter if it ships one |
What you should know:
- Output side only. The generated type is the schema's
output(aftertransform/default). JSON Schema followsjsonSchema.ioin the config (default"output"). - Brands keep their marker. A branded type includes the library's marker type, so the sidecar imports it:
import type { $brand } from "zod",import type { Brand } from "valibot". - External recursion needs the library's own self annotation (
z.ZodType<T>/v.GenericSchema<T>), and that annotation type must be exported, because the sidecar imports it. An aliased re-export (export type { CatT as PublicCat }) is rebound on import; a local-only name is skipped with a diagnostic. Annotation-free recursion emits exactly what the library itself infers: self-truncated, withany/unknownat the cut, the same as your editor shows. - Compile gate. Before an external-bearing sidecar is written it is verified against the real checker. A type that would not compile is skipped with a diagnostic, and the previous output stays.
- External schemas are sidecar-only. In-place markers stay tskm-only, and external schemas never enter the tskm structural walker or Tier-1, which read tskm's internal conventions.
- Converter rejections are per schema. A JSON Schema conversion the external converter rejects (for example zod
bigint/date/transform, or valibottransform) is skipped with the converter's reason; the rest of the file still emits. - Other libraries are opt-in, not auto-detected. Only the configured
schemaSourcesare scanned, so a Standard Schema library outside the default three produces nothing until you add its package toschemaSources. The pipeline itself is vendor-generic: the type query, recursion annotations, the compile gate, and spec-1.1 JSON Schema converters all work unchanged for a never-seen vendor. - Re-export hubs work for
recursive(). If you funnel the runtime through one module (an anti-corruption layer) and author schemas against it, add that hub toschemaSourcesso its imports are scanned. Arecursive()root reached through the hub is then routed to the structural walker by name, with noInfermarker. The worker confirms the runtime vendor is"tskm"before walking, so a same-namedrecursiveexported by some other library is skipped with a diagnostic rather than mis-walked. Other discovery shapes (a local helper, a non-recursivere-export, a namespace import) still need an explicitInfermarker. - Vendor identity is the package root. tskm derives each source's vendor name from its package root (
zod/v4becomeszod) and matches it against the runtime~standardvendor string for JSON Schema allow-listing and brand-marker imports. This holds for zod/valibot/arktype. A library whose vendor string differs from its package root is reported per file as not allow-listed (never silently dropped), but it cannot currently be enabled for JSON Schema delegation.
See examples/standard-schema for the end-to-end loop over all three vendors (transforms, brands, annotated recursion, an arktype morph).
-
Schema discovery is syntactic and conservative. Sidecar auto-discovery only matches a factory call directly on an exported const:
export const userSchema = object({ name: string() })
Schemas built through a local helper (
export const x = make()), asatisfiesclause, a re-export, or a namespace import are not found. Add an explicitexport type T = Infer<typeof x>marker for those. The one exception is arecursive()root whoserecursiveis imported from a re-export hub listed inschemaSources: it is found by name and routed to the walker, no marker needed (see Standard Schema interop). For recursive schemas the marker must live in the schema's defining file; cross-file markers fail closed (see below). -
One alias per derived name. Two exports that derive the same type name (
useranduserSchemaboth becomeUser) keep the first declaration in discovery order; the later one is skipped with a diagnostic. The canonical schema-plus-marker pair emits exactly oneUser:import { type Infer, object, string } from "@tskm/core" export const userSchema = object({ name: string() }) export type User = Infer<typeof userSchema>
-
Recursive schemas: use
recursive(). The self-reference is passed into the builder, so the implicit-any rule for self-referential initializers never fires, and no hand-written annotation is needed:export const categorySchema = recursive((self) => object({ name: string(), children: array(self) }), ) // materializes: type Category = { name: string; children: Category[] }
Recursive roots are the one place type generation evaluates your module, in an isolated, SIGKILL-guarded subprocess. Set
worker.execPathto a TS-capable runtime such asbunwhen your sources are.tsand the host runtime cannot import them.Same-file mutual recursion (A↔B) is supported. The pair forms a type-level cycle at authoring time, so give one member a loose
GenericSchemaannotation (still no structural type by hand). A specifier-form export (const node = recursive(...)followed byexport { node }) resolves through an explicitexport type Node = Infer<typeof node>marker; auto-discovery without a marker still requires the inlineexport constform.Everything cross-file fails closed: skip plus a diagnostic, never a wrong or dangling alias. A recursive schema imported (or re-exported) into another file is not inlined there. A cross-file
Infer<typeof imported>alias is rejected by the checker guard. A generated body that would reference a sibling alias which itself failed is pruned with it (cycles through non-exported values stay unsupported). Declare recursive aliases in the file that defines the schema. -
Transforms inside a recursive cycle resolve when the builder is a generic arrow:
export const categorySchema = recursive(<S extends GenericSchema>(self: S) => object({ name: string(), depth: pipe(number(), transform((n: number) => Math.trunc(n))), children: array(self), }), )
The compiler asks the checker for a one-level unroll of the builder with a sentinel at the self positions, then splices the result only after both a structural data-key cross-check and a bidirectional fixpoint oracle pass. A wrong candidate is rejected, never emitted. A
branddirectly under a union/tuple root is always rejected; with no data keys, both gates would be blind to brand absorption. Any rejection keeps the honest floor,unknownat the transform position, with a path-precise diagnostic. A plain-arrow builder cannot be unrolled and always gets the floor. -
lazystays as the non-recursive defer / escape hatch. lazy-based recursion still needs the old hand-writtenGenericSchema<T>annotation (the "before" form shown in Why tskm?) and is not auto-materialized in v1. At runtime bothlazyandrecursivefollow the input's depth and are not cycle-guarded, so a pathologically deep value can overflow the stack. -
optional(x)renders ask: T | undefinedby default, notk?: T(the value is the same; the key is still required in the object position). JSON Schema drops it fromrequiredeither way:export const profileSchema = object({ nickname: optional(string()) }) // generates: type Profile = { nickname: string | undefined }
Opt into the faithful-optional mode to emit an omittable key (
k?: T) instead, which mirrors exactly what the validator accepts:export const profileSchema = object({ nickname: optional(string()) }, { optionalKeys: true }) // generates: type Profile = { nickname?: string }
optionalKeysmust be the literaltrue(an inline{ optionalKeys: true }or anas const) for the omittable type to resolve. Seeexamples/ssot. -
In-place mode only recognizes single-line
export type T = Infer<typeof X>markers, and trailing content on that line is dropped on first conversion. It is experimental and opt-in. -
JSON Schema runs your schema module in an isolated subprocess. It assumes the module is side-effect-free and imports cleanly under the chosen runtime (
--exec/execPath).transform/refinements that JSON Schema can't express are warned and omitted.minValue/maxValuemap tominimum/maximumand are only meaningful on a number base (on adatebase they emit numeric bounds that don't apply to the string schema). Recursive (lazy/recursive) schemas become$ref/$defs.recursive()roots are named after their exports (Category), while other hoisted cycles keep kind-derived names (object,object_2, and so on). -
unionemits a single schema-level issue on failure (no per-member aggregation yet).discriminatedUnionalso emits a single schema-level issue (path-unscoped) whoseexpectednames the discriminant and its tag set, since the tag alone selects the member; an unknown or missing tag fails there rather than per member. -
pipe(schema, transform(fn))infersfn's input from an explicit parameter annotation; annotate it (transform((s: string) => s.trim())) when the input isn't otherwise constrained.
examples/basic: the smallest end-to-end loop, schema → generated type → validate.examples/advanced: adiscriminatedUnionwith derived tag metadata, a recursive JSON schema materialized byrecursive(), and the explicitexport type T = Infer<typeof schema>marker.examples/ssot: the single-source-of-truth primitives composed in one place,templateLiteralids, faithful optional keys, arecordkeyed by a template literal,exactObjectclosed shapes,discriminatedUnion, and the issue severity / warning channel.examples/standard-schema: zod, valibot and arktype schemas compiled by one tskm pipeline, with transform/morph output types, branded ids ($brand/Brandimports), and annotated recursion.
bun install
bun run build # rolldown → dist (ESM + .d.ts)
bun run test # bun test: unit + type + integration lanes
bun run lint # biome
bun run test:mutation # Stryker mutation testing (builds first, then per package)Test-suite effectiveness is gated with StrykerJS.
Each package must keep a mutation score of 80% or higher. CI runs the gate per
package and fails below the threshold. Per-package config lives in
packages/*/stryker.config.mjs.
The test runner is the in-repo plugin tools/stryker-bun-runner (the official
Stryker scope has no bun runner, and the community one misattributed per-test
coverage here). It spawns one fresh bun test process per run, activates
mutants through the __STRYKER_ACTIVE_MUTANT__ env var that Stryker's
instrumented code already reads, and attributes per-test coverage by pairing a
preload-side execution counter with bun's JUnit report (whose document order is
the execution order). bun 1.3.13 does not implement
expect.getState().currentTestName, which is why the pairing goes through
sequence numbers instead of test names.
Notes:
- Runs are always full runs; incremental mode stays disabled (details in the config files).
reports/output is a generated record and must not be committed. CI uploads thejsonreport as an artifact.packages/compileris mutated in place (inPlace: true). Its integration fixtures resolve@tskm/corerelative to the real repo layout, and a copy sandbox would break that resolution. Stryker restores sources afterwards. A killed mutant can still abort a test before its cleanup runs, leaving generated fixture artifacts behind (structural-resolve-tmp-*,test/fixtures/**/*.schema.gen.ts). They are gitignored, and the package'stest:mutationscript sweeps them before each run; a stale leftover can still fail the next plainbun testonce. Clean up withgit clean -fX packages/compiler/test.src/cli.tsand thesrc/*-worker.tschild-process entries stay excluded from mutation: no test imports them in-process, so their mutants are only observable across a process boundary and would otherwise sit as no-coverage noise in the report.
MIT