skmtc-generator
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. As
Files1
View on GitHubSKILL.mddeno/docs/skills/skmtc-generator/SKILL.md
GitHubAuthoring 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 as cloned, with one exception: declare @skmtc/*
dependencies as caret ranges (^x.y.z), even where the clone source
pins them exactly — an exact core pin puts a second copy of core in the
bundle as soon as the project pins a different core:
gen-x/ deno.json (name @scope/gen-*, caret-range @skmtc/* pins (^x.y.z),
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/toExportPathare 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 probesfindDefinition; 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), whileinsertNormalizedModel(Peer, { schema, fallbackName })returns the definition itself (name via.identifier.name). Never hand-write peer imports; never import a peer's naming helpers (askcontext.toModelContentSettingsif you need identity without materializing). Key collision under different generators throwsRegistered definition mismatch. - Two composition shapes. Projection (one definition per subject —
entry calls
insertModel/insertOperation) and accumulator (many subjects append into one definition — entry doescontext.findDefinition(...) ?? defineAndRegister(context, {...})then mutates the container value; the sanctioned exception to "no methods beyond constructor and toString").defineAndRegisteris a lang-package free function (import it from your lang package) — there is nocontext.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 —toRefNameon 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 (findDefinitionprobe + your lang package'sdefineAndRegister). The synthesized NAME derives from the schema's ownstackTrail— a pure function from position to name, computed at the point of need (components/schemas/Order/properties/metadata→OrderMetadata; an operation-rooted trail reusestoMethodVerbnaming →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 throughinsertNormalizedModel'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.tsin 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 })→ passvariantthrough toinsertOperation, and fold it into names withwithVariant. Dropping it collides every variant onto'main'. - Enrichments are the settings your generator needs that the
document cannot supply; the consumer provides them; options are
the caller's. Declare them as a valibot three-scope umbrella
(
subject/generator/stack) inenrichments.ts— that schema is the whole contract the consumer'sclient.jsoncan fill; the opt-out isexport const toEnrichmentSchema = () => emptyEnrichmentSchema— a FUNCTION returning the schema, required in both the entry config and the base-factory config. Read viathis.settings.enrichments.subject?...; the run-constant scopes (client.jsonkeys[id]._generatorand._stack, umbrella membersgeneratorandstack— no underscore in generator code) are read outside a projection withtoGeneratorEnrichment/toStackEnrichment. Declare every scope you read: a scope leftv.undefined()rejects any value at its key (so_stackneeds every generator in the run to declarestack; check each generator'senrichments.ts— most stock ones declaresubjectonly). A wrong-typed value fails that item only; keys the schema drops and routing paths nothing read land onmanifest.enrichmentWarnings. Options come from the CALLING generator, on the insert:this.insertModel(Peer, refName, { options: { suffix: 'Input' } }). A projection declares its options type on its base factory (the veneer's second type parameter:toTsModelProjectionBase<E, Options>); the Driver hands them totoIdentifierName/toExportPathand the constructor, and the instance stores them asthis.options. Options are identity: fold them intotoIdentifierNamewhenever the output depends on them, or a second insertion with different options throwsRegistered definition mismatch. The call surface follows the declaration —{ options }is required when the peer declares options and a type error when it does not; declareT | undefinedto make it optional. Pass a fresh object per insertion. Module state is never a channel: it breaks determinism. - Naming: models from
refNamecasing (core'scamelCase,capitalize,decapitalize); operations from method + path via core'stoEndpointName(post→Create, put→Update). NeveroperationId— spec-author-controlled; no stock generator reads it. - Registration at construction;
toString()is a prototype method reading precomputed fields (an arrow-functiontoStringfield 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) |
Registered definition mismatch naming Cached options |
The peer's output depends on its options but its name ignores them — fold options into toIdentifierName |
| 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.