mjst is a Bun monorepo that generates TypeScript type definitions, runtime parsers, and predicate validators from JSON Schema (Draft 2020-12), and lints JSON/YAML documents against JSON Schema and custom style rules. Generated CLI output runs under Node ≥ 20; the development toolchain (install, build, test) uses Bun.
mjst/
├── packages/
│ ├── cli/ # @amritk/mjst — command-line interface (generate + lint)
│ ├── api/ # @amritk/api — contract-first HTTP API layer (routes, validation, OpenAPI, typed client)
│ ├── lint/ # @amritk/lint — format-agnostic JSON/YAML style-guide linter
│ ├── generate-parsers/ # @amritk/generate-parsers — parser + type generator
│ ├── generate-validators/ # @amritk/generate-validators — predicate validator generator
│ ├── runtime-validators/ # @amritk/runtime-validators — eval-free runtime schema interpreter
│ ├── generate-examples/ # @amritk/generate-examples — fast-check arbitrary + example generator
│ ├── generate-markdown/ # @amritk/generate-markdown — README table generator
│ ├── adapters/ # @amritk/adapters — convert external schemas (TypeBox, …) to JSON Schema
│ ├── resolve-refs/ # @amritk/resolve-refs — inline internal/cross-file/remote $refs
│ ├── yaml/ # @amritk/yaml — tiny YAML parser with exact source positions
│ └── helpers/ # @amritk/helpers — shared schema utilities + runtime
├── .claude/ # Developer guidelines
├── .changeset/ # Changesets config (release automation)
├── .github/ # CI, release, issue & PR templates
└── package.json # Workspace root (private)
Command-line entry point. Reads CLI flags and/or a JSON config file, loads a schema, runs the generator, and writes TypeScript output. It also carries a lint subcommand (mjst lint <files>) that lints JSON/YAML documents via @amritk/lint and prints a compact file:line:col report.
- Depends on:
@amritk/generate-parsers,@amritk/lint(@amritk/generate-markdownis a dev dependency — onlyscripts/generate-readme.tsuses it, and that script is not published) - Bin:
mjst→dist/cli.js(built for the Node target) - Config schema:
config.schema.json— also drives the CLI README table via@amritk/generate-markdown. Thelintsubcommand has its own independent flags (see the CLI README).
Contract-first, framework-agnostic HTTP API layer. Each route declares its method, path, request schemas, and response schemas once; from that one contract the package derives typed handlers (FromSchema), runtime request/response validation, an OpenAPI 3.1 document (contract schemas embed verbatim — 3.1's dialect is Draft 2020-12), and a typed fetch client (createClient, no codegen). Two engines execute the same contracts: the runtime engine (createApi — eval-free, powered by @amritk/runtime-validators, for development and CSP-restricted platforms) and the compiled engine (compileToModule — emits a fused fetch-handler module with inlined guards, schema-derived serializers, and a precomputed OpenAPI string, for production/Cloudflare Workers). A differential test corpus holds the two engines observationally identical. Adapters: toFetchHandler (Bun, Workers, Deno, Hono, Next.js) and toNodeHandler (node:http, Express/Connect). @amritk/api/client is the browser-safe entry — the client surface (createClient, defineContract, the opt-in serializers, error predicates, type helpers, client-side auth helpers) with an import graph that touches no server module or node:* built-in (pinned by a test), so frontends bundle it without externalization warnings; the client's non-JSON pieces (pathParams: buildParamPath, queryParams: toSearchParams, cookies: appendCookies, form/multipart serializers) are registered opt-ins, so a JSON-only static-path app bundles none of them. @amritk/api/bundler ships contract-slimming build plugins for browser bundles, and @amritk/api/dev ships hot reloading for the development server (createHotApi — a stable Api whose build is swapped atomically, keeping the socket and process state; watchPaths — the debounced filesystem seam; importFresh — the module re-import, whole-graph on Node 22.15+ via a node:module resolve hook). Bundler and dev are one-way entries: they may import the runtime, never the reverse, so node:fs never reaches the graph that ships to Workers and browsers.
- Depends on:
@amritk/runtime-validators(its single runtime dependency, by design — integrations connect through seams:context,mounts, hooks,onError). - Design docs:
docs/api-framework-plan.md(architecture + roadmap),docs/ummo-readiness.md(adoption audit).
A fast, format-agnostic JSON/YAML style-guide linter — the library behind the mjst lint subcommand. A ruleset maps nodes selected by a JSONPath (given) to a function (then): structural validation against a JSON Schema, built-in style checks (casing, pattern, alphabetical, length, …), or a custom function. Every finding carries an exact line:column range because the parser keeps source positions on every node. The core engine ships no built-in ruleset and knows nothing about OpenAPI or any other schema — you bring the rules.
- OpenAPI ruleset (
@amritk/lint/rules/openapi): a self-contained subpath export layering an OpenAPI preset on top of the format-agnostic engine — theoasruleset, the OpenAPI-specific functions (oasFunctions), format detectors (oasFormats), and auto-fixers (oasFixers), pluscreateOpenApiRuleset(definition?, basePath?)which builds a runnableRulesetwith those functions/formats layered in andextendsresolution that understands theoas/loupe:oas/spectral:oasnames. It adds no dependencies (the OpenAPI functions/fixers use only the engine's owncore/fixplus@amritk/runtime-validators).$refresolution stays the caller's job: pass the built ruleset to the corelintWithResultwith a resolver (e.g. wrapping@amritk/resolve-refs) for rules that need the dereferenced tree. - Depends on:
@amritk/runtime-validators(the built-inschemafunction — and the OpenAPI example/schema rules — run an arbitrary Draft 2020-12 JSON Schema over a matched node) and@amritk/yaml(source-position-preserving YAML parsing so findings map back toline:column). - Entry points:
lintDocument(input, options?)→IDiagnostic[];lintDocumentWithResult(adds a plugin's rewrittenoutput);fixDocument(applies aFixerRegistryto a fixpoint, then re-lints);createRuleset/resolveNamedRuleset(normalize a definition, layer built-in functions, resolveextends). - Rendering is the caller's job:
lintDocumentreturns structured findings only — the library ships no output "formatter" layer, and the CLI supplies its ownfile:line:colreport.
Core code generator. Given a JSONSchema and a root type name, produces an array of GeneratedFile objects — TypeScript type definitions plus optional runtime parser functions that validate and coerce unknown input.
- Depends on:
@amritk/helpers,json-schema-typed(@amritk/generate-markdownis a dev dependency — onlyscripts/generate-readme.tsuses it, and that script is not published) - Subpath imports:
#generators/*→./src/generators/*.ts#helpers/*→./src/helpers/*.ts#types/*→./src/types/*.ts
- Key entry point:
src/generators/build-schema.ts— traverses the root schema and its$ref/$dynamicRefgraph recursively.
Generates lightweight predicate-style validators: each schema becomes a validateFoo(input, _path?): ValidationResult function. No coercion, just shape checks plus structured error paths.
- Depends on:
@amritk/helpers,json-schema-typed - Subpath imports:
#generators/*→./src/generators/*.ts - Key entry point:
src/generators/build-schema.ts
The runtime counterpart to generate-validators. Instead of writing validator source files at build time, it validates a JSON Schema discovered at runtime (a plugin config, a user-supplied schema). It is an eval-free interpreter — it walks the schema directly, with no new Function and no compile step — so it has zero startup cost and runs anywhere eval is forbidden (strict CSP, Cloudflare Workers, React Native/Hermes). The trade-off vs Ajv is deliberate: it wins the cold one-shot path (validate a few values per schema) by ~90–1600×, and loses steady-state throughput (one schema, many values) by ~15–25× — use the build-time generate-validators for that.
- Depends on:
json-schema-typed(types only). Deliberately self-contained — no@amritk/helpers— so the runtime stays slim.ajv/ajv-formatsare dev-only, for the benchmark suite and the differential fuzz test. - Consumed by:
@amritk/lint— its built-inschemarule function validates a matched node against an arbitrary runtime-supplied JSON Schema through this interpreter. - Entry points:
validate(schema)→ error-collecting validator (true | { valid: false, errors });validateGuard(schema)→ zero-allocation boolean type guard. Both go throughsrc/interpreter/prepare.ts(aWeakMapcache over the interpreter). - Design notes: a single recursive walker (
src/interpreter/interpret.ts) evaluates the schema against the value; the error array is allocated lazily so valid input never allocates, and the guard path short-circuits on first failure. The only reusable work — compilingpatternregexes and resolving local$refs (JSON-Pointer fragments and$anchornames) — is memoized per validator. Recursion via$refterminates naturally as the data shrinks. Parity with Ajv is enforced bysrc/differential.test.ts(~144k random/mutated values). OpenAPInullable: trueis honored (null accepted regardless of type).
Generates test data from a schema. For each schema node it emits a type definition, a fast-check arbitrary (FooArbitrary) for property-based testing, and a concrete, self-contained example value (fooExample) for fixtures/seeds/docs.
- Depends on:
@amritk/helpers,json-schema-typed.fast-checkis an optional peer dependency — only the generated arbitraries import it; the generator itself and the staticfooExamplevalues do not. - Subpath imports:
#generators/*→./src/generators/*.ts - Key entry points:
src/generators/build-schema.ts(pipeline),generate-arbitrary.ts(fast-check combinator emitter),derive-example.ts(concrete value derivation + serialization). Tests assert the generated source strings rather than executing fast-check, keeping them hermetic.
Renders a single configuration-reference table from a config.schema.json into a README.md. Used to keep the CLI / generator READMEs in sync with their config schemas. Reads x-cli-flag and x-icon extension keywords.
Converts schemas authored in external libraries into Draft 2020-12 JSON Schema so the rest of the pipeline can consume them unchanged. Each adapter is a pure (source: unknown) => JSONSchema function; loading the source module is the CLI's job (--input <format> / --export <name>).
- Implemented:
typebox,zod,valibot, andeffect. Each external library is an optional peer dependency loaded at runtime (so the core stays slim): TypeBox schemas are already JSON-Schema-shaped (strip symbol keys + rewrite extended types);zoduses Zod 4'stoJSONSchema;valibotuses@valibot/to-json-schema;effectusesJSONSchema.make. The Zod, Valibot, and TypeBox adapters map their date types to thex-mjstDate extension; the Effect adapter passes through Effect's encoded (string) representation. - Lossy constructs: types JSON Schema cannot express are preserved as an
x-mjstvendor extension rather than dropped.@amritk/helpers/mjst-extensiondefines the shared contract (MJST_EXTENSION_KEY,MjstExtension, and the readersgetMjstInstanceOf/getMjstPrimitive/getMjstBrand), which the type generator, parsers, and validators read to emit the right TypeScript type and runtime checks. The extension currently carries:instanceOf(a runtime class such asDate, checked withinstanceof),primitive(a non-JSON primitive such asbigint, checked withtypeof), andbrand(a type-level nominal brand — the value still validates as its base JSON type at runtime, but the generated TypeScript type is intersected with a unique brand). Brands cannot be auto-detected from the source libraries (Zod/Valibot/Effect brands are type-level or stripped during conversion), so they are opt-in via a hand-authoredx-mjst.brandkeyword — which TypeBox passes through fromType.String({ 'x-mjst': { brand: 'UserId' } }).
Resolves and inlines $refs into a single dereferenced document — internal (#/...) pointers, cross-file refs, and remote http(s) documents. A one-pass, cycle-safe resolver: each unique ref resolves once, and a self-reference is kept as a $ref that resolves within the output document rather than looping or collapsing the recursive branch. Fetched remote documents are cached in memory for the session — keyed by URL and by the credentials/transport they were fetched with, so one tenant's document can never be served to a call carrying different credentials — bounded by size and TTL, with concurrent loads of the same URL (and same credentials) coalesced onto one request; local files are re-read each pass (they may change on disk in a long-lived session). Remote fetches are guarded by a default-deny SSRF check: isPrivateHost refuses loopback, private, link-local, IPv4-mapped IPv6, and cloud-metadata hosts by IP (169.254.169.254) and by name (metadata.google.internal, anything under .internal), and assertPublicHost additionally resolves the hostname and refuses it when any address it points at is non-public (closing the 127.0.0.1.nip.io gap; true DNS-rebinding protection would need connection pinning, which Node's fetch does not expose). Redirects are followed manually (redirect: 'manual') with both guards re-applied to every hop, so an allow-listed host cannot bounce to a private address. Local $refs are confined the same way: by default a ref may only resolve under the root document's directory, so {"$ref": "../../etc/passwd"} is refused (widen it with allowedRoots, or turn cross-file reads off entirely with localRefs: false). A resolve is also bounded as a whole by maxDocuments, totalTimeoutMs, and maxDepth.
- Depends on: nothing. Documents are parsed as JSON only (mjst deals in JSON Schema), and there is no
@amritk/*dependency, so it stays a slim, standalone resolver. - Entry points:
resolveRefs(data)— in-memory, internal refs only;resolveRefsFromFile(filename, options)— from disk or a URL, including cross-file and remote refs. Errors are collected on the result (never thrown); a refused or missing target degrades to{}. - Relationship to the lint repo: mirrors the resolver shipped in
@amritk/loupe-ref-resolver(the Loupe linter). The intent is for this published package to become the single shared implementation both repos depend on.
A tiny, dependency-free YAML parser built for diagnostics: every node maps back to an exact line:column source position. Used to load .yaml/.yml schema/config documents (e.g. via the resolveRefsFromFile parse callback) and by @amritk/lint to parse linted documents, while preserving the locations needed to point at the offending node in an error.
- Depends on: nothing.
- Scope: a pragmatic subset of YAML 1.2 sized for configs/OpenAPI — block & flow collections, block scalars (
|/>), quoted/plain scalars, comments, and anchors. Out of scope by design: multi-document streams (only the first document is read), explicit?mapping keys, and exotic tags.
Shared utility belt used both by the generators and copied into generated output. Each helper is exposed as its own subpath export (@amritk/helpers/<name>) so consumers — and generated files — only pull in what they need.
The $ref-graph traversal that the parser, validator, and example generators run is centralized in @amritk/helpers/walk-ref-graph: it upgrades draft-07 input, resolves each ref, rewrites $dynamicRef → $ref, seeds $dynamicAnchor-only definitions, derives type/file names, and memoizes the resolution work per root document. Each generator only turns the prepared node into file content and barrels the result with @amritk/helpers/generate-index-barrel.
Categories:
- Schema traversal:
extract-refs,resolve-ref,build-dynamic-ref-map,resolve-dynamic-refs,extract-dynamic-anchor-defs,upgrade-draft07-schema,ref-to-filename,ref-to-name,schema-guards,walk-ref-graph - Codegen utilities:
generate-type-definition,generate-index-barrel,parse-documentation,safe-accessor - Runtime helpers (referenced from generated output):
is-object,validate-array,validate-record,has-ref. In--helpers=embeddedmode (default when@amritk/helpersis not resolvable fromoutDir),@amritk/generate-parsersreads these sources at generation time, off disk:src/generators/build-schema.tslocates the installed package withcreateRequire(import.meta.url).resolve('@amritk/helpers/package.json')and readssrc/<helper>.tsfrom it (falling back todist/<helper>.js), then emits the content intooutDir/_helpers/so the generated output is self-contained. Nothing is snapshotted into the generator at build time — which is why@amritk/helpers'filesmust keep shipping those foursrc/*.tsfiles.
- Within a package: use
#subpath imports declared in that package'spackage.json(e.g.import { foo } from '#helpers/foo'). - Cross-package: use the published package name (e.g.
import { buildSchema } from '@amritk/generate-parsers',import { resolveRef } from '@amritk/helpers/resolve-ref'). - Same directory: use relative
./imports.
JSON Schema file
│
▼
@amritk/mjst (src/cli.ts)
│ parses CLI args / config, loads schema
▼
buildSchema() ← generate-parsers/src/generators/build-schema.ts
│ traverses $ref graph
│ resolves $dynamicRef via @amritk/helpers
│ applies schema extensions
▼
generateFiles() ← generate-parsers/src/generators/generate-files.ts
(per schema node)
├─ generateTypeDefinition() ← TypeScript type shape
├─ generateParserFunction() ← runtime coercion/validation (skipped with --types-only)
└─ collectImports() ← import statements for $ref dependencies
│
▼
GeneratedFile[]
{ filename, content }
│
▼
Written to --outDir
(with --build, tsc compiles them to .js + .d.ts)
- Framework: Vitest. See
.claude/testing.md. - Convention: test files colocated with implementation, named
*.test.ts. - Conformance suites: the packages that implement a spec are measured against
that spec's official test suite, with an expected-failure list naming every case
they do not pass and why —
packages/yamlagainst the YAML test suite, andruntime-validators/generate-parsers/generate-validators/resolve-refsagainst the vendored JSON Schema Test Suite (fixtures/json-schema-test-suite). Each fails when a case moves in either direction, so a boundary can never move silently. - Mocking: avoided unless necessary (e.g.
generate-markdowntests stubnode:fs/promises). - Aliases:
vitest.config.tsaliases the@amritk/*package names back to source so tests run without a build step.
Run all tests:
bun run testRun tests for a specific package or file:
bun run test packages/generate-parsers- Functional programming: one function per file, no classes.
- Type safety: strict TypeScript throughout (
exactOptionalPropertyTypes,noUncheckedIndexedAccess, etc.) with comprehensive type guards in@amritk/helpers/schema-guards. - Extensible:
SchemaExtensionsallows injecting additional optional properties into specific definitions before generation. - Node-friendly output: packages build with
--target=nodeso the CLI runs undernpx/pnpm dlx/bunxwithout forcing consumers onto Bun.