Claude Cursor Skill

skmtc-model

The model-generator shape for Skmtc: one definition per component schema, built by copying the shipped SKELETON package and filling its SLOT markers with the target library's syntax. Covers the edge cases every model generator must survive — refs, recursion, optional vs nullable,

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download skmtc-skmtc-deno_docs_skills_skmtc-model-e3abffc.zip · 20 KB
skmtc/skmtc 19 0 forks Apache-2.0 Updated 7d ago
Part of skmtc/skmtc — 12 skills

Install

skills CLI npx skills add https://github.com/skmtc/skmtc/tree/main/deno/docs/skills/skmtc-model
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install skmtc-skmtc@llmmart
Git git clone https://github.com/skmtc/skmtc.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole skmtc/skmtc collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Model generators: fill the skeleton

A model generator turns each component schema (refName) into one definition in one file: entry → projection → schema-type router → one snippet class per schema type. That structure is invariant across target libraries — only naming policy and per-type syntax vary. So do not write the structure: copy it.

1. The method

The skeleton/ directory next to this file is a complete, compiling, engine-tested model generator that renders a placeholder syntax (m.object({...})). Author by transplant, not from scratch:

  1. Copy skeleton/ to your package location; run deno test --allow-env --allow-sys --allow-read — 6 green tests prove the machinery before you touch anything.
  2. Rename: name in deno.json; MyLib → YourLib in class names and filenames; myLibEntry → yourLibEntry; then src/lib.ts — LIB_MODULE (emitted module specifier) and LIB (imported symbol).
  3. Fill the slots (§2), smallest first: scalars → string/enum → array/object → union → lazy/recursion annotation.
  4. Re-pin the test: update the pinned strings in mod.test.ts to your target syntax. The structural assertions (files exist, shared refs dedup to ONE definition, import headers stitched, recursion annotated) must pass UNCHANGED — if one breaks, you broke machinery, not syntax.

Every slot is a // SLOT(name): comment. Everything outside a SLOT is engine machinery — modifying it is almost always a mistake.

2. The slots

Slot File Decision
library src/lib.ts emitted module + symbol, single point
naming, identifier-kind, export-path src/base.ts identity policy (from refName ONLY)
string, string-constraints src/MyLibString.ts string / enum / literal syntax; formats, min/maxLength
number, integer, boolean, unknown, void src/MyLibScalars.ts scalar syntax; numeric constraints
array src/MyLibArray.ts list syntax
object-properties, object-intersection, object-empty, visibility src/MyLibObject.ts object syntax; properties+record composition; readOnly/writeOnly policy
record src/MyLibObject.ts additionalProperties map syntax
union src/MyLibUnion.ts oneOf/anyOf; discriminated form
lazy src/MyLibRef.ts deferred-reference form for cycles
recursion-annotation src/MyLibProjection.ts type annotation breaking circular inference
modifiers src/modifiers.ts optional/nullable syntax and wrap order
enrichments src/enrichments.ts config seam (default: opt-out)

3. Edge cases the skeleton already handles — keep them working

  • Refs are names, never expansions. MyLibRef puts only the peer's NAME in the value tree; the ModelDriver resolves the definition (cache hit → reuse, miss → construct) and stitches the cross-file import. Inline-expanding a ref, or hand-writing its import, is how shared models duplicate.
  • Recursion is a protocol, not a special case. A back-reference to a model still open on the build stack (context.modelDepth > 0) renders via SLOT(lazy) and bumps the depth; the projection then sees > 1 and sets settings.identifier.typeName (SLOT recursion-annotation) so the emitted export const doesn't die of circular inference (TS7022/7024). Self-recursion only — mutual recursion is not detected.
  • Optional and nullable are different axes. required comes from the PARENT object's required list and flows into each property leaf's modifiers; nullable sits on the node itself. Both render exactly once, in applyModifiers, at the leaf — no other owner, and never while building stored fields.
  • additionalProperties → the record path; true/empty schema → the unknown fallback; properties + additionalProperties together → SLOT(object-intersection).
  • An object schema has four forms — and position can change the render. Properties-only, record-only (additionalProperties), both, empty: every place an object renders must survive all four. In TypeScript one expression serves both type and declaration positions (z.object({...}), .and(z.record(...)) for both-forms), so the object SLOTs compose freely. In a head+value language (Kotlin) the two positions DIVERGE, and a position-blind toString() cannot serve both (compiler-verified 2026-08-04, kotlin-debug rig): properties-only declares as a data class parameter list, and in type position must render a NAME — synthesize the named sibling declaration and reference it (name derived from the schema's own stackTrail, no naming param threaded through the router; collisions policed by a document-wide claim registry that throws per-item, since the name shares a PACKAGE with every component class — gen-kotlin-jackson toSynthesizedName.ts + synthesizedNames.ts; a parameter list in type position parses as a function type and fails, and widening to Map<String, Any?> discards the type — capitulation, not a solution); record-only and empty must not take a data-class head at all (data class X() is illegal — their declaration kind is typealias); both-forms has a declaration form (data class plus a @field:JsonAnySetter @get:JsonAnyGetter catch-all map property) but no anonymous type form. Decide the identifier KIND and the value together from the same schema guards (gen-kotlin-jackson shape.ts is the worked example) — never from the name alone, and never by making one toString() answer both positions.
  • A discriminated union may be a DECLARATION, not an expression. In TypeScript SLOT(union) is one expression (z.discriminatedUnion("type", [...])). In a language without union types (Kotlin) a qualifying discriminated union becomes a named sealed declaration, and the member models must declare the supertype — a member may be BUILT before its union is ever seen, so membership comes from a document-wide scan (parent → member inversion, WeakMap-memoized) consulted at member construction, never from the union's own walk. Non-qualifying unions render the honest wire type (JsonNode), not Any. Full pattern: the Kotlin lang skill §8c.
  • Property keys go through handleKey — 'first-name' renders quoted; never assume keys are identifiers.
  • Visibility. readOnly/writeOnly are captured per property in MyLibObjectProperties.visibility. Default policy ignores them; if the target needs them, annotate the value (e.g. .readonly()) or emit request/response variants via variant threading — decide at SLOT(visibility). Caller options are the third route: declare the second type parameter of toTsModelProjectionBase, let the calling generator pass { options } on the insert, and fold them into the name.
  • Unknown never throws. Untyped schemas route to the unknown fallback so one odd schema can't kill the subject. custom values pass through untouched.
  • TypeSystem contracts. Each snippet class carries the fields peers rely on (TypeSystemString needs format + enums; objects expose objectProperties/recordProperties). Add fields freely; remove none — removal breaks insertNormalizedModel consumers and fails the SchemaToValueFn check.

4. Verify

The shipped mod.test.ts runs the REAL pipeline (toArtifacts) over a fixture with an enum, an array-of-ref, a shared ref (×2 → one definition), optional + nullable, a record, and a self-recursive model. It is your regression gate: green before you start, green after every slot. Read failures in this order: import header first (a missing import means a string swallowed a snippet), then the body, then deno lint (the skmtc/* rules are wired in deno.json).

5. Model-generator pitfalls

Symptom Fix
Shared model duplicated per consumer A ref was rendered/expanded instead of flowing through MyLibRef
Stack overflow on recursive schema The modelDepth branch in MyLibRef was removed or bypassed
Emitted file dies of TS7022/7024 SLOT(recursion-annotation) not set for the target
.optional() doubled or missing Modifiers applied outside applyModifiers, or a second owner added
Enum with null member renders 'null' Keep the literal() null-guard from MyLibString
Peer generator can't consume yours schemaToValueFn/createIdentifier statics or TypeSystem contract fields removed
Lint fires no-template-imports/no-adhoc-tostring Target syntax leaked outside a toString() body — move it into the SLOT
data class NameMap<String, Any?> (head glued to a type) in output Declaration kind and value were decided separately — see the four-forms bullet in §3; kind+value must come from the same schema guards

6. Boundaries

Engine semantics (the one law, memoization, enrichments, variants, naming rules) live in skmtc-generator — read it first. TS-layer specifics (register shapes, identifier kinds, import machinery, List/FunctionParameter) live in skmtc-lang-typescript. This skill owns only the model SHAPE. The skeleton is TypeScript-emitting; for a Kotlin model generator, keep this skill's shape and edge-case rules but take call shapes from the Kotlin lang skill (no Kotlin skeleton yet). Operation generators are a different shape — load skmtc-operation; accumulators are covered by neither (clone gen-msw/gen-express per skmtc-generator §2).

Files (skmtc)
  • skeleton
    • src
      • base.ts 1.1 KB
        import { camelCase, decapitalize } from '@skmtc/core'
        import { toTsModelProjectionBase } from '@skmtc/lang-typescript'
        import { join } from '@std/path'
        import denoJson from '../deno.json' with { type: 'json' }
        import { type EnrichmentSchema, toEnrichmentSchema } from './enrichments.ts'
        
        export const MyLibBase = toTsModelProjectionBase<EnrichmentSchema>({
          id: denoJson.name,
        
          // SLOT(naming): the emitted binding name, derived from refName ONLY
          // (deterministic; never operationId, never construction-dependent).
          toIdentifierName({ refName }): string {
            return decapitalize(camelCase(refName))
          },
        
          // SLOT(identifier-kind): 'variable' for schema values; 'type' or
          // 'interface' would make consumers import it type-only.
          toIdentifierType: () => ({ type: 'variable' }),
        
          // SLOT(export-path): where each model's file lives. '@' is the
          // project-root marker; keep the .generated.ts suffix convention.
          toExportPath({ refName, enrichments, variant }): string {
            const name = this.toIdentifierName({ refName, enrichments, variant })
        
            return join('@', 'models', `${name}.generated.ts`)
          },
        
          toEnrichmentSchema,
        })
        
      • enrichments.ts 405 B
        import { type EmptyEnrichments, emptyEnrichmentSchema } from '@skmtc/core'
        
        // SLOT(enrichments): the opt-out. To accept per-model options, replace
        // with a valibot three-scope umbrella ({ subject, generator, stack }) —
        // see skmtc-generator §4. Must stay a FUNCTION returning the schema.
        export const toEnrichmentSchema = () => emptyEnrichmentSchema
        
        export type EnrichmentSchema = EmptyEnrichments
        
      • lib.ts 388 B
        /**
         * SLOT(library): the emitted library, in one place.
         *
         * LIB_MODULE is the module specifier written into emitted import
         * headers; LIB is the imported symbol every snippet's render body
         * composes with. Change both here, then rewrite the `toString()`
         * bodies (each marked with a SLOT comment) in the snippet classes.
         */
        export const LIB_MODULE = 'mylib'
        export const LIB = 'm'
        
      • mod.ts 430 B
        import { toModelEntry } from '@skmtc/core'
        import denoJson from '../deno.json' with { type: 'json' }
        import { type EnrichmentSchema, toEnrichmentSchema } from './enrichments.ts'
        import { MyLibProjection } from './MyLibProjection.ts'
        
        export const myLibEntry = toModelEntry<EnrichmentSchema>({
          id: denoJson.name,
          toEnrichmentSchema,
          transform({ context, refName }) {
            context.insertModel(MyLibProjection, refName)
          },
        })
        
      • modifiers.ts 627 B
        import type { Modifiers, Stringable } from '@skmtc/core'
        
        /**
         * SLOT(modifiers): how the target library expresses optional and
         * nullable. Applied ONCE, at each leaf's render — never while building
         * stored fields, and no other owner. Order matters: nullable wraps the
         * value first, optional wraps the result (zod convention; adjust if
         * your library composes differently).
         */
        export const applyModifiers = (
          value: Stringable,
          { required, nullable }: Modifiers,
        ): string => {
          const withNullable = nullable ? `${value}.nullable()` : `${value}`
        
          return required ? withNullable : `${withNullable}.optional()`
        }
        
      • MyLib.ts 3.6 KB
        /**
         * The schema-type router: every schema node dispatches to exactly one
         * snippet class. Every branch returns a snippet OBJECT — text exists
         * only inside toString() bodies. Fine-grained attribution is captured
         * via each snippet's super call (`stackTrail: schema.stackTrail.clone()`).
         */
        import { toGeneratorOnlyKey, toRefName } from '@skmtc/core'
        import type { Modifiers, SchemaToValueFn, SchemaType } from '@skmtc/core'
        import { match } from 'ts-pattern'
        import { myLibEntry } from './mod.ts'
        import { MyLibArray } from './MyLibArray.ts'
        import { MyLibObject } from './MyLibObject.ts'
        import { MyLibRef } from './MyLibRef.ts'
        import { MyLibString } from './MyLibString.ts'
        import { MyLibUnion } from './MyLibUnion.ts'
        import {
          MyLibBoolean,
          MyLibInteger,
          MyLibNumber,
          MyLibUnknown,
          MyLibVoid,
        } from './MyLibScalars.ts'
        
        export const toMyLibValue: SchemaToValueFn = (
          { schema: schemaNode, destinationPath, required, context, rootRef },
        ) => {
          // `schemaNode` arrives typed as the generic `Schema` parameter, and
          // TypeScript does not narrow a type parameter by discriminant.
          // Widening it to the `SchemaType` union lets the match below narrow
          // each case on its own — generator code narrows, it does not assert.
          const schema: SchemaType = schemaNode
        
          const modifiers: Modifiers = {
            required,
            nullable: 'nullable' in schema ? schema.nullable : undefined,
          }
        
          const generatorKey = toGeneratorOnlyKey({ generatorId: myLibEntry.id })
        
          return match(schema)
            // Custom values pass through untouched — they are already Stringable.
            .with({ type: 'custom' }, (custom) => custom)
            .with({ type: 'ref' }, (ref) => {
              return new MyLibRef({
                context,
                destinationPath,
                refName: toRefName(ref.$ref),
                modifiers,
                rootRef,
                schema: ref,
              })
            })
            .with({ type: 'array' }, (arraySchema) => {
              return new MyLibArray({
                context,
                destinationPath,
                modifiers,
                items: arraySchema.items,
                generatorKey,
                rootRef,
                schema: arraySchema,
              })
            })
            .with({ type: 'object' }, (objectSchema) => {
              return new MyLibObject({
                context,
                destinationPath,
                objectSchema,
                modifiers,
                generatorKey,
                rootRef,
              })
            })
            .with({ type: 'union' }, (unionSchema) => {
              return new MyLibUnion({
                context,
                destinationPath,
                members: unionSchema.members,
                discriminator: unionSchema.discriminator,
                modifiers,
                generatorKey,
                rootRef,
                schema: unionSchema,
              })
            })
            .with({ type: 'string' }, (stringSchema) => {
              return new MyLibString({
                context,
                stringSchema,
                modifiers,
                destinationPath,
                generatorKey,
              })
            })
            .with({ type: 'number' }, (schema) => {
              return new MyLibNumber({
                context,
                schema,
                modifiers,
                destinationPath,
                generatorKey,
              })
            })
            .with({ type: 'integer' }, (schema) => {
              return new MyLibInteger({
                context,
                schema,
                modifiers,
                destinationPath,
                generatorKey,
              })
            })
            .with({ type: 'boolean' }, (schema) => {
              return new MyLibBoolean({
                context,
                schema,
                modifiers,
                destinationPath,
                generatorKey,
              })
            })
            .with(
              { type: 'void' },
              () => new MyLibVoid({ context, destinationPath, generatorKey }),
            )
            .with({ type: 'unknown' }, (schema) => {
              return new MyLibUnknown({
                context,
                destinationPath,
                generatorKey,
                schema,
              })
            })
            .exhaustive()
        }
        
      • MyLibArray.ts 1.5 KB
        import { TsSnippet } from '@skmtc/lang-typescript'
        import type {
          GenerateContextType,
          GeneratorKey,
          Modifiers,
          OasRef,
          OasSchema,
          RefName,
          TypeSystemValue,
        } from '@skmtc/core'
        import { toMyLibValue } from './MyLib.ts'
        import { applyModifiers } from './modifiers.ts'
        import { LIB, LIB_MODULE } from './lib.ts'
        
        type MyLibArrayArgs = {
          context: GenerateContextType
          destinationPath: string
          items: OasSchema | OasRef<'schema'>
          /** The originating array schema node — for fine-grained attribution. */
          schema?: OasSchema | OasRef<'schema'>
          modifiers: Modifiers
          generatorKey: GeneratorKey
          rootRef?: RefName
        }
        
        export class MyLibArray extends TsSnippet {
          type = 'array' as const
          items: TypeSystemValue
          modifiers: Modifiers
        
          constructor(
            {
              context,
              generatorKey,
              destinationPath,
              items,
              modifiers,
              rootRef,
              schema,
            }: MyLibArrayArgs,
          ) {
            super({ context, generatorKey, stackTrail: schema?.stackTrail.clone() })
        
            this.modifiers = modifiers
        
            // The items value is built by recursing through the router — a
            // snippet, never a string. This is what keeps nested refs cached.
            this.items = toMyLibValue({
              destinationPath,
              schema: items,
              required: true,
              context,
              rootRef,
            })
        
            this.register({ imports: { [LIB_MODULE]: [LIB] }, destinationPath })
          }
        
          override toString(): string {
            // SLOT(array)
            return applyModifiers(`${LIB}.array(${this.items})`, this.modifiers)
          }
        }
        
      • MyLibObject.ts 5.6 KB
        import { isEmpty } from '@skmtc/core'
        import { handleKey, TsSnippet } from '@skmtc/lang-typescript'
        import type {
          CustomValue,
          GenerateContextType,
          GeneratorKey,
          Modifiers,
          OasObject,
          OasRef,
          OasSchema,
          RefName,
          TypeSystemValue,
        } from '@skmtc/core'
        import { toMyLibValue } from './MyLib.ts'
        import { applyModifiers } from './modifiers.ts'
        import { MyLibUnknown } from './MyLibScalars.ts'
        import { LIB, LIB_MODULE } from './lib.ts'
        
        type MyLibObjectArgs = {
          context: GenerateContextType
          destinationPath: string
          objectSchema: OasObject
          modifiers: Modifiers
          generatorKey: GeneratorKey
          rootRef?: RefName
        }
        
        export class MyLibObject extends TsSnippet {
          type = 'object' as const
          objectProperties: MyLibObjectProperties | null
          recordProperties: MyLibRecord | null
          modifiers: Modifiers
        
          constructor(
            {
              context,
              generatorKey,
              destinationPath,
              objectSchema,
              modifiers,
              rootRef,
            }: MyLibObjectArgs,
          ) {
            super({
              context,
              generatorKey,
              stackTrail: objectSchema.stackTrail.clone(),
            })
        
            this.modifiers = modifiers
        
            const { properties, required, additionalProperties } = objectSchema
        
            const hasProperties = properties && !isEmpty(properties)
        
            this.recordProperties = additionalProperties
              ? new MyLibRecord({
                context,
                generatorKey,
                destinationPath,
                schema: additionalProperties,
                rootRef,
              })
              : null
        
            this.objectProperties = hasProperties
              ? new MyLibObjectProperties({
                context,
                generatorKey,
                destinationPath,
                properties,
                // 'required' lists which PROPERTIES are required — it is not
                // about the object itself. Each property's optionality renders
                // at that property's own leaf via its modifiers.
                required,
                rootRef,
              })
              : null
        
            this.register({ imports: { [LIB_MODULE]: [LIB] }, destinationPath })
          }
        
          override toString(): string {
            const { objectProperties, recordProperties } = this
        
            // SLOT(object-intersection): properties + additionalProperties in
            // one schema — the target's intersection syntax.
            if (objectProperties && recordProperties) {
              return applyModifiers(
                `${objectProperties}.and(${recordProperties})`,
                this.modifiers,
              )
            }
        
            // SLOT(object-empty): a fully unconstrained object.
            return applyModifiers(
              recordProperties?.toString() ?? objectProperties?.toString() ??
                `${LIB}.object({})`,
              this.modifiers,
            )
          }
        }
        
        type Visibility = {
          readOnly: boolean
          writeOnly: boolean
        }
        
        type MyLibObjectPropertiesArgs = {
          context: GenerateContextType
          destinationPath: string
          properties: Record<string, OasSchema | OasRef<'schema'> | CustomValue>
          required: OasObject['required']
          generatorKey: GeneratorKey
          rootRef?: RefName
        }
        
        class MyLibObjectProperties extends TsSnippet {
          properties: Record<string, TypeSystemValue>
          required: string[]
          /** Per-property readOnly/writeOnly — see SLOT(visibility). */
          visibility: Record<string, Visibility>
        
          constructor(
            {
              context,
              generatorKey,
              destinationPath,
              properties,
              required = [],
              rootRef,
            }: MyLibObjectPropertiesArgs,
          ) {
            super({ context, generatorKey })
        
            this.required = required
        
            // The property loop: every value comes from the router — a snippet,
            // never rendered text. Optionality flows into each leaf's modifiers.
            this.properties = Object.fromEntries(
              Object.entries(properties).map(([key, property]) => [
                key,
                toMyLibValue({
                  destinationPath,
                  schema: property,
                  required: required.includes(key),
                  context,
                  rootRef,
                }),
              ]),
            )
        
            this.visibility = Object.fromEntries(
              Object.entries(properties).map(([key, property]) => [
                key,
                {
                  readOnly: 'readOnly' in property && property.readOnly === true,
                  writeOnly: 'writeOnly' in property && property.writeOnly === true,
                },
              ]),
            )
          }
        
          override toString(): string {
            // SLOT(object-properties): handleKey quotes keys that aren't valid
            // identifiers ('first-name' → quoted).
            //
            // SLOT(visibility): this.visibility[key] carries readOnly/writeOnly.
            // Default is to ignore them (single-variant output). Strategies if
            // the target needs them: annotate the value (e.g. `.readonly()`),
            // or emit request/response variants via the entry's `variant`
            // threading and drop writeOnly/readOnly fields respectively.
            const fields = Object.entries(this.properties)
              .map(([key, value]) => `${handleKey(key)}: ${value}`)
              .join(', ')
        
            return `${LIB}.object({${fields}})`
          }
        }
        
        type MyLibRecordArgs = {
          context: GenerateContextType
          destinationPath: string
          schema: true | OasSchema | OasRef<'schema'>
          generatorKey: GeneratorKey
          rootRef?: RefName
        }
        
        class MyLibRecord extends TsSnippet {
          value: TypeSystemValue
        
          constructor(
            { context, generatorKey, destinationPath, schema, rootRef }:
              MyLibRecordArgs,
          ) {
            super({ context, generatorKey })
        
            // additionalProperties: true (or an empty schema) means untyped
            // values — route to the unknown fallback, never throw.
            this.value = schema === true || isEmpty(schema)
              ? new MyLibUnknown({ context, destinationPath, generatorKey })
              : toMyLibValue({
                destinationPath,
                schema,
                required: true,
                context,
                rootRef,
              })
          }
        
          override toString(): string {
            // SLOT(record): string-keyed map of this.value.
            return `${LIB}.record(${LIB}.string(), ${this.value})`
          }
        }
        
      • MyLibProjection.ts 2.2 KB
        import { camelCase, capitalize } from '@skmtc/core'
        import type {
          ContentSettings,
          GenerateContextType,
          RefName,
          TypeSystemValue,
        } from '@skmtc/core'
        import { createVariable } from '@skmtc/lang-typescript'
        import { toMyLibValue } from './MyLib.ts'
        import { MyLibBase } from './base.ts'
        import type { EnrichmentSchema } from './enrichments.ts'
        
        type ConstructorArgs = {
          context: GenerateContextType
          destinationPath: string
          refName: RefName
          settings: ContentSettings<EnrichmentSchema>
          rootRef?: RefName
        }
        
        export class MyLibProjection extends MyLibBase {
          value: TypeSystemValue
        
          constructor(
            { context, refName, settings, destinationPath, rootRef }: ConstructorArgs,
          ) {
            super({ context, refName, settings })
        
            const schema = context.resolveSchemaRefOnce(refName, MyLibBase.id)
        
            this.value = toMyLibValue({
              schema,
              required: true,
              destinationPath,
              context,
              rootRef,
            })
        
            // A recursive schema renders a lazy back-reference (see MyLibRef).
            // The enclosing `export const` then references its own initializer,
            // which TypeScript cannot type by inference — TS7022/TS7024.
            // Detected via `modelDepth`, not by rendering: `resolveSchemaRefOnce`
            // above set this key to 1 and every terminal back-reference bumps it,
            // so `> 1` means a cycle was emitted into this model's value.
            // (Rendering here instead would orphan every inner snippet from the
            // attribution map.) Self-recursion is what OpenAPI schemas produce;
            // mutual recursion is NOT detected by this check.
            if (context.modelDepth[`${MyLibBase.id}:${refName}`] > 1) {
              // SLOT(recursion-annotation): a type expression that breaks the
              // cycle, valid in the emitted file (zod: `z.ZodType<Order>`, with
              // the type supplied by a peer type generator).
              this.settings.identifier.typeName = `MyLibSchema<${
                capitalize(camelCase(refName))
              }>`
            }
          }
        
          // These two statics make the projection consumable by PEER generators
          // via insertNormalizedModel — keep them.
          static schemaToValueFn = (...args: Parameters<typeof toMyLibValue>) => {
            return toMyLibValue(...args)
          }
        
          static createIdentifier = createVariable
        
          override toString(): string {
            return `${this.value}`
          }
        }
        
      • MyLibRef.ts 3 KB
        import { ModelDriver, toModelGeneratorKey } from '@skmtc/core'
        import { TsSnippet } from '@skmtc/lang-typescript'
        import type {
          GenerateContextType,
          Modifiers,
          OasRef,
          OasSchema,
          RefName,
        } from '@skmtc/core'
        import { applyModifiers } from './modifiers.ts'
        import { MyLibProjection } from './MyLibProjection.ts'
        import { myLibEntry } from './mod.ts'
        import { LIB, LIB_MODULE } from './lib.ts'
        
        type MyLibRefArgs = {
          context: GenerateContextType
          destinationPath: string
          modifiers: Modifiers
          refName: RefName
          rootRef?: RefName
          /** The originating ref schema node — for fine-grained attribution. */
          schema?: OasSchema | OasRef<'schema'>
        }
        
        /**
         * A $ref. Only the peer's NAME lands in this value tree — the Driver
         * (or the recursion branch) resolves the definition and stitches the
         * cross-file import. Never inline-expand a ref and never hand-write
         * its import.
         */
        export class MyLibRef extends TsSnippet {
          type = 'ref' as const
          modifiers: Modifiers
          name: string
          terminal: boolean
        
          constructor(
            { context, refName, destinationPath, modifiers, rootRef, schema }:
              MyLibRefArgs,
          ) {
            super({
              context,
              generatorKey: toModelGeneratorKey({
                generatorId: myLibEntry.id,
                refName,
                variant: 'main',
              }),
              stackTrail: schema?.stackTrail.clone(),
            })
        
            if (context.modelDepth[`${myLibEntry.id}:${refName}`] > 0) {
              // A back-reference to a model still open on the build stack: a
              // recursive cycle, rendered below via SLOT(lazy). Bump the depth
              // so the enclosing MyLibProjection — whose own
              // `resolveSchemaRefOnce` set this key to 1 — can detect recursion
              // as `> 1` and annotate the export to break the target language's
              // circular type inference. `ModelDriver` resets the key to 0 when
              // the model finishes building.
              context.modelDepth[`${myLibEntry.id}:${refName}`]++
        
              const settings = context.toModelContentSettings({
                refName,
                projection: MyLibProjection,
                variant: 'main',
              })
        
              this.register({
                imports: { [LIB_MODULE]: [LIB] },
                destinationPath: settings.exportPath,
              })
        
              this.name = settings.identifier.name
              this.modifiers = modifiers
              this.terminal = true
            } else {
              // The memoization path: probe the cache; hit → reuse (the peer's
              // constructor never runs) + auto-stitched import; miss →
              // construct recursively.
              const { settings } = new ModelDriver({
                context,
                refName,
                destinationPath,
                rootRef,
                projection: MyLibProjection,
                variant: 'main',
              })
        
              this.name = settings.identifier.name
              this.modifiers = modifiers
              this.terminal = false
            }
          }
        
          override toString(): string {
            const out = applyModifiers(this.name, this.modifiers)
        
            // SLOT(lazy): the target's deferred-reference form for recursive
            // cycles (zod: `z.lazy(() => x)`).
            return this.terminal ? `${LIB}.lazy(() => ${out})` : out
          }
        }
        
      • MyLibScalars.ts 3.7 KB
        import { TsSnippet } from '@skmtc/lang-typescript'
        import type {
          GenerateContextType,
          GeneratorKey,
          Modifiers,
          OasBoolean,
          OasInteger,
          OasNumber,
          OasRef,
          OasSchema,
        } from '@skmtc/core'
        import { applyModifiers } from './modifiers.ts'
        import { LIB, LIB_MODULE } from './lib.ts'
        
        type ScalarArgs<Schema> = {
          context: GenerateContextType
          schema: Schema
          modifiers: Modifiers
          destinationPath: string
          generatorKey: GeneratorKey
        }
        
        export class MyLibNumber extends TsSnippet {
          type = 'number' as const
          schema: OasNumber
          modifiers: Modifiers
        
          constructor(
            { context, schema, modifiers, destinationPath, generatorKey }: ScalarArgs<
              OasNumber
            >,
          ) {
            super({ context, generatorKey, stackTrail: schema.stackTrail.clone() })
        
            this.schema = schema
            this.modifiers = modifiers
        
            this.register({ imports: { [LIB_MODULE]: [LIB] }, destinationPath })
          }
        
          override toString(): string {
            // SLOT(number): minimum / maximum / multipleOf live on this.schema.
            return applyModifiers(`${LIB}.number()`, this.modifiers)
          }
        }
        
        export class MyLibInteger extends TsSnippet {
          type = 'integer' as const
          schema: OasInteger
          modifiers: Modifiers
        
          constructor(
            { context, schema, modifiers, destinationPath, generatorKey }: ScalarArgs<
              OasInteger
            >,
          ) {
            super({ context, generatorKey, stackTrail: schema.stackTrail.clone() })
        
            this.schema = schema
            this.modifiers = modifiers
        
            this.register({ imports: { [LIB_MODULE]: [LIB] }, destinationPath })
          }
        
          override toString(): string {
            // SLOT(integer): if the target has no integer kind, compose it
            // (e.g. number + integer constraint).
            return applyModifiers(`${LIB}.integer()`, this.modifiers)
          }
        }
        
        export class MyLibBoolean extends TsSnippet {
          type = 'boolean' as const
          schema: OasBoolean
          modifiers: Modifiers
        
          constructor(
            { context, schema, modifiers, destinationPath, generatorKey }: ScalarArgs<
              OasBoolean
            >,
          ) {
            super({ context, generatorKey, stackTrail: schema.stackTrail.clone() })
        
            this.schema = schema
            this.modifiers = modifiers
        
            this.register({ imports: { [LIB_MODULE]: [LIB] }, destinationPath })
          }
        
          override toString(): string {
            // SLOT(boolean)
            return applyModifiers(`${LIB}.boolean()`, this.modifiers)
          }
        }
        
        type MyLibUnknownArgs = {
          context: GenerateContextType
          destinationPath: string
          generatorKey: GeneratorKey
          /**
           * The originating schema node — for fine-grained attribution.
           * Optional: also built internally (e.g. a record's unknown value)
           * with no originating node, in which case the pointer is inherited.
           */
          schema?: OasSchema | OasRef<'schema'>
        }
        
        export class MyLibUnknown extends TsSnippet {
          type = 'unknown' as const
        
          constructor(
            { context, destinationPath, generatorKey, schema }: MyLibUnknownArgs,
          ) {
            super({ context, generatorKey, stackTrail: schema?.stackTrail.clone() })
        
            this.register({ imports: { [LIB_MODULE]: [LIB] }, destinationPath })
          }
        
          override toString(): string {
            // SLOT(unknown): the never-throw fallback — untyped schemas route
            // here rather than failing the subject.
            return `${LIB}.unknown()`
          }
        }
        
        // `OasVoid` is not part of the `OasSchema` union, so it can't flow
        // through `SnippetBase.schema` — a void snippet inherits its ancestor /
        // key-derived pointer.
        type MyLibVoidArgs = {
          context: GenerateContextType
          generatorKey: GeneratorKey
          destinationPath: string
        }
        
        export class MyLibVoid extends TsSnippet {
          type = 'void' as const
        
          constructor({ context, generatorKey, destinationPath }: MyLibVoidArgs) {
            super({ context, generatorKey })
        
            this.register({ imports: { [LIB_MODULE]: [LIB] }, destinationPath })
          }
        
          override toString(): string {
            // SLOT(void)
            return `${LIB}.void()`
          }
        }
        
      • MyLibString.ts 1.8 KB
        import { TsSnippet } from '@skmtc/lang-typescript'
        import type {
          GenerateContextType,
          GeneratorKey,
          Modifiers,
          OasString,
        } from '@skmtc/core'
        import { applyModifiers } from './modifiers.ts'
        import { LIB, LIB_MODULE } from './lib.ts'
        
        type MyLibStringArgs = {
          context: GenerateContextType
          stringSchema: OasString
          modifiers: Modifiers
          destinationPath: string
          generatorKey: GeneratorKey
        }
        
        export class MyLibString extends TsSnippet {
          type = 'string' as const
          stringSchema: OasString
          // format + enums are part of the TypeSystemString contract peers rely on.
          format: string | undefined
          enums: string[] | (string | null)[] | undefined
          modifiers: Modifiers
        
          constructor(
            { context, stringSchema, generatorKey, destinationPath, modifiers }:
              MyLibStringArgs,
          ) {
            super({
              context,
              generatorKey,
              stackTrail: stringSchema.stackTrail.clone(),
            })
        
            this.stringSchema = stringSchema
            this.format = stringSchema.format
            this.enums = stringSchema.enums
            this.modifiers = modifiers
        
            this.register({ imports: { [LIB_MODULE]: [LIB] }, destinationPath })
          }
        
          override toString(): string {
            const { enums } = this
        
            // SLOT(string): the target syntax for strings, enums and literals.
            // A single-member enum is a literal; a null member comes from
            // OpenAPI 3.1-style nullable enums and must not be quoted.
            const content = enums?.length
              ? enums.length === 1
                ? `${LIB}.literal(${literal(enums[0])})`
                : `${LIB}.enum([${enums.map(literal).join(', ')}])`
              : `${LIB}.string()`
        
            // SLOT(string-constraints): minLength / maxLength / pattern /
            // format live on this.stringSchema — append target syntax here.
            return applyModifiers(content, this.modifiers)
          }
        }
        
        const literal = (
          value: string | null,
        ): string => (value === null ? 'null' : `'${value}'`)
        
      • MyLibUnion.ts 1.9 KB
        import { TsSnippet } from '@skmtc/lang-typescript'
        import type {
          GenerateContextType,
          GeneratorKey,
          Modifiers,
          OasDiscriminator,
          OasRef,
          OasSchema,
          RefName,
          TypeSystemValue,
        } from '@skmtc/core'
        import { toMyLibValue } from './MyLib.ts'
        import { applyModifiers } from './modifiers.ts'
        import { LIB, LIB_MODULE } from './lib.ts'
        
        type MyLibUnionArgs = {
          context: GenerateContextType
          destinationPath: string
          members: (OasSchema | OasRef<'schema'>)[]
          /** The originating union schema node — for fine-grained attribution. */
          schema?: OasSchema | OasRef<'schema'>
          discriminator?: OasDiscriminator
          modifiers: Modifiers
          generatorKey: GeneratorKey
          rootRef?: RefName
        }
        
        export class MyLibUnion extends TsSnippet {
          type = 'union' as const
          members: TypeSystemValue[]
          discriminator: string | undefined
          modifiers: Modifiers
        
          constructor(
            {
              context,
              generatorKey,
              destinationPath,
              members,
              discriminator,
              modifiers,
              rootRef,
              schema,
            }: MyLibUnionArgs,
          ) {
            super({ context, generatorKey, stackTrail: schema?.stackTrail.clone() })
        
            this.members = members.map((member) =>
              toMyLibValue({
                destinationPath,
                schema: member,
                required: true,
                context,
                rootRef,
              })
            )
        
            this.discriminator = discriminator?.propertyName
            this.modifiers = modifiers
        
            this.register({ imports: { [LIB_MODULE]: [LIB] }, destinationPath })
          }
        
          override toString(): string {
            const members = this.members.map((member) => `${member}`).join(', ')
        
            // SLOT(union): oneOf/anyOf syntax; use the discriminator when the
            // target has a dedicated discriminated-union form.
            const content = this.discriminator
              ? `${LIB}.discriminatedUnion('${this.discriminator}', [${members}])`
              : `${LIB}.union([${members}])`
        
            return applyModifiers(content, this.modifiers)
          }
        }
        
    • deno.json 613 B
      {
        "name": "@your-scope/gen-mylib",
        "version": "0.0.1",
        "exports": "./mod.ts",
        "tasks": {
          "test": "deno test --allow-env --allow-sys --allow-read"
        },
        "imports": {
          "@skmtc/core": "jsr:@skmtc/core@0.28.3",
          "@skmtc/lang-typescript": "jsr:@skmtc/lang-typescript@0.12.17",
          "@std/assert": "jsr:@std/assert@^1.0.10",
          "@std/path": "jsr:@std/path@^1.0.9",
          "ts-pattern": "npm:ts-pattern@^5.9.0"
        },
        "fmt": {
          "semiColons": false,
          "singleQuote": true
        },
        "lint": {
          "plugins": ["jsr:@skmtc/lint-plugin@0.1.0"],
          "rules": {
            "exclude": ["no-slow-types"]
          }
        }
      }
      
    • deno.lock 4.7 KB · in bundle
    • mod.test.ts 5.6 KB
      /**
       * Engine gate for the skeleton (and for your generator once the slots
       * are filled): the fixture below runs through the REAL pipeline and the
       * rendered artifacts are pinned byte-for-byte. After customizing a
       * slot, update the pinned strings to your target syntax — the
       * structural assertions (files exist, shared refs dedup, imports
       * stitched, recursion annotated) must keep passing unchanged.
       *
       * Fixture coverage: enum, array-of-ref, shared ref (Address ×2 →
       * ONE definition), optional, nullable, additionalProperties record,
       * self-recursion (Category → Category).
       */
      import { StackTrail, toArtifacts } from '@skmtc/core'
      import { assertEquals, assertStringIncludes } from '@std/assert'
      import myLibEntry from './mod.ts'
      
      const fixture = {
        openapi: '3.0.3',
        info: { title: 'Skeleton fixture', version: '0.0.1' },
        paths: {},
        components: {
          schemas: {
            Order: {
              type: 'object',
              required: ['id', 'status', 'items'],
              properties: {
                id: { type: 'string' },
                status: { $ref: '#/components/schemas/OrderStatus' },
                items: {
                  type: 'array',
                  items: { $ref: '#/components/schemas/OrderItem' },
                },
                shippingAddress: { $ref: '#/components/schemas/Address' },
                billingAddress: { $ref: '#/components/schemas/Address' },
                notes: { type: 'string', nullable: true },
              },
            },
            OrderItem: {
              type: 'object',
              required: ['sku', 'quantity'],
              properties: {
                sku: { type: 'string' },
                quantity: { type: 'integer' },
                unitPrice: { type: 'number' },
              },
            },
            OrderStatus: {
              type: 'string',
              enum: ['pending', 'shipped', 'delivered'],
            },
            Address: {
              type: 'object',
              required: ['line1', 'city'],
              properties: {
                line1: { type: 'string' },
                city: { type: 'string' },
              },
            },
            Category: {
              type: 'object',
              required: ['name'],
              properties: {
                name: { type: 'string' },
                children: {
                  type: 'array',
                  items: { $ref: '#/components/schemas/Category' },
                },
              },
            },
            Metadata: { type: 'object', additionalProperties: { type: 'string' } },
          },
        },
      }
      
      const generate = () => {
        return toArtifacts({
          traceId: 'skeleton-test',
          spanId: 'skeleton-test',
          // The fixture is a plain literal; the OpenAPI document type is only
          // asserted here, in test code.
          document: { type: 'oas', value: fixture as never },
          settings: undefined,
          stackTrail: new StackTrail(['skeleton', 'test']),
          // The same shape the CLI's generated server uses. The cast bridges
          // the config map's caller-chosen EnrichmentType generic — test-only.
          toGeneratorConfigMap: (() => ({
            [myLibEntry.id]: myLibEntry,
          })) as Parameters<typeof toArtifacts>[0]['toGeneratorConfigMap'],
          startAt: Date.now(),
          silent: true,
        })
      }
      
      Deno.test('every model renders to its own file', () => {
        const { artifacts, manifest } = generate()
      
        assertEquals(JSON.stringify(manifest.results).includes('error'), false)
      
        const paths = Object.keys(artifacts).toSorted()
      
        assertEquals(paths, [
          'models/address.generated.ts',
          'models/category.generated.ts',
          'models/metadata.generated.ts',
          'models/order.generated.ts',
          'models/orderItem.generated.ts',
          'models/orderStatus.generated.ts',
        ])
      })
      
      Deno.test('refs land as imported names, not inline expansions', () => {
        const { artifacts } = generate()
      
        const order = artifacts['models/order.generated.ts']
      
        // The import header is the first thing to check: a missing import
        // means a string swallowed a snippet.
        assertStringIncludes(order, `import {m} from 'mylib'`)
        assertStringIncludes(
          order,
          `import {address} from '@/models/address.generated.ts'`,
        )
      
        // Shared ref: two uses, one definition, referenced by NAME.
        assertEquals(order.match(/shippingAddress: address/g)?.length, 1)
        assertEquals(order.match(/billingAddress: address/g)?.length, 1)
        assertEquals(order.includes('line1'), false)
      })
      
      Deno.test('order model pins the full render', () => {
        const { artifacts } = generate()
      
        assertEquals(
          artifacts['models/order.generated.ts'],
          `import {m} from 'mylib'
      import {orderStatus} from '@/models/orderStatus.generated.ts'
      import {orderItem} from '@/models/orderItem.generated.ts'
      import {address} from '@/models/address.generated.ts'
      
      export const order = m.object({id: m.string(), status: orderStatus, items: m.array(orderItem), shippingAddress: address.optional(), billingAddress: address.optional(), notes: m.string().nullable().optional()});
      `,
        )
      })
      
      Deno.test('self-recursion renders lazy and annotates the identifier', () => {
        const { artifacts } = generate()
      
        const category = artifacts['models/category.generated.ts']
      
        assertStringIncludes(category, 'm.lazy(() => category)')
        // SLOT(recursion-annotation) — the annotation that breaks TS7022.
        assertStringIncludes(
          category,
          'export const category: MyLibSchema<Category> =',
        )
      })
      
      Deno.test('additionalProperties renders as a record', () => {
        const { artifacts } = generate()
      
        assertStringIncludes(
          artifacts['models/metadata.generated.ts'],
          'm.record(m.string(), m.string())',
        )
      })
      
      Deno.test('enum and modifiers render at the leaf', () => {
        const { artifacts } = generate()
      
        assertStringIncludes(
          artifacts['models/orderStatus.generated.ts'],
          `m.enum(['pending', 'shipped', 'delivered'])`,
        )
        assertStringIncludes(
          artifacts['models/orderItem.generated.ts'],
          'unitPrice: m.number().optional()',
        )
      })
      
    • mod.ts 158 B
      export { myLibEntry as default } from './src/mod.ts'
      export { MyLibProjection } from './src/MyLibProjection.ts'
      export { toMyLibValue } from './src/MyLib.ts'
      
  • SKILL.md 10.1 KB
    ---
    name: skmtc-model
    version: 0.1.4
    description: >
      The model-generator shape for Skmtc: one definition per component
      schema, built by copying the shipped SKELETON package and filling its
      SLOT markers with the target library's syntax. Covers the edge cases
      every model generator must survive — refs, recursion, optional vs
      nullable, additionalProperties, enums, readOnly/writeOnly. Use when
      authoring or editing a generator that maps schemas to a
      validator/schema/type library ("write a gen-<lib>", "map OpenAPI
      models to <lib>"). Load ALONGSIDE skmtc-generator (engine rules)
      and skmtc-lang-typescript (TS layer).
    metadata:
      describes:
        '@skmtc/core': '0.28'
        '@skmtc/lang-typescript': '0.12'
    ---
    
    # Model generators: fill the skeleton
    
    A **model generator** turns each component schema (`refName`) into one
    definition in one file: entry → projection → schema-type router → one
    snippet class per schema type. That structure is invariant across
    target libraries — only naming policy and per-type syntax vary. So do
    not write the structure: copy it.
    
    ## 1. The method
    
    The `skeleton/` directory next to this file is a complete, compiling,
    engine-tested model generator that renders a placeholder syntax
    (`m.object({...})`). Author by transplant, not from scratch:
    
    1. **Copy** `skeleton/` to your package location; run
       `deno test --allow-env --allow-sys --allow-read` — 6 green tests
       prove the machinery before you touch anything.
    2. **Rename**: `name` in `deno.json`; `MyLib` → `YourLib` in class
       names and filenames; `myLibEntry` → `yourLibEntry`; then
       `src/lib.ts` — `LIB_MODULE` (emitted module specifier) and `LIB`
       (imported symbol).
    3. **Fill the slots** (§2), smallest first: scalars → string/enum →
       array/object → union → lazy/recursion annotation.
    4. **Re-pin the test**: update the pinned strings in `mod.test.ts` to
       your target syntax. The structural assertions (files exist, shared
       refs dedup to ONE definition, import headers stitched, recursion
       annotated) must pass UNCHANGED — if one breaks, you broke machinery,
       not syntax.
    
    Every slot is a `// SLOT(name):` comment. Everything outside a SLOT is
    engine machinery — modifying it is almost always a mistake.
    
    ## 2. The slots
    
    | Slot | File | Decision |
    |---|---|---|
    | `library` | `src/lib.ts` | emitted module + symbol, single point |
    | `naming`, `identifier-kind`, `export-path` | `src/base.ts` | identity policy (from `refName` ONLY) |
    | `string`, `string-constraints` | `src/MyLibString.ts` | string / enum / literal syntax; formats, min/maxLength |
    | `number`, `integer`, `boolean`, `unknown`, `void` | `src/MyLibScalars.ts` | scalar syntax; numeric constraints |
    | `array` | `src/MyLibArray.ts` | list syntax |
    | `object-properties`, `object-intersection`, `object-empty`, `visibility` | `src/MyLibObject.ts` | object syntax; properties+record composition; readOnly/writeOnly policy |
    | `record` | `src/MyLibObject.ts` | additionalProperties map syntax |
    | `union` | `src/MyLibUnion.ts` | oneOf/anyOf; discriminated form |
    | `lazy` | `src/MyLibRef.ts` | deferred-reference form for cycles |
    | `recursion-annotation` | `src/MyLibProjection.ts` | type annotation breaking circular inference |
    | `modifiers` | `src/modifiers.ts` | optional/nullable syntax and wrap order |
    | `enrichments` | `src/enrichments.ts` | config seam (default: opt-out) |
    
    ## 3. Edge cases the skeleton already handles — keep them working
    
    - **Refs are names, never expansions.** `MyLibRef` puts only the peer's
      NAME in the value tree; the `ModelDriver` resolves the definition
      (cache hit → reuse, miss → construct) and stitches the cross-file
      import. Inline-expanding a ref, or hand-writing its import, is how
      shared models duplicate.
    - **Recursion is a protocol, not a special case.** A back-reference to
      a model still open on the build stack (`context.modelDepth` > 0)
      renders via SLOT(lazy) and bumps the depth; the projection then sees
      `> 1` and sets `settings.identifier.typeName` (SLOT
      recursion-annotation) so the emitted `export const` doesn't die of
      circular inference (TS7022/7024). Self-recursion only — mutual
      recursion is not detected.
    - **Optional and nullable are different axes.** `required` comes from
      the PARENT object's `required` list and flows into each property
      leaf's `modifiers`; `nullable` sits on the node itself. Both render
      exactly once, in `applyModifiers`, at the leaf — no other owner, and
      never while building stored fields.
    - **additionalProperties** → the record path; `true`/empty schema →
      the unknown fallback; properties + additionalProperties together →
      SLOT(object-intersection).
    - **An object schema has four forms — and position can change the
      render.** Properties-only, record-only (additionalProperties), both,
      empty: every place an object renders must survive all four. In
      TypeScript one expression serves both type and declaration positions
      (`z.object({...})`, `.and(z.record(...))` for both-forms), so the
      object SLOTs compose freely. In a head+value language (Kotlin) the
      two positions DIVERGE, and a position-blind `toString()` cannot serve
      both (compiler-verified 2026-08-04, kotlin-debug rig): properties-only
      declares as a `data class` parameter list, and in type position must
      render a NAME — synthesize the named sibling declaration and
      reference it (name derived from the schema's own `stackTrail`, no
      naming param threaded through the router; collisions policed by a
      document-wide claim registry that throws per-item, since the name
      shares a PACKAGE with every component class — gen-kotlin-jackson
      `toSynthesizedName.ts` + `synthesizedNames.ts`; a parameter list in
      type position parses as a function type and fails, and widening to
      `Map<String, Any?>` discards the type — capitulation, not a
      solution); record-only and empty
      must not take a data-class head at all (`data class X()` is illegal —
      their declaration kind is `typealias`); both-forms has a declaration
      form (data class plus a `@field:JsonAnySetter @get:JsonAnyGetter`
      catch-all map property) but no anonymous type form. Decide the
      identifier KIND and the value together from the same schema guards
      (gen-kotlin-jackson `shape.ts` is the worked example) — never from
      the name alone, and never by making one `toString()` answer both
      positions.
    - **A discriminated union may be a DECLARATION, not an expression.**
      In TypeScript SLOT(union) is one expression
      (`z.discriminatedUnion("type", [...])`). In a language without union
      types (Kotlin) a qualifying discriminated union becomes a named
      `sealed` declaration, and the member models must declare the
      supertype — a member may be BUILT before its union is ever seen, so
      membership comes from a document-wide scan (parent → member
      inversion, WeakMap-memoized) consulted at member construction, never
      from the union's own walk. Non-qualifying unions render the honest
      wire type (`JsonNode`), not `Any`. Full pattern: the Kotlin lang
      skill §8c.
    - **Property keys** go through `handleKey` — `'first-name'` renders
      quoted; never assume keys are identifiers.
    - **Visibility.** `readOnly`/`writeOnly` are captured per property in
      `MyLibObjectProperties.visibility`. Default policy ignores them; if
      the target needs them, annotate the value (e.g. `.readonly()`) or
      emit request/response variants via `variant` threading — decide at
      SLOT(visibility). Caller options are the third route: declare the
      second type parameter of `toTsModelProjectionBase`, let the calling
      generator pass `{ options }` on the insert, and fold them into the name.
    - **Unknown never throws.** Untyped schemas route to the unknown
      fallback so one odd schema can't kill the subject. `custom` values
      pass through untouched.
    - **TypeSystem contracts.** Each snippet class carries the fields peers
      rely on (`TypeSystemString` needs `format` + `enums`; objects expose
      `objectProperties`/`recordProperties`). Add fields freely; remove
      none — removal breaks `insertNormalizedModel` consumers and fails the
      `SchemaToValueFn` check.
    
    ## 4. Verify
    
    The shipped `mod.test.ts` runs the REAL pipeline (`toArtifacts`) over a
    fixture with an enum, an array-of-ref, a shared ref (×2 → one
    definition), optional + nullable, a record, and a self-recursive model.
    It is your regression gate: green before you start, green after every
    slot. Read failures in this order: import header first (a missing
    import means a string swallowed a snippet), then the body, then
    `deno lint` (the `skmtc/*` rules are wired in `deno.json`).
    
    ## 5. Model-generator pitfalls
    
    | Symptom | Fix |
    |---|---|
    | Shared model duplicated per consumer | A ref was rendered/expanded instead of flowing through `MyLibRef` |
    | Stack overflow on recursive schema | The `modelDepth` branch in `MyLibRef` was removed or bypassed |
    | Emitted file dies of TS7022/7024 | SLOT(recursion-annotation) not set for the target |
    | `.optional()` doubled or missing | Modifiers applied outside `applyModifiers`, or a second owner added |
    | Enum with `null` member renders `'null'` | Keep the `literal()` null-guard from `MyLibString` |
    | Peer generator can't consume yours | `schemaToValueFn`/`createIdentifier` statics or TypeSystem contract fields removed |
    | Lint fires `no-template-imports`/`no-adhoc-tostring` | Target syntax leaked outside a `toString()` body — move it into the SLOT |
    | `data class NameMap<String, Any?>` (head glued to a type) in output | Declaration kind and value were decided separately — see the four-forms bullet in §3; kind+value must come from the same schema guards |
    
    ## 6. Boundaries
    
    Engine semantics (the one law, memoization, enrichments, variants,
    naming rules) live in **skmtc-generator** — read it first. TS-layer
    specifics (register shapes, identifier kinds, import machinery,
    `List`/`FunctionParameter`) live in **skmtc-lang-typescript**. This
    skill owns only the model SHAPE. The skeleton is TypeScript-emitting;
    for a Kotlin model generator, keep this skill's shape and edge-case
    rules but take call shapes from the Kotlin lang skill (no Kotlin
    skeleton yet). Operation generators are a different shape — load
    `skmtc-operation`; accumulators are covered by neither (clone
    `gen-msw`/`gen-express` per skmtc-generator §2).
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related