Skip to content

ushironoko/tskm

Repository files navigation

tskm

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.

Overview

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 ~standard interface, 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).

Why tskm?

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[]
}

Simple Usage

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).

Issue severity and warnings

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.

CLI

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 Config

From 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.

How It Works

The compiler never reads your schema at runtime. The inferred type only exists in the type system, so it asks the type checker directly:

  1. Discover. oxc-parser scans each source file for exported const factory calls rooted in a configured schema source, and for explicit type T = Infer<typeof X> markers. The scan is purely syntactic. Schema sources come from the schemaSources config: @tskm/core is always included, and zod / valibot / arktype are on by default. tskm imports count as confirmed schemas; consts from external sources stay candidates until step 2 proves them.

  2. 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 with typeToString (no truncation, anonymous structural form). tsgo is Microsoft's native TypeScript port, driven over its IPC API via @corsa-bind/napi and the @typescript/native-preview binary. The prettified (__P) rendering is used for top-level objects; everything else keeps the raw form, because __P applied to a top-level Date, Map, Set, or branded primitive would expand the entire prototype. Because the answer comes from the type system, types produced by transform, generics, or conditional types all resolve correctly.

  3. 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's Brand) 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 / never keeps 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.

  4. Recursive tskm schemas (recursive()) take a structural route instead, because the plain query would collapse their self positions to any before 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. A recursive import from any configured schema source counts, not just a direct @tskm/core one, 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 tskm recursive() (its ~standard.vendor is "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.

Type support

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.

Standard Schema interop

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 (after transform / default). JSON Schema follows jsonSchema.io in 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, with any/unknown at 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 valibot transform) 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 schemaSources are scanned, so a Standard Schema library outside the default three produces nothing until you add its package to schemaSources. 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 to schemaSources so its imports are scanned. A recursive() root reached through the hub is then routed to the structural walker by name, with no Infer marker. The worker confirms the runtime vendor is "tskm" before walking, so a same-named recursive exported by some other library is skipped with a diagnostic rather than mis-walked. Other discovery shapes (a local helper, a non-recursive re-export, a namespace import) still need an explicit Infer marker.
  • Vendor identity is the package root. tskm derives each source's vendor name from its package root (zod/v4 becomes zod) and matches it against the runtime ~standard vendor 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).

Limitations

  • 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()), a satisfies clause, a re-export, or a namespace import are not found. Add an explicit export type T = Infer<typeof x> marker for those. The one exception is a recursive() root whose recursive is imported from a re-export hub listed in schemaSources: 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 (user and userSchema both become User) keep the first declaration in discovery order; the later one is skipped with a diagnostic. The canonical schema-plus-marker pair emits exactly one User:

    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.execPath to a TS-capable runtime such as bun when your sources are .ts and 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 GenericSchema annotation (still no structural type by hand). A specifier-form export (const node = recursive(...) followed by export { node }) resolves through an explicit export type Node = Infer<typeof node> marker; auto-discovery without a marker still requires the inline export const form.

    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 brand directly 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, unknown at the transform position, with a path-precise diagnostic. A plain-arrow builder cannot be unrolled and always gets the floor.

  • lazy stays as the non-recursive defer / escape hatch. lazy-based recursion still needs the old hand-written GenericSchema<T> annotation (the "before" form shown in Why tskm?) and is not auto-materialized in v1. At runtime both lazy and recursive follow the input's depth and are not cycle-guarded, so a pathologically deep value can overflow the stack.

  • optional(x) renders as k: T | undefined by default, not k?: T (the value is the same; the key is still required in the object position). JSON Schema drops it from required either 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 }

    optionalKeys must be the literal true (an inline { optionalKeys: true } or an as const) for the omittable type to resolve. See examples/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/maxValue map to minimum/maximum and are only meaningful on a number base (on a date base 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).

  • union emits a single schema-level issue on failure (no per-member aggregation yet). discriminatedUnion also emits a single schema-level issue (path-unscoped) whose expected names 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)) infers fn's input from an explicit parameter annotation; annotate it (transform((s: string) => s.trim())) when the input isn't otherwise constrained.

Examples

  • examples/basic: the smallest end-to-end loop, schema → generated type → validate.
  • examples/advanced: a discriminatedUnion with derived tag metadata, a recursive JSON schema materialized by recursive(), and the explicit export type T = Infer<typeof schema> marker.
  • examples/ssot: the single-source-of-truth primitives composed in one place, templateLiteral ids, faithful optional keys, a record keyed by a template literal, exactObject closed 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/Brand imports), and annotated recursion.

Development

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)

Mutation testing

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 the json report as an artifact.
  • packages/compiler is mutated in place (inPlace: true). Its integration fixtures resolve @tskm/core relative 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's test:mutation script sweeps them before each run; a stale leftover can still fail the next plain bun test once. Clean up with git clean -fX packages/compiler/test.
  • src/cli.ts and the src/*-worker.ts child-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.

License

MIT

About

Standard Schema compliant validation library with an AOT type compiler

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors