Authoring Skmtc generators
1. What Skmtc is
Skmtc derives application code from an OpenAPI document treated as a
domain model. A stack of generators (small, opinionated,
cloneable packages) is run by a deterministic engine that sweeps every
subject of the schema — each model (component schema, by refName)
and each operation (path + method) — through each generator.
Outputs are regenerated wholesale every run: never hand-edit generated
files; customize the generator. Generators compose: a React-Query
generator reuses the Zod generator's schemas through the engine's cache.
2. The method: start from a stock generator
Generator source code is the customization surface: stock generators
hardcode their export paths and peer imports on purpose, so the fastest
reliable route to a correct generator is imitation of a published one —
clone the structure, swap the target syntax. Pick the nearest exemplar:
| Need |
Clone |
| model → validator/schema value |
load skmtc-model and copy its engine-tested skeleton (fill-in slots; gen-zod is its pattern source) |
| model → type declaration |
@skmtc/gen-typescript |
| operation → client hook/SDK/form, consuming a model generator |
load skmtc-operation (decomposition + peer-consumption rules; gen-tanstack-query-fetch-zod is its canonical instance) |
| many subjects → one shared file (accumulator) |
@skmtc/gen-msw, @skmtc/gen-express |
| Kotlin |
@skmtc/gen-kotlin-jackson (current lang-kotlin API; the older gen-kotlin-* were retired) |
Fetch source from JSR: https://jsr.io/@skmtc/<name>/meta.json → pick
version → fetch files (or deno doc jsr:@skmtc/<name>). Keep the
package convention exactly as cloned:
gen-x/ deno.json (name @scope/gen-*, EXACT-version @skmtc/* pins,
lint plugin jsr:@skmtc/lint-plugin)
mod.ts → re-exports + `export { entry as default }`
src/mod.ts → the entry: toModelEntry / toOasOperationEntry
src/base.ts → identity statics via the lang base factory
src/enrichments.ts→ enrichment schema (emptyEnrichmentSchema opt-out)
src/XProjection.ts→ constructor builds the value tree
src/<router>.ts → schema-type → snippet dispatch
What you adapt: the identity policy in base.ts (names, export paths,
identifier kind) and the router's per-type snippets (your target
syntax). What you keep: everything else — the shape is the point.
3. The one law: your code never writes output text
Three phases: parse → generate (your code runs here) → render
(toString() runs once per file, only here). During generate the output
does not exist; your generator builds object trees the engine can
walk, attribute, deduplicate, and settle imports from.
The trap: template strings full of target syntax
(`export const ${name} = ...`) compile, render — and silently break
imports (never settled into the header), reuse (text is invisible to the
cache → duplicates), composition (peers can't reference text), and
provenance. Lint rules skmtc/no-template-imports and
skmtc/no-adhoc-tostring catch the worst mechanically; keep them wired.
Litmus, applied at the keystroke: target-language punctuation inside
a string that will be STORED on an object → stop, build the object.
Strings are legitimate as leaves: identifier names, export paths,
module specifiers, literals, a cached peer name, and final syntax
assembled inside a toString() body from already-structured fields.
Prefer composing even render-time syntax inside toString() over helper
functions that return strings — helpers drift.
4. What imitation can't teach: the engine rules
- Identity before construction.
toIdentifierName / toIdentifierType
/ toExportPath are statics computed from (subject, enrichments, variant) WITHOUT constructing the projection. This is the invariant
everything rests on: cheap cache probes, and peers knowing where your
artifact will live. Never make a name depend on construction.
- Coordination is memoization. No plugin registry, no dependency
graph, no topological sort — the cache is the file map, keyed
(identifier.name, exportPath), and every producer creates its own
dependencies. Generator order does not affect output; never propose
ordering, priorities, or a pre-generation pass. On a peer reference the Driver probes
findDefinition; hit → reuse (constructor never runs) + auto-stitched
import; miss → construct recursively. So: never hardcode a peer's name
or path — insert and read the result, minding the two return shapes:
insertModel(Peer, refName) returns an Inserted handle (name via
.toName(), definition via .definition), while
insertNormalizedModel(Peer, { schema, fallbackName }) returns the
definition itself (name via .identifier.name). Never hand-write
peer imports; never import a peer's naming helpers (ask
context.toModelContentSettings if you need identity without
materializing). Key collision under different generators throws
Registered definition mismatch.
- Two composition shapes. Projection (one definition per subject —
entry calls
insertModel/insertOperation) and accumulator (many
subjects append into one definition — entry does
context.findDefinition(...) ?? defineAndRegister(context, {...})
then mutates the container value; the sanctioned exception to "no
methods beyond constructor and toString"). defineAndRegister is a
lang-package free function (import it from your lang package) —
there is no context.defineAndRegister; that API was deleted.
- Peers have exactly two doors: the insert machinery, or an API the
peer package explicitly exports. Never call another generator's
identity statics (
toIdentifierName/toExportPath/toEnrichments)
yourself, and never fabricate a refName — toRefName on a string you
built points at a schema that does not exist, and attribution,
enrichment routing, and recursion tracking are all keyed by REAL
refNames; the fabrication survives only until something resolves it.
If the sanctioned call cannot express what you need, do NOT settle
for a degraded render — a widened type (Map<String, Any?> for a
known shape) is capitulation, not a solution. Treat the situation as
a solved problem you haven't found yet: research how other code
generators handle this exact edge case — the stock lineup, retired
in-house generators (git history is a design archive), and mature
external tools (OpenAPI Generator, Fabrikt). The answer is almost
always to SYNTHESIZE a named declaration and reference it by name
(findDefinition probe + your lang package's defineAndRegister).
The synthesized NAME derives from the schema's own stackTrail — a
pure function from position to name, computed at the point of need
(components/schemas/Order/properties/metadata → OrderMetadata;
an operation-rooted trail reuses toMethodVerb naming →
CreateApiOrdersBody). Never thread a naming hint as a parameter:
position-derived names are deterministic, collision-free (distinct
positions → distinct trails), and reach EVERY construction path —
including values built through insertNormalizedModel's contract,
which cannot pass a hint. Two rules: anchor on the document landmarks
(components/paths), never absolute indices — the trail's head
carries per-run tracing frames (trace-*/span-*/parse); and
throw on an unrecognized or empty trail rather than invent a name
(the engine isolates the throw to that subject). Worked example:
toSynthesizedName.ts in the kotlin-debug rig's gen-kotlin-jackson
(verified end-to-end 2026-08-04, compiler-clean).
Only when the known solution needs machinery the engine genuinely
lacks have you found an ENGINE GAP — name it in your summary and
raise it; never silently ship the degraded form as if it were the
answer. Never re-create engine machinery inside a generator — a
faithful-looking counterfeit passes every automated check and breaks,
far from the cause, on the next engine change.
- When in doubt, make it a producer. The cost asymmetry is one-way:
a producer that never needed to be one costs a few lines; a string
that later needed to be a producer severs the chain for everything
built on it. Assume your value will be built upon.
- The variant axis fans out at the engine, not the generator. One
subject can produce N definitions via named variants declared in
enrichments;
'main' is always present. Thread the variant:
transform({ context, operation, variant }) →
pass variant through to insertOperation, and fold it into names
with withVariant. Dropping it collides every variant onto 'main'.
- Enrichments are the only config channel (bundled generators take
no options; module state breaks determinism). Declare a valibot
three-scope umbrella (
subject/generator/stack); the opt-out is
export const toEnrichmentSchema = () => emptyEnrichmentSchema — a
FUNCTION returning the schema, required in both the entry config and
the base-factory config. Read via
this.settings.enrichments.subject?...; unread keys surface as
warnings.
- Naming: models from
refName casing (core's camelCase,
capitalize, decapitalize); operations from method + path via
core's toEndpointName (post→Create, put→Update). Never
operationId — spec-author-controlled; no stock generator reads it.
- Registration at construction;
toString() is a prototype method
reading precomputed fields (an arrow-function toString field breaks
provenance wrapping). Errors are isolated per subject — a throw kills
one artifact, not the run.
5. Verify against the run
Never guess a signature. Skmtc has almost no training-data presence;
your recalled API shapes are unreliable. Exact signatures for core
contracts (Oas* classes, Inserted, ContentSettings,
TypeSystemArgs, entry configs) are one command away:
deno doc jsr:@skmtc/core@<pinned-version> <SymbolName> — read it
instead of guessing, and instead of casting around a type error. In
particular OasSchema is a union type, not a class hierarchy: every
variant implements .isRef() returning false, and OasRef is a
sibling with .isRef() returning true.
Render does not run Prettier or Biome — no formatter runs inside the
engine, so read the raw output as the generator produced it and format
as a post-generation step.
Generation is sub-second — run it after every meaningful change. Read in
order: (1) manifest — expected definitions at expected paths? parse
issues? per-item errors? (2) one golden artifact — import header
first (missing import = a string swallowed a snippet), then the body;
(3) deno lint; (4) if you consume a peer: their definition exists once
and your file imports it. Never "fix" missing output by concatenating
the text into a template.
6. Pitfalls
| Symptom |
Fix |
| Import missing / appears mid-file |
Declare via register, never in templates |
| Duplicate definitions of a shared model |
Reference peers via insertModel, not by name |
Registered definition mismatch |
Thread variant; or two generators claim one (name, path) |
| Peer output name wrong |
Read .identifier.name off the insert result |
| Works once, breaks on recursion/refs |
Build tree in constructor; refs via the ref snippet/Driver |
| Enrichment ignored |
Umbrella routing key mismatch — check warnings |
| Output edits vanish |
You edited generated files; customize the generator |
| Router misroutes custom values |
schema.type === 'custom' is a real dispatch case — presence-test with 'readOnly' in schema-style guards, not type equality |
null slips through an optional guard |
!== undefined lets null pass on Nullable generics — check both |
insertResult.identifier is a type error |
You have an Inserted handle (from insertModel) — use .toName()/.definition; only insertNormalizedModel returns the definition |
toRefName(`...${name}`) anywhere |
Fabricated ref — go through a peer's two doors, never its statics |
| Reading fields off a peer's value beyond the definition/name |
Coupled to the peer's PRIVATE snippet shape — it will change silently |
7. The lang layer
The engine is language-blind; the import graph declares the language —
a generator imports its projection-base factories and snippet base from
its language package, and the Drivers read the language off the
projection class. Everything concrete — base-factory names, snippet
classes, File/Import/Definition, identifier factories, emitted-language
import rules, sanitization — lives in that package and its skill.
Load skmtc-lang-typescript before writing code (the Kotlin layer has
its own skill, not yet published). Lang skill wins on language
specifics; this skill wins on engine semantics. Two SHAPE skills carry the per-shape guidance — load the
one matching your subject: skmtc-model (fill-in skeleton, model
edge cases: refs, recursion, visibility) or skmtc-operation
(operation decomposition, peer-consumption rules). Shape-specific
guidance lives there, not here.
Scope note: this skill covers OpenAPI input. GraphQL SDL input
exists (toGqlOperationEntry, subject routing by
[rootKind][fieldName]) and the engine rules here apply to it
unchanged, but the GraphQL specifics — the entry, the enrichment
routing, the companion base factories — are not covered here.
1---2name: skmtc-generator3description: Author and edit Skmtc generators — packages that project an OpenAPI domain model into application code. Method: clone the nearest stock generator, then apply the engine rules imitation can't teach. Assumes zero prior Skmtc knowledge. Use when asked to "write a skmtc generator", "author/clone/customize gen-x", "add a field type", "change export paths", "add enrichment options", or when editing generator source. ALWAYS pair with the target language's skill (skmtc-lang-typescript).4---56# Authoring Skmtc generators78## 1. What Skmtc is910Skmtc derives application code from an OpenAPI document treated as a11domain model. A **stack** of **generators** (small, opinionated,12cloneable packages) is run by a deterministic engine that sweeps every13subject of the schema — each **model** (component schema, by `refName`)14and each **operation** (`path` + `method`) — through each generator.15Outputs are regenerated wholesale every run: never hand-edit generated16files; customize the generator. Generators compose: a React-Query17generator reuses the Zod generator's schemas through the engine's cache.1819## 2. The method: start from a stock generator2021Generator source code is the customization surface: stock generators22hardcode their export paths and peer imports on purpose, so the fastest23reliable route to a correct generator is imitation of a published one —24clone the structure, swap the target syntax. Pick the nearest exemplar:2526| Need | Clone |27|---|---|28| model → validator/schema value | load **skmtc-model** and copy its engine-tested skeleton (fill-in slots; gen-zod is its pattern source) |29| model → type declaration | `@skmtc/gen-typescript` |30| operation → client hook/SDK/form, consuming a model generator | load **skmtc-operation** (decomposition + peer-consumption rules; gen-tanstack-query-fetch-zod is its canonical instance) |31| many subjects → one shared file (accumulator) | `@skmtc/gen-msw`, `@skmtc/gen-express` |32| Kotlin | `@skmtc/gen-kotlin-jackson` (current lang-kotlin API; the older gen-kotlin-* were retired) |3334Fetch source from JSR: `https://jsr.io/@skmtc/<name>/meta.json` → pick35version → fetch files (or `deno doc jsr:@skmtc/<name>`). Keep the36package convention exactly as cloned:3738```39gen-x/ deno.json (name @scope/gen-*, EXACT-version @skmtc/* pins,40 lint plugin jsr:@skmtc/lint-plugin)41 mod.ts → re-exports + `export { entry as default }`42 src/mod.ts → the entry: toModelEntry / toOasOperationEntry43 src/base.ts → identity statics via the lang base factory44 src/enrichments.ts→ enrichment schema (emptyEnrichmentSchema opt-out)45 src/XProjection.ts→ constructor builds the value tree46 src/<router>.ts → schema-type → snippet dispatch47```4849What you adapt: the identity policy in `base.ts` (names, export paths,50identifier kind) and the router's per-type snippets (your target51syntax). What you keep: everything else — the shape is the point.5253## 3. The one law: your code never writes output text5455Three phases: parse → **generate** (your code runs here) → render56(`toString()` runs once per file, only here). During generate the output57does not exist; your generator builds **object trees** the engine can58walk, attribute, deduplicate, and settle imports from.5960**The trap**: template strings full of target syntax61(`` `export const ${name} = ...` ``) compile, render — and silently break62imports (never settled into the header), reuse (text is invisible to the63cache → duplicates), composition (peers can't reference text), and64provenance. Lint rules `skmtc/no-template-imports` and65`skmtc/no-adhoc-tostring` catch the worst mechanically; keep them wired.6667**Litmus, applied at the keystroke**: target-language punctuation inside68a string that will be STORED on an object → stop, build the object.69Strings are legitimate as *leaves*: identifier names, export paths,70module specifiers, literals, a cached peer *name*, and final syntax71assembled **inside a `toString()` body** from already-structured fields.72Prefer composing even render-time syntax inside `toString()` over helper73functions that return strings — helpers drift.7475## 4. What imitation can't teach: the engine rules7677- **Identity before construction.** `toIdentifierName` / `toIdentifierType`78 / `toExportPath` are statics computed from `(subject, enrichments,79 variant)` WITHOUT constructing the projection. This is the invariant80 everything rests on: cheap cache probes, and peers knowing where your81 artifact *will* live. Never make a name depend on construction.82- **Coordination is memoization.** No plugin registry, no dependency83 graph, no topological sort — the cache is the file map, keyed84 `(identifier.name, exportPath)`, and every producer creates its own85 dependencies. Generator order does not affect output; never propose86 ordering, priorities, or a pre-generation pass. On a peer reference the Driver probes87 `findDefinition`; hit → reuse (constructor never runs) + auto-stitched88 import; miss → construct recursively. So: never hardcode a peer's name89 or path — insert and read the result, minding the two return shapes:90 `insertModel(Peer, refName)` returns an **Inserted handle** (name via91 `.toName()`, definition via `.definition`), while92 `insertNormalizedModel(Peer, { schema, fallbackName })` returns the93 **definition itself** (name via `.identifier.name`). Never hand-write94 peer imports; never import a peer's naming helpers (ask95 `context.toModelContentSettings` if you need identity without96 materializing). Key collision under different generators throws97 `Registered definition mismatch`.98- **Two composition shapes.** Projection (one definition per subject —99 entry calls `insertModel`/`insertOperation`) and accumulator (many100 subjects append into one definition — entry does101 `context.findDefinition(...) ?? defineAndRegister(context, {...})`102 then mutates the container value; the sanctioned exception to "no103 methods beyond constructor and toString"). `defineAndRegister` is a104 **lang-package free function** (import it from your lang package) —105 there is no `context.defineAndRegister`; that API was deleted.106- **Peers have exactly two doors**: the insert machinery, or an API the107 peer package explicitly exports. Never call another generator's108 identity statics (`toIdentifierName`/`toExportPath`/`toEnrichments`)109 yourself, and never fabricate a refName — `toRefName` on a string you110 built points at a schema that does not exist, and attribution,111 enrichment routing, and recursion tracking are all keyed by REAL112 refNames; the fabrication survives only until something resolves it.113 If the sanctioned call cannot express what you need, do NOT settle114 for a degraded render — a widened type (`Map<String, Any?>` for a115 known shape) is capitulation, not a solution. Treat the situation as116 a solved problem you haven't found yet: research how other code117 generators handle this exact edge case — the stock lineup, retired118 in-house generators (git history is a design archive), and mature119 external tools (OpenAPI Generator, Fabrikt). The answer is almost120 always to SYNTHESIZE a named declaration and reference it by name121 (`findDefinition` probe + your lang package's `defineAndRegister`).122 The synthesized NAME derives from the schema's own `stackTrail` — a123 pure function from position to name, computed at the point of need124 (`components/schemas/Order/properties/metadata` → `OrderMetadata`;125 an operation-rooted trail reuses `toMethodVerb` naming →126 `CreateApiOrdersBody`). Never thread a naming hint as a parameter:127 position-derived names are deterministic, collision-free (distinct128 positions → distinct trails), and reach EVERY construction path —129 including values built through `insertNormalizedModel`'s contract,130 which cannot pass a hint. Two rules: anchor on the document landmarks131 (`components`/`paths`), never absolute indices — the trail's head132 carries per-run tracing frames (`trace-*`/`span-*`/`parse`); and133 throw on an unrecognized or empty trail rather than invent a name134 (the engine isolates the throw to that subject). Worked example:135 `toSynthesizedName.ts` in the kotlin-debug rig's gen-kotlin-jackson136 (verified end-to-end 2026-08-04, compiler-clean).137 Only when the known solution needs machinery the engine genuinely138 lacks have you found an ENGINE GAP — name it in your summary and139 raise it; never silently ship the degraded form as if it were the140 answer. Never re-create engine machinery inside a generator — a141 faithful-looking counterfeit passes every automated check and breaks,142 far from the cause, on the next engine change.143- **When in doubt, make it a producer.** The cost asymmetry is one-way:144 a producer that never needed to be one costs a few lines; a string145 that later needed to be a producer severs the chain for everything146 built on it. Assume your value will be built upon.147- **The variant axis fans out at the engine, not the generator.** One148 subject can produce N definitions via named variants declared in149 enrichments; `'main'` is always present. Thread the variant:150 `transform({ context, operation, variant })` →151 pass `variant` through to `insertOperation`, and fold it into names152 with `withVariant`. Dropping it collides every variant onto `'main'`.153- **Enrichments are the only config channel** (bundled generators take154 no options; module state breaks determinism). Declare a valibot155 three-scope umbrella (`subject`/`generator`/`stack`); the opt-out is156 `export const toEnrichmentSchema = () => emptyEnrichmentSchema` — a157 FUNCTION returning the schema, required in both the entry config and158 the base-factory config. Read via159 `this.settings.enrichments.subject?...`; unread keys surface as160 warnings.161- **Naming**: models from `refName` casing (core's `camelCase`,162 `capitalize`, `decapitalize`); operations from **method + path** via163 core's `toEndpointName` (post→Create, put→Update). **Never**164 `operationId` — spec-author-controlled; no stock generator reads it.165- **Registration at construction; `toString()` is a prototype method**166 reading precomputed fields (an arrow-function `toString` field breaks167 provenance wrapping). Errors are isolated per subject — a throw kills168 one artifact, not the run.169170## 5. Verify against the run171172**Never guess a signature.** Skmtc has almost no training-data presence;173your recalled API shapes are unreliable. Exact signatures for core174contracts (`Oas*` classes, `Inserted`, `ContentSettings`,175`TypeSystemArgs`, entry configs) are one command away:176`deno doc jsr:@skmtc/core@<pinned-version> <SymbolName>` — read it177instead of guessing, and instead of casting around a type error. In178particular `OasSchema` is a union type, not a class hierarchy: every179variant implements `.isRef()` returning `false`, and `OasRef` is a180sibling with `.isRef()` returning `true`.181182Render does not run Prettier or Biome — no formatter runs inside the183engine, so read the raw output as the generator produced it and format184as a post-generation step.185186Generation is sub-second — run it after every meaningful change. Read in187order: (1) manifest — expected definitions at expected paths? parse188issues? per-item errors? (2) one golden artifact — **import header189first** (missing import = a string swallowed a snippet), then the body;190(3) `deno lint`; (4) if you consume a peer: their definition exists once191and your file imports it. Never "fix" missing output by concatenating192the text into a template.193194## 6. Pitfalls195196| Symptom | Fix |197|---|---|198| Import missing / appears mid-file | Declare via `register`, never in templates |199| Duplicate definitions of a shared model | Reference peers via `insertModel`, not by name |200| `Registered definition mismatch` | Thread `variant`; or two generators claim one (name, path) |201| Peer output name wrong | Read `.identifier.name` off the insert result |202| Works once, breaks on recursion/refs | Build tree in constructor; refs via the ref snippet/Driver |203| Enrichment ignored | Umbrella routing key mismatch — check warnings |204| Output edits vanish | You edited generated files; customize the generator |205| Router misroutes custom values | `schema.type === 'custom'` is a real dispatch case — presence-test with `'readOnly' in schema`-style guards, not type equality |206| `null` slips through an optional guard | `!== undefined` lets `null` pass on Nullable generics — check both |207| `insertResult.identifier` is a type error | You have an `Inserted` handle (from `insertModel`) — use `.toName()`/`.definition`; only `insertNormalizedModel` returns the definition |208| `` toRefName(`...${name}`) `` anywhere | Fabricated ref — go through a peer's two doors, never its statics |209| Reading fields off a peer's value beyond the definition/name | Coupled to the peer's PRIVATE snippet shape — it will change silently |210211## 7. The lang layer212213The engine is language-blind; the import graph declares the language —214a generator imports its projection-base factories and snippet base from215its language package, and the Drivers read the language off the216projection class. Everything concrete — base-factory names, snippet217classes, File/Import/Definition, identifier factories, emitted-language218import rules, sanitization — lives in that package and its skill.219Load `skmtc-lang-typescript` before writing code (the Kotlin layer has220its own skill, not yet published). Lang skill wins on language221specifics; this skill wins on engine semantics. Two SHAPE skills carry the per-shape guidance — load the222one matching your subject: `skmtc-model` (fill-in skeleton, model223edge cases: refs, recursion, visibility) or `skmtc-operation`224(operation decomposition, peer-consumption rules). Shape-specific225guidance lives there, not here.226227Scope note: this skill covers **OpenAPI input**. GraphQL SDL input228exists (`toGqlOperationEntry`, subject routing by229`[rootKind][fieldName]`) and the engine rules here apply to it230unchanged, but the GraphQL specifics — the entry, the enrichment231routing, the companion base factories — are not covered here.