| name | skmtc-lang-kotlin |
| version | 0.10.0 |
| description | The Kotlin target-language layer for SKMTC generators
(`@skmtc/lang-kotlin`). Covers how a generator declares Kotlin as its
target language (importing `toKtModelProjectionBase` /
`toKtOasOperationProjectionBase` and `KtSnippet` from the lang
package), what the lang package exports (the `kotlin` Lang object,
the register family, `KtFile` / `KtImport` / `KtDefinition`), the
head+value rendering model, entity kinds and identifier factories,
the import model of emitted Kotlin (packages from paths, symbol-level
imports, same-package suppression, no type-only imports), the value
composition classes (`KtParameterList`, `KtPrimaryConstructor`,
`KtFunctionSignature`, `KtAnnotation`), the `SchemaToValueFn` router
contract and where serialization annotations and default values are
decided (inside the router's per-type snippets, exposed as value
fields), and naming/sanitization (`sanitizePropertyName`,
`toPackageName`, hard keywords like `object`).
Use this skill alongside `skmtc-generator` whenever a generator emits
Kotlin — and specifically when the user asks about "lang-kotlin",
"KtSnippet", "data class generation", "sealed interface", "package
directive", "backticks", "@SerialName"/"@JsonProperty" wiring,
"KtAnnotation", or anything about the *shape of the emitted Kotlin*
rather than engine behavior. Engine rules (Projections, Snippets,
cross-generator coordination, variants) live in `skmtc-generator`.
|
| allowed-tools | ["Bash","Read","Glob","Grep","Write","Edit"] |
SKMTC Kotlin language layer
This skill covers the target-language side of generator authoring:
what the emitted Kotlin looks like and which package owns each piece.
The boundary rule, worth internalizing first:
The authoring language is always TypeScript/Deno; only the target
language varies. Rules about how generator source is written
(as casts, switch+never, Valibot enrichments) live in
skmtc-generator. This skill covers how the generator's output is
shaped — files, packages, imports, declarations, naming — for
generators whose target is Kotlin.
This skill follows the skmtc-lang-<X> template established by
skmtc-lang-typescript: the same seven section headings, with Kotlin's
answers. Read §2 first if you read only one section — the head +
value rendering model is the intuition every other rule falls out of.
1. Package surface
@skmtc/lang-kotlin exports:
| Export | What it is |
|---|
kotlin | The Lang object. Three neutral factories the engine's Drivers call, reading it ephemerally off the projection class's inherited static (projection.lang): createFile, toDefinition, toImport (+ the identifier-assembly seam toIdentifier). Generators never call it |
KtSnippet | The snippet base — where Kotlin enters the DSL class hierarchy. Carries the static lang; its register / defineAndRegister methods are typed by the concise vocabulary. Registering snippets are keyless (generatorKey is optional attribution input) and always pass an explicit destinationPath |
toKtModelProjectionBase / toKtOasOperationProjectionBase | The projection-base veneers over core's factories — pre-bind KtSnippet as the factory's positional first argument and add own-file register(args) + explicit cross-file registerInto(destinationPath, args). The config is core's ModelProjectionBaseConfig<E, KtIdentifierType> (etc.), so toIdentifierType's return is compile-time bound to Kotlin's entity kinds |
register / defineAndRegister | The register functions — convert the concise form, create the destination KtFile on first write, hand pure data to the neutral context.register. Transforms (closures with no class) import defineAndRegister directly. register throws on a cross-language file collision |
KtRegisterArgs / KtDefineAndRegisterArgs | The concise register vocabulary: imports / definitions / custom (leading file content — see §3). Deliberately no reExports field: Kotlin has no re-exports, so a generator registering one is a compile-time error, not a runtime no-op |
KtFile | CodeFileBase subclass — a Kotlin output file. Derives its package directive from its own export path (§5), sorts imports alphabetically, suppresses same-package imports, renders the custom slot above the package line |
KtImport / KtImportNameArg / KtImportSpecifier | ImportBase subclass — symbol-level import statements with as aliases; no brace grouping, no type-only form (§3) |
KtDefinition | DefinitionBase subclass — renders ${head}${value} (declaration kinds) or ${head} = ${value} (assignment kinds), plus the value-carried KtAnnotated / KtDocumented protocols above the line (§2, §4) |
KtIdentifier / isKtIdentifier / KtIdentifierType / KtIdentifierArgs | Kotlin's concrete IdentifierBase: carries the typed type: KtEntityType and renders its own declaration head — [private ]<keyword> <name>[: <typeName>] |
createClass / createDataClass / createEnumClass / createInterface / createSealedInterface / createTypeAlias / createValue | The identifier factories — one per entity kind (§2) |
KtEntityType / isKtEntityType / toKtEntityType | The entity-kind vocabulary, its guard, and the throw-narrowing of the engine's opaque type string |
KtParameterList | Primary-constructor parameter list, parentheses included (§4) |
KtPrimaryConstructor | Constructor modifiers + the explicit constructor keyword rule (§4) |
KtFunctionSignature / KtFunctionParameter | Method signatures for interface/class bodies — annotations, KDoc, expression bodies, parameter defaults (§4) |
KtAnnotation / KtAnnotations / toKtAnnotations / KtAnnotated | Annotation rendering + the self-registering import (§3, §4) |
KtDocumented / isKtDocumented | The value-carried KDoc protocol (§4) |
withDescription | Wraps a rendered declaration in a KDoc comment block |
sanitizePropertyName | Kotlin/JVM property-name sanitization — backtick escaping, hard keywords (§5) |
toPackageName | @/-export-path → dotted package, with segment validation (§5) |
ktHardKeywords / isKtIdentifierName | The validation primitives behind the two above |
langId | 'kotlin' |
fileExtensions | ['.kt'] |
Wiring — the import graph declares the language
import { toKtModelProjectionBase } from '@skmtc/lang-kotlin'
import { emptyEnrichmentSchema } from '@skmtc/core'
import denoJson from '../deno.json' with { type: 'json' }
export const KtModelBase = toKtModelProjectionBase({
id: denoJson.name,
toIdentifierName: ({ refName }) => refName,
toIdentifierType: (refName, context) => {
const schema = context.resolveSchemaRefOnce(refName, denoJson.name)
return { type: schema.type === 'object' ? 'data-class' : 'typealias' }
},
toExportPath: ({ refName }) => `@/models/${refName}.generated.kt`,
toEnrichmentSchema: () => emptyEnrichmentSchema
})
import type { EmptyEnrichments, ModelProjectionArgs, Stringable } from '@skmtc/core'
import { KtAnnotation } from '@skmtc/lang-kotlin'
import { KtModelBase } from './base.ts'
import { toKtValue } from './Kt.ts'
export class KtModel extends KtModelBase {
annotations: KtAnnotation[]
value: Stringable
constructor({ context, settings, refName }: ModelProjectionArgs<EmptyEnrichments>) {
super({ context, settings, refName })
const schema = context.resolveSchemaRefOnce(refName, KtModel.id).resolve()
this.value = toKtValue({
context,
schema,
destinationPath: settings.exportPath,
required: true
})
this.annotations = this.value.annotations
this.annotations.push(
new KtAnnotation({
context,
name: 'JsonIgnoreProperties',
args: ['ignoreUnknown = true'],
packageName: 'com.fasterxml.jackson.annotation',
destinationPath: settings.exportPath
})
)
}
toString(): string {
return `${this.value}`
}
}
import { toModelEntry } from '@skmtc/core'
import denoJson from '../deno.json' with { type: 'json' }
import { KtModel } from './KtModel.ts'
export const ktModelEntry = toModelEntry({
id: denoJson.name,
transform({ context, refName }) {
context.insertModel(KtModel, refName)
},
toEnrichmentSchema: () => emptyEnrichmentSchema
})
A generator declares its language only by importing its
projection-base veneer (and, for registering snippets, KtSnippet)
from this package. The language rides the class hierarchy as the
static lang on KtSnippet; the engine's Drivers read it ephemerally
off the projection class when they need to create a file or build a
Definition. Nothing else changes versus a TypeScript generator: the
same entry factories, the same engine calls — insertModel(Peer, refName) for a named $ref, insertNormalizedModel(Peer, { schema, fallbackName }) for a schema that may be inline or a ref,
findDefinition to read the cache — the same ContentSettings. All
engine-side, all in skmtc-generator; do not re-derive them from core
source.
Generators normally never construct KtFile / KtDefinition /
KtImport directly — this package's register functions and the
engine's Drivers build them. If you find yourself writing
new KtImport(...) in a generator, you almost certainly wanted
this.register({ imports }).
The package dependency (both required, same lang-kotlin pin in
every package of the stack — see §6):
{
"@skmtc/core": "jsr:@skmtc/core@<pin>",
"@skmtc/lang-kotlin": "jsr:@skmtc/lang-kotlin@<pin>"
}
The same-pin rule covers core too: pin @skmtc/core to the exact
version the lang-kotlin package (vendored or JSR) pins — read it from
that package's deno.json rather than taking a newer one. Two core
copies in one build break cross-copy instanceof exactly like two
lang copies (§6).
The router contract — SchemaToValueFn, no core dive needed
The toKtValue router the scaffold above calls is typed by core's
SchemaToValueFn. Its whole surface:
type SchemaType = OasSchema | OasRef<'schema'> | OasVoid | CustomValue
type SchemaToValueFn = <S extends SchemaType>(
args: TypeSystemArgs<S>
) => TypeSystemOutput<S['type']>
type TypeSystemArgs<S extends SchemaType> = {
context: GenerateContextType
destinationPath: string
schema: S
rootRef?: RefName
required: boolean | undefined
}
The contract is structural: each per-type snippet class carries the
fields of its output type alongside its own state — nothing extends a
core class for this. The skmtc create … --lang kotlin skeleton's
DataClassValue is the worked case: type = 'object' as const,
recordProperties: null, objectProperties, modifiers. A snippet
for another schema type carries its own output type's fields the same
way ('string' → type, format, enums, modifiers).
The complete output-shape crib — exact, one line per contract
type; a snippet carries its case's fields verbatim (each shape also
takes an optional generatorKey; enums keeps its full Nullable
union — §4):
type TypeSystemString = { type: 'string'; format: string | undefined; enums: string[] | (string | null)[] | undefined; modifiers: Modifiers }
type TypeSystemInteger = { type: 'integer'; modifiers: Modifiers }
type TypeSystemNumber = { type: 'number'; modifiers: Modifiers }
type TypeSystemBoolean = { type: 'boolean'; modifiers: Modifiers }
type TypeSystemArray = { type: 'array'; items: TypeSystemValue; modifiers: Modifiers }
type TypeSystemRef = { type: 'ref'; name: string; modifiers: Modifiers }
type TypeSystemObject = { type: 'object'; recordProperties: TypeSystemRecord | null; objectProperties: TypeSystemObjectProperties | null; modifiers: Modifiers }
type TypeSystemRecord = { value: TypeSystemValue | 'true' }
type TypeSystemObjectProperties = { properties: Record<string, TypeSystemValue> }
type TypeSystemUnion = { type: 'union'; members: TypeSystemValue[]; discriminator: string | undefined; modifiers: Modifiers }
type TypeSystemUnknown = { type: 'unknown' }
type TypeSystemCustom = { type: 'custom'; value: Stringable }
Two more facts that save a dive into core/types/TypeSystem.ts:
Modifiers is deliberately thin — { required?, description?, nullable? } and nothing else. Wire facts (readOnly / writeOnly,
format) are NOT threaded through modifiers; the per-type snippet
holds its typed schema variant and reads them directly (§4,
"annotations and defaults are decided inside the per-type snippet").
- Keep the router's plain signature (§4: union in, union out,
intersected with
KtValueFields) — the generic
TypeSystemOutput<S['type']> tightening is documented there as a
rare exception, not the default.
The output types carry .type discriminators, but consumers never
need them (§4): a renderer reads fields off routed values —
annotations, defaultValue — instead of narrowing.
2. Entity kinds & identifiers
Kotlin output has seven entity kinds (KtEntityType), created via the
identifier factory functions exported by this package. The identifier
renders its own declaration head; the value renders everything after
it. KtDefinition composes the two without inspecting either:
declaration kinds: ${head}${value} class | data-class | enum-class | interface | sealed-interface
assignment kinds: ${head} = ${value} typealias | val
| Factory | Head rendered | The value then renders |
|---|
createClass('UsersService') | class UsersService | ${primaryConstructor}${supertypeClause}${body} |
createDataClass('User') | data class User | ${parameterList}${supertypeClause} |
createEnumClass('Status') | enum class Status | {\n ACTIVE,\n …\n} |
createInterface('UsersApi') | interface UsersApi | {\n${signatures}\n} |
createSealedInterface('Animal') | sealed interface Animal | usually nothing — the bodyless idiom |
createTypeAlias('UserList') | typealias UserList | the right-hand side (List<User>) |
createValue('timeout', { typeName: 'Long' }) | val timeout: Long | the right-hand side expression |
The consequences, each a rule you'd otherwise rediscover:
- A value's
toString() starts where the head ends. For a data
class that means the parameter list (parens included, §4); for an
enum/interface it means the braced body including the leading
{; for a sealed interface it is typically the empty string —
an empty render IS how sealed interface Animal gets its bodyless
form. Never render the keyword, the name, or data class inside a
value.
- Visibility is the identifier's fact. Kotlin defaults to public,
so
exported: true (the default) renders nothing and
createDataClass('User', { exported: false }) renders
private data class User. There is no noExport on this package's
definition/register args — the neutral Driver-level noExport flag
is folded into a restricted identifier copy at the Lang.toDefinition
boundary, and in-generator code passes exported: false to the
factory instead.
- The
typeName slot is the val x: T annotation — only
createValue takes it. It is part of the head, not the value.
isKtIdentifier narrows a neutral IdentifierBase back to
KtIdentifier; toKtEntityType / isKtEntityType narrow the
engine's opaque type string. Generators rarely call either — the
veneer's ModelProjectionBaseConfig<E, KtIdentifierType> makes
toIdentifierType's return compile-checked, and a foreign
identifier fails loudly at the Generate boundary.
- Unlike TypeScript, the entity kind does not drive import form —
every Kotlin import is
import pkg.Name. It drives only the
declaration shell. Kinds not yet in the vocabulary (object, fun,
var) arrive with the milestones that need them; toKtEntityType
throwing on them is the desired behavior until then.
3. The import model of emitted Kotlin
Generators register imports in the concise form (KtImportNameArg);
the register function converts them to KtImports at the boundary:
this.register({
imports: {
'com.fasterxml.jackson.annotation': ['JsonProperty', 'JsonSubTypes'],
'@/models/Role.generated.kt': ['Role'],
'kotlinx.serialization.json': [{ name: 'Json', alias: 'KJson' }]
}
})
Rendering rules KtFile / KtImport apply (authors never hand-write
these):
- One statement per symbol (
import kotlinx.serialization.Serializable)
— Kotlin has no brace grouping. Imports render sorted
alphabetically, so registration order cannot leak into output.
- No type-only imports.
KtImportNameArg has no type tag —
the TS1484 discipline from lang-typescript has no Kotlin
counterpart. If you find yourself tagging imports, you're writing a
TypeScript instinct into a Kotlin generator.
- Path-form modules resolve to packages at render.
'@/models/Role.generated.kt' → import models.Role via
toPackageName (§5). The Driver passes exactly this form for
cross-file peer imports.
- Same-package imports are suppressed centrally. A symbol in the
destination file's own package needs no import in Kotlin, and
KtFile drops it at render. Over-registering is therefore harmless
— callers never need a same-package check. The single-file
consequence: in a generator that registers every model into ONE
file (the DTO-file idiom), peer references need no import wiring
at all — get the peer's name through the insertModel handle
(.toName(), per skmtc-generator §4) and interpolate it; there
is no circular-import hazard to design around, since Kotlin
same-file declarations reference each other freely. Concretely, the
router's whole ref case is those two calls: a RefValue snippet
whose constructor does context.insertModel(KtModel, ref.toRefName()) and whose toString() returns the stored
.toName() — no register, nothing else.
- Importing from the default package throws. Kotlin cannot import
a root-level (package-less) symbol from a packaged file; hitting
this means a generator's path policy put an artifact at
@/<Name>.kt
that packaged files reference — give the artifact a package path.
- Re-exports don't exist. No barrels, no
reExports field. The
absence is compile-time by design.
Annotation imports — the self-registering leaf
The dominant import in generated Kotlin is an annotation class.
KtAnnotation makes the annotation and its import one statement
(the TsHeritage registering-leaf pattern), so they cannot drift
apart:
new KtAnnotation({
context,
name: 'JsonProperty',
args: ['"user_id"'],
packageName: 'com.fasterxml.jackson.annotation',
destinationPath
})
Omit packageName for default-scope annotations (@Deprecated,
@Suppress — kotlin.* needs no import): the annotation then only
renders. Never pair a KtAnnotation with a separate manual
register({ imports }) for the same class (§6).
Multi-element args render comma-joined on one line
(args.join(', ')). For a multi-line annotation body — the
@JsonSubTypes idiom with one JsonSubTypes.Type(…) entry per
line — pass a SINGLE arg carrying the whole body: a Stringable
snippet whose toString() composes the lines (keeping composition
inside toString()), or a pre-formatted string.
The self-registration covers the annotation's own class only.
When the annotation's arguments name a second symbol —
@JsonSerialize(using = MoneyStringSerializer::class),
@field:JsonDeserialize(using = MoneyStringDeserializer::class) —
that symbol needs its own register({ imports }) alongside the
annotation (the args are opaque Stringables; nothing parses class
references out of them). One annotation, two imports. Exception:
when the referenced class lands in the destination file's own
package, same-package suppression (§3) makes the extra register
unnecessary — though registering it anyway is harmless.
Leading file content — the custom slot
KtRegisterArgs.custom sets the destination file's neutral custom
slot (FileBase.custom), rendered above the package directive
(only comments may precede package in Kotlin) — e.g. a
generated-file attribution banner. Same placement and semantics as
TsFile: last non-undefined write wins.
4. Syntax helpers
The composable value classes a declaration-kind value interpolates.
All return Stringable-compatible values that compose in template
literals; plain Kotlin syntax carries no grammar rule worth a
class, so supertype clauses ( : Animal) and braced bodies
( {\n…\n}) are written inline in the value's toString().
| Helper | Renders | The grammar rule it owns |
|---|
KtParameterList | (\n @Anno\n val id: String,\n val email: String? = null\n) | Parentheses included; each parameter a val property with annotations one per line above it, private/protected/internal visibility, ? nullability, = default |
KtPrimaryConstructor | (…) or @Anno private constructor(…) | Modifiers force Kotlin's explicit constructor keyword; without modifiers it renders just the parameter list |
KtFunctionSignature | @GetMapping("/users/{id}")\n fun getUsersId(@PathVariable("id") id: String): User | Method signatures inside interface/class bodies: per-signature KDoc + annotations, abstract by default, expression body via body ( = … — block bodies deliberately unsupported), implicit Unit when returnType omitted |
KtFunctionParameter | @RequestParam("verbose") verbose: Boolean? = null | One signature parameter — annotations, nullability, defaults |
KtAnnotations (via toKtAnnotations(value)) | One annotation per line + trailing newline; empty renders '' | The class-level annotation block above a declaration |
withDescription(value, { description }) | /** … */\n${value} | KDoc — Kotlin's block-comment syntax is JSDoc-identical; multi-line descriptions get * margins |
The exact constructor shapes — these are complete, and the
generated API appendix (appendix.md, this skill's directory)
carries the full deno doc surface of the whole package (every
export, exact argument shapes, generated from source — it cannot
drift). Between this section and that file there is nothing left to
learn from pre-reading the package source; verify with deno check
after scaffolding instead:
type KtParameterArgs = {
name: string
type: Stringable
nullable?: boolean
defaultValue?: Stringable
annotations?: KtAnnotation[]
visibility?: 'private' | 'protected' | 'internal'
}
type KtAnnotationArgs = {
context: GenerateContextType
name: string
args?: Stringable[]
packageName?: string
destinationPath: string
}
type KtFunctionSignatureArgs = {
name: string
parameters: KtFunctionParameterArgs[]
returnType?: Stringable
annotations?: KtAnnotation[]
description?: string
body?: Stringable
}
type KtPrimaryConstructorArgs = {
parameters: Stringable
modifiers?: Stringable[]
}
data class value: `${parameterList}${supertypes.length ? ` : ${supertypes.join(', ')}` : ''}`
class value: `${primaryConstructor} : ${supertype} {\n${body}\n}`
enum class value: ` {\n ${members.join(',\n ')}\n}`
interface value: ` {\n${signatures.join('\n\n')}\n}`
sealed interface: `` ← the bodyless idiom
typealias / val: the right-hand-side expression
Exact render shapes — verbatim
Generated by constructing each class and printing toString() —
match output against these instead of reading the rendering source
(«» marks the exact boundaries; every newline and space is real):
createDataClass('User') «data class User»
createDataClass('User', { exported: false }) «private data class User»
createValue('timeout', { typeName: 'Long' }) «val timeout: Long»
createSealedInterface('Animal') «sealed interface Animal»
KtAnnotation { name: 'JsonProperty', args: ['"user_id"'] }
«@JsonProperty("user_id")»
KtAnnotation { name: 'JvmInline' } «@JvmInline»
toKtAnnotations(value) — one per line + TRAILING newline (so
`${annotations}${head}` needs no separator); empty renders «»:
«@JsonProperty("user_id")
@Deprecated("use v2")
»
KtParameterList — parens included, one param per line at 4-space
indent, annotations one per line ABOVE their param, inline
visibility; an EMPTY list renders parens around a blank line:
«(
@JsonProperty("user_id")
val userId: String,
private val name: String,
val email: String? = null
)» empty: «(
)»
KtPrimaryConstructor — without modifiers, identical to its parameter
list; with modifiers, a LEADING space then the explicit keyword:
« private constructor(…)»
KtFunctionSignature — indented 4 spaces (it lives inside a
declaration body); single-line KDoc stays inline; parameters on one
line; expression body appends ` = …`:
« /** Fetch one user. */
@GetMapping("/users/{id}")
fun getUsersId(id: String, verbose: Boolean? = null): User»
« fun toId(raw: String): UserId = UserId(raw)»
KtDefinition (declaration kind) — KDoc, then annotations, then
`${head}${value}` with no space before the value's parens:
«/** A user of the system. */
@JsonSerialize(…)
data class User(
…
)»
KtDefinition (assignment kind) «typealias UserList = List<User>»
withDescription(value, { description }) — MULTI-line descriptions get
a block with ` * ` margins (single-line stays inline as above):
«/**
* Line one.
* Line two.
*/
data class X(…)»
The oneOf → sealed interface recipe
The Kotlin idiom for a discriminated oneOf (Animal = Dog | Cat,
discriminated by petType) is a sealed parent plus supertyped
members. The pattern: the union assigns membership to its members
— a member schema does not know it is in a union and behaves as if it
is not.
-
Members carry two fields the parent writes to, both reachable
through inserted.definition.value (the Driver wraps the projection
instance itself in the Definition, so that IS the member
projection): supertypes — declared on the projection, rendered by
its own toString() — and the routed value's parameters array,
reached as .value.parameters (it lives on the object value that
renders it; no projection property, no copy). The router keeps the
bare SchemaToValueFn type — nothing extra is threaded in;
facts flow out of the router on the values it returns:
export class KtModel extends KtModelBase {
supertypes: Stringable[] = []
annotations: KtAnnotation[]
value: ReturnType<typeof toKtValue>
constructor({ context, settings, refName }: ModelProjectionArgs<EmptyEnrichments>) {
super({ context, settings, refName })
const schema = context.resolveSchemaRefOnce(refName, KtModel.id).resolve()
this.value = toKtValue({
context,
schema,
destinationPath: settings.exportPath,
required: true
})
this.annotations = this.value.annotations
}
toString(): string {
return `${this.value}${this.supertypes.length ? ` : ${this.supertypes.join(', ')}` : ''}`
}
}
The discriminator tag property is removed by the parent, during
generate — not filtered at render: insertModel guarantees the
member's constructor has run (existence), so the object value and
its parameters array always exist by the time a union case
touches them, whichever visit order; the KtParameterList built in
the value's constructor holds the same array instance, so the
removal is visible at render with zero render-time work. The member
never chooses between tags (a member of two unions gets two
removals); which tag appears on the wire is decided at
serialization time by the sealed parent the value is viewed through
(each parent renders its own @JsonTypeInfo(property = …)), never
by the member.
-
Parent: createSealedInterface(refName) with a value whose
toString() renders '' — the bodyless idiom gives
sealed interface Animal. The router case stays ONE line — case 'union': return new UnionValue({ context, unionSchema: schema, destinationPath, rootRef, modifiers: { required } }) — like every
other case: the router routes and constructs, it never does the
work. The work all happens in UnionValue's constructor: it
receives the union facts (discriminator name, member list) with the
typed schema, so it is where the parent's class annotations are
decided — pushed into its own annotations array; the parent
projection's alias line (previous fragment) makes that same array
its KtAnnotated protocol slot. No annotation helper elsewhere, no
.type guard anywhere. Membership assignment in a constructor is
exactly axiom 2 — declaration at construction. Member assignment
uses insertModel — it returns the one memoized handle however
many producers ask, and .definition.value is the member
projection instance — typed as such by insertModel's generics, so
the writes below need no cast:
export class UnionValue extends KtSnippet {
type = 'union' as const
members: (TypeSystemValue & KtValueFields)[]
discriminator: string | undefined
modifiers: Modifiers
annotations: KtAnnotation[] = []
constructor({ context, unionSchema, destinationPath, rootRef, modifiers }: Args) {
super({ context })
this.modifiers = modifiers
this.discriminator = unionSchema.discriminator?.propertyName
this.members = unionSchema.members.map(member =>
toKtValue({ context, schema: member, destinationPath, required: true })
)
const tag = this.discriminator
if (tag) {
const mapping = unionSchema.discriminator?.mapping ?? {}
const entries = unionSchema.members.flatMap(member => {
if (!member.isRef()) return []
const refName = member.toRefName()
const wireTag =
Object.entries(mapping).find(([, ref]) => ref.endsWith(`/${refName}`))?.[0] ?? refName
return [{ className: refName, wireTag }]
})
this.annotations.push(
new KtAnnotation({
context,
destinationPath,
name: 'JsonTypeInfo',
args: [`use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "${tag}"`],
packageName: 'com.fasterxml.jackson.annotation'
}),
new KtAnnotation({
context,
destinationPath,
name: 'JsonSubTypes',
args: [new SubTypeEntries({ context, entries })],
packageName: 'com.fasterxml.jackson.annotation'
})
)
}
unionSchema.members.forEach(member => {
if (!member.isRef()) return
const inserted = context.insertModel(KtModel, member.toRefName())
if (rootRef) inserted.definition.value.supertypes.push(rootRef)
if (tag) {
const parameters = inserted.definition.value.value.parameters ?? []
const at = parameters.findIndex(parameter => parameter.wireName === tag)
if (at >= 0) parameters.splice(at, 1)
}
})
}
override toString(): string {
return ''
}
}
export class SubTypeEntries extends KtSnippet {
entries: { className: string; wireTag: string }[]
constructor({ context, entries }: SubTypeEntriesArgs) {
super({ context })
this.entries = entries
}
override toString(): string {
return this.entries
.map(({ className, wireTag }) => `JsonSubTypes.Type(value = ${className}::class, name = "${wireTag}")`)
.join(',\\n ')
}
}
A note on the structural eval's composition metric: constructor-built
annotation args (the KtAnnotation args above, an enum constant's
@JsonProperty("${wireValue}")) count toward
composition-outside-toString() — that is expected and fine.
Annotations are BUILT at construction by design; the metric's 50%
warning threshold accounts for this, so do not restructure working
code just to move that number.
-
Order cannot matter: inserts are idempotent and memoized, so
member-first and union-first visits converge on one instance, and
generate completes before render — pinned by core's
GenerateContext.insert-mutation.test.ts. Assign during generate
only; toString() stays a pure read of the seams. Multi-union
membership composes ( : A, B) for free.
-
This is generator-owned state, NOT a lang protocol — the old
KtSupertyped render protocol stays gone. The
skmtc create … --lang kotlin scaffold is a deliberate skeleton
and does NOT ship this pattern — this section is the canonical
recipe; implement it in your generator.
The enum class recipe — head and body from two seams
enums is not a string fact. Every schema variant except union
and unknown carries it, each over its own element type — integer and
number enums (enums: number[] | (number | null)[]), boolean, array,
object. This recipe covers the string case because that is the one
that maps cleanly onto a Kotlin enum class with wire values; the
others need a policy decision you make deliberately (an integer enum
can be an enum class whose constants carry an Int, or just Int
with the constraint dropped). Decide it in the router case that owns
that type — a number snippet that silently ignores enums has made
the decision by omission, and the schema's constraint vanishes with no
trace.
A string-with-enums schema becomes enum class PetType { … }, and
the declaration is split across the two places §2 defines: the
identifier renders the head (enum class PetType), so
toIdentifierType must return 'enum-class' — a mapping-metadata
policy, where inspecting the schema is sanctioned:
toIdentifierType: (refName, context): KtIdentifierType => {
const schema = context.resolveSchemaRefOnce(refName, denoJson.name).resolve()
if (schema.type === 'object') return { type: 'data-class' }
if (schema.type === 'union') return { type: 'sealed-interface' }
if (schema.type === 'string' && schema.enums) return { type: 'enum-class' }
return { type: 'typealias' }
}
The value renders the braced body — the router's string case
forks on enums presence (a schema fact; the same within-case
license as the format branch and the object case's Map fork):
case 'string':
return schema.enums
? new EnumClassValue({ context, stringSchema: schema, destinationPath, modifiers })
: new StringValue({ context, stringSchema: schema, destinationPath, modifiers })
export class EnumClassValue extends KtSnippet {
type = 'string' as const
format: string | undefined
enums: string[] | undefined
modifiers: Modifiers
annotations: KtAnnotation[] = []
constants: { annotations: KtAnnotation[]; name: string }[]
constructor({ context, stringSchema, destinationPath, modifiers }: Args) {
super({ context })
this.format = stringSchema.format
this.enums = (stringSchema.enums ?? []).flatMap(entry => (entry === null ? [] : [entry]))
this.modifiers = modifiers
this.constants = this.enums.map(wireValue => ({
name: wireValue.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase(),
annotations: [
new KtAnnotation({
context,
destinationPath,
name: 'JsonProperty',
args: [`"${wireValue}"`],
packageName: 'com.fasterxml.jackson.annotation'
})
]
}))
}
override toString(): string {
const body = this.constants
.map(({ annotations, name }) => `${annotations.map(a => ` ${a}\n`).join('')} ${name},`)
.join('\n\n')
return ` {\n${body}\n}`
}
}
One caveat to state rather than discover: an inline (property-
level) string-with-enums also routes through this case, and a braced
body is not a valid property type. In the discriminated-union shape
this is harmless — the only inline enums are discriminator tags,
whose parameter entries the union parent removes before render. A
schema with genuine inline enums needs a policy decision first: hoist
the enum to a named schema (insertNormalizedModel with a
fallbackName) or degrade the property to plain String.
The value protocols — what renders above the declaration
The neutral Lang.toDefinition call the Drivers make has no
annotations or description slot, so both ride on the value and
KtDefinition collects them at render:
KtAnnotated — a value with an annotations: KtAnnotation[]
field. toKtAnnotations(value) collects it into a KtAnnotations
block rendered one-per-line above the head.
KtDocumented (guard: isKtDocumented) — a value with a
description?: string field, rendered as KDoc above the
annotations. An explicit description passed to KtDefinition /
defineAndRegister wins over the protocol.
Both protocols are read off the definition's value —
thing.value.annotations / thing.value.description — and that is
the only place they live. For a defineAndRegister call the value is
the object you passed (the worked example below). For a Projection,
the Driver wraps the projection instance itself in the Definition —
the projection IS the definition's value, so the field must sit
directly on the projection. When the fact is computed inside a
router case (the routed value carries it), the wiring is
reference-sharing: after the single router call, point the
projection's field at the routed value's own array —
this.annotations = this.value.annotations — one array, two names;
writes through either are visible to both (the §1 scaffold). Two
disciplines keep it safe: a getter (get annotations() { … }) is
still a method and still banned, and neither name may ever be
reassigned afterward — push into the array, never replace it; a
later this.annotations = [] silently splits the two names onto
different arrays with no error anywhere.
Annotations and defaults are decided inside the per-type snippet
Which serialization annotations to emit, what default value to use,
which access modifier applies — every such decision depends on a fact
somebody already holds, and the rule is to make the decision where
the fact lives, then expose the result as a field:
The router's dispatch answers schema.type once. Every
type-dependent decision is made inside the per-type snippet that
dispatch constructed, and exposed as a field on the snippet
(annotations, defaultValue) for the consuming renderer to read.
Nothing outside the router ever asks .type again — not on a
schema, not on a routed value.
A helper that takes a schema and asks resolved.type === 'string'
after the router already dispatched is asking the already-answered
question a second time — the structural eval's single-dispatch check
flags exactly this, and is right to.
Split every decision by where its fact lives:
- Type facts → the snippet self-declares.
@JsonSerialize(using = MoneyStringSerializer::class) derives from
format: 'decimal'; @JsonFormat(…) from format: 'date-time'.
The router's string case already owns those facts, and it spends
them by CHOOSING a snippet — BigDecimalValue,
OffsetDateTimeValue, EnumClassValue, or plain StringValue —
each rendering exactly one Kotlin type and self-declaring the
annotations that type implies as its protocol field
(annotations: KtAnnotation[], the same shape KtAnnotated gives
class level). The line to hold: a fork that picks different
output types lives in the router case (a schema-fact fork, the
same license as the enums fork); a branch inside a snippet is
only for rendering variations of the one type it renders. A
StringValue whose toString() returns 'BigDecimal' on one
branch and 'String' on another is two snippets sharing a file —
split it; the module is named after what it renders (§6F).
- Defaults are the snippet's own knowledge. A map snippet knows its
zero value is
'emptyMap()'; it exposes a defaultValue protocol
field rather than letting the renderer inspect
value.type === 'object' to decide.
- Position facts → the parameter renderer, no dispatch needed.
There are two, and they are decided in the same place: the
@JsonProperty("user_id") rename (which needs wireName vs the
sanitized name — facts only the object snippet has) and access
control from readOnly / writeOnly. Access control is the one
that reads like a type fact, because it arrives on the schema —
but it belongs to the occurrence, not the type: the same String
snippet may sit at a read-only property here and a writable one
there, so a shared per-type snippet cannot carry it without being
wrong at one of the two sites. The parameter renderer reads both per
property and concatenates its own position annotations with the
value's type annotations. When BOTH facts apply to one property,
they combine into a single annotation with two named args —
@JsonProperty(value = "user_id", access = JsonProperty.Access.READ_ONLY)
— never two @JsonProperty annotations; the single-arg forms are
the degenerate cases. The rule generalizes: a fact that can
differ between two properties of the same schema type is a position
fact, whatever it is declared on.
- Cross-type wire facts → the
in operator. readOnly /
writeOnly are declared on the concrete variants, not on the
OasSchema union (union and unknown have neither), so the read
itself needs in — a fact read, not a dispatch:
'readOnly' in resolved ? resolved.readOnly : undefined.
- Cross-type applicability → move the call, don't guard. If class
annotations don't apply to unions, let the union router case be the
one that doesn't request them — never an internal
schema.type !== 'union' guard inside the annotation helper.
The mechanics, on the skeleton's shapes:
export type KtValueFields = {
annotations: KtAnnotation[]
defaultValue?: Stringable
parameters?: KtDataClassParameter[]
}
export const toKtValue = (
args: TypeSystemArgs<SchemaType>
): TypeSystemValue & KtValueFields => { }
The generic tightening (<S extends SchemaType> returning
TypeSystemOutput<S['type']> & KtValueFields) exists and is almost
never worth it. Under the doctrine, consumers read protocol fields
and never narrow — so per-type return precision is something a
rule-following consumer cannot even use; the one legitimate
cross-snippet read (parameters) is an optional protocol field, not
a narrowed contract field. And the generic form has a real
implementation cost: inside the function body S is unresolved, so
TypeScript cannot prove a switch branch's concrete snippet against
the conditional return type — which pushes the implementation toward
the banned as or an overload pair. Reach for the generic form only
when a call site routes a statically known schema type and must
read one of its CONTRACT fields without a guard; prefer widening the
protocol with an optional field even then.
case 'string': {
const args = { context, stringSchema: schema, destinationPath, modifiers: { required } }
if (schema.enums) return new EnumClassValue(args)
if (schema.format === 'decimal') return new BigDecimalValue(args)
if (schema.format === 'date-time') return new OffsetDateTimeValue(args)
return new StringValue(args)
}
export class BigDecimalValue extends KtSnippet {
type = 'string' as const
format: string | undefined
enums: string[] | (string | null)[] | undefined
modifiers: Modifiers
annotations: KtAnnotation[]
defaultValue?: Stringable
constructor({ context, stringSchema, destinationPath, modifiers }: Args) {
super({ context })
this.format = stringSchema.format
this.enums = stringSchema.enums
this.modifiers = modifiers
this.annotations = [
new KtAnnotation({
context,
destinationPath,
name: 'JsonSerialize',
args: ['using = MoneyStringSerializer::class'],
packageName: 'com.fasterxml.jackson.databind.annotation'
})
]
this.register({
imports: {
'com.example.serde': ['MoneyStringSerializer'],
'java.math': ['BigDecimal']
},
destinationPath
})
}
override toString(): string {
return 'BigDecimal'
}
}
The Nullable-generic fields keep their full union. enums,
default, and example are declared on the schema classes as
conditionals over the class's Nullable parameter (Nullable extends true ? (string | null)[] | undefined : string[] | undefined). That
parameter defaults to boolean | undefined, so the conditional
distributes and never collapses — .resolve() does not pin it to
false. At every use site the type is the widened union, and it is
also exactly what the TypeSystem contract declares
(TypeSystemString.enums IS string[] | (string | null)[] | undefined). So: copy the contract's declaration verbatim into your
snippet field. Simplifying it to the single arm you expect compiles
in your head and fails at deno check — which skmtc bundle will
not catch (esbuild does not typecheck). Filter at the point of use
instead, as EnumClassValue does
(.flatMap(entry => (entry === null ? [] : [entry]))), not by
narrowing the field.
The rule is general — the trio above is not the whole list.
OasObject.properties is the same conditional shape
(Record<…> | null | undefined at use sites), so a presence guard
must clear the null arm too: Object.keys(properties) behind a
bare !== undefined check fails deno check; guard with
properties && Object.keys(properties).length (truthiness clears
both arms). Before assuming any schema field is plain
T | undefined, check its appendix declaration for the Nullable
conditional.
const entries: KtDataClassParameter[] = []
this.parameters = entries
for (const [wireName, property] of Object.entries(properties ?? {})) {
const isRequired = (required ?? []).includes(wireName)
const value = toKtValue({ context, schema: property, destinationPath, required: isRequired })
const inherentDefault = value.defaultValue
entries.push({
wireName,
name: sanitizePropertyName(wireName),
type: value,
nullable: !isRequired && inherentDefault === undefined,
annotations: [...toPositionAnnotations(wireName), ...value.annotations],
defaultValue: isRequired ? undefined : (inherentDefault ?? 'null')
})
}
this.parameterList = new KtParameterList(entries)
override toString(): string {
return `${this.parameterList}`
}
One more placement rule the split implies: facts are declared in
place — no policy module. The decimal branch declares the money
serde annotations where it branches; the date-time branch its
@JsonFormat; the enum snippet its fallback. A central policy.ts
collecting serializer class names, format patterns, and fallback
values re-centralizes what the mapping distributes — a parallel
dispatch table keyed by comments instead of code. A constant used
once belongs at its use site (plain string literals are free
everywhere; hoisting them to another module buys nothing).
Forking within a router case on schema facts is the same license
the string case's format/enums forks use — the case reads a
schema fact and CHOOSES a snippet; it is never a second .type
dispatch. The canonical instance: an additionalProperties-only
object is a Kotlin Map, not a data class, and the split is a
properties-presence check — a schema fact, not a type dispatch.
Unlike the string forks (whose targets render different Kotlin types
and are therefore separate snippet modules), the object fork stays
ONE snippet for the whole case, and the stock shape dissolves the
where-do-the-child-snippets-live question: the TypeSystem contract
fields themselves hold the child snippets — objectProperties
and recordProperties are slots the 'object' output type demands
anyway, each nullable, each holding the snippet that renders that
half. No wrapper, no mirroring: the contract slot IS where the
child snippet lives. (This shape is already worked in full in this
skill — no need to open gen-zod's source to re-derive it.)
case 'object':
return new ObjectValue({ context, objectSchema: schema, destinationPath, modifiers })
export class ObjectValue extends KtSnippet {
type = 'object' as const
objectProperties: DataClassParameters | null
recordProperties: MapValue | null
modifiers: Modifiers
annotations: KtAnnotation[] = []
defaultValue?: Stringable
parameters: KtDataClassParameter[] | undefined
constructor({ context, objectSchema, destinationPath, modifiers }: Args) {
super({ context })
this.modifiers = modifiers
const { properties, required, additionalProperties } = objectSchema
this.objectProperties = properties && Object.keys(properties).length
? new DataClassParameters({ context, properties, required, destinationPath })
: null
this.parameters = this.objectProperties?.parameters
this.recordProperties = additionalProperties
? new MapValue({ context, schema: additionalProperties, destinationPath })
: null
if (this.objectProperties && this.recordProperties) {
throw new Error('object with both properties and additionalProperties is not mapped')
}
if (!this.objectProperties && this.recordProperties) this.defaultValue = 'emptyMap()'
}
override toString(): string {
return `${this.objectProperties ?? this.recordProperties ?? 'Map<String, Any?>'}`
}
}
Worked example — a serializable DTO, end to end
import type { GenerateContextType } from '@skmtc/core'
import {
KtAnnotation,
KtParameterList,
createDataClass,
defineAndRegister
} from '@skmtc/lang-kotlin'
type UserValueArgs = {
context: GenerateContextType
destinationPath: string
}
class UserValue {
annotations: KtAnnotation[]
parameters: KtParameterList
constructor({ context, destinationPath }: UserValueArgs) {
this.annotations = [
new KtAnnotation({
context,
destinationPath,
name: 'Serializable',
packageName: 'kotlinx.serialization'
})
]
this.parameters = new KtParameterList([
{
name: 'userId',
type: 'String',
annotations: [
new KtAnnotation({
context,
destinationPath,
name: 'SerialName',
args: ['"user_id"'],
packageName: 'kotlinx.serialization'
})
]
},
{ name: 'name', type: 'String' },
{ name: 'email', type: 'String', nullable: true, defaultValue: 'null' }
])
}
toString(): string {
return `${this.parameters}`
}
}
export const writeUser = (context: GenerateContextType): void => {
const destinationPath = '@/com/example/api/User.generated.kt'
defineAndRegister(context, {
identifier: createDataClass('User'),
value: new UserValue({ context, destinationPath }),
destinationPath
})
}
Renders — package from the path, imports self-registered by the
annotations, annotation block from the protocol, head from the
identifier, parens from the parameter list:
package com.example.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class User(
@SerialName("user_id")
val userId: String,
val name: String,
val email: String? = null
)
Which annotations to emit (kotlinx.serialization vs Jackson vs
none) is generator policy — this package renders what it is handed and
never names a serialization library.
5. Naming & sanitization
- The export path IS the package.
KtFile derives its package
directive from its own export path: the segments after @/ are the
package directories (@/com/example/api/User.generated.kt →
package com.example.api; @/models/User.generated.kt →
package models). client.json#settings.basePath points at the
Gradle source root (e.g. consumer/src/main/kotlin), completing
Kotlin's package-=-folder convention. So a generator's
toExportPath is also its package policy — there is no separate
package knob.
- Path segments must be valid package parts.
toPackageName
throws when a directory segment is not a plain Kotlin identifier or
is a hard keyword — @/my-models/User.kt (hyphen) or
@/object/User.kt (keyword) cannot map to a package. Loud beats
backticked package names. A root-level path (@/Scratch.kt) is the
default package: legal, discouraged, renders no package line, and
cannot be imported from (§3).
- Multi-package output (
client.json#settings.packages): export
paths are forward paths under a package's rootPath
(my-sdk-core/src/main/kotlin/com/example/User.kt), and the package
is derived with the owning rootPath stripped. Each rootPath is
that module's Gradle source root, exactly as basePath is in
single-package mode. Cross-module imports resolve to the target
module's real dotted package.
sanitizePropertyName(name) — makes a property name safe as a
Kotlin declaration name. Plain identifier → as-is; hard keyword
(object, val, …) or invalid name (user name, 1st) →
backticked (`object`); a name backticks cannot save (contains
. ; : / \ [ ] < > a backtick or newline — illegal
on the JVM even escaped) → throws. Returns a plain string (no
quoted-property fallback exists in Kotlin).
- Escaping is lang-side backticks; renames are gen-side
annotations. The two compose, and the split decides your
snake_case strategy: keep the wire name and backtick if needed
(
`object` still equals its wire name — no annotation), or
rename to camelCase and annotate
(@SerialName("user_id") val userId). Renaming is generator
policy; this package only guarantees the chosen name parses.
- File naming:
.kt extension; the engine injects the
generated-file infix (client.json#settings.generatedSuffix,
default '.generated') into toExportPath before the extension —
User.kt lands as User.generated.kt, idempotently, exactly as
for any language. Keep the convention — including when replacing a
hand-written file: Kotlin resolves by package, not filename, so
Dtos.generated.kt replaces a hand-written Dtos.kt without
touching the app, and the engine-owned marker survives. An exact
filename genuinely matters only when something outside Kotlin keys
on it (a build script, a TS import); only then set
client.json#settings.generatedSuffix: "" rather than fighting the
suffix in the path policy. Class names come from the refName; the
engine's cache keys on (identifier.name, exportPath).
6. Kotlin-output anti-patterns
- Baking the head into the value — a value
toString() returning
data class User(…) or private class Foo. The identifier renders
the keyword, name, visibility, and : TypeName; the definition adds
${head} — doubling them emits data class User data class User(…).
Return only what follows the head (§2).
- Adding delimiters outside the value — wrapping a value in
(${value}) or { ${value} } at the call site. Values own their
delimiters: KtParameterList brings its parens, a body value brings
its {\n…\n}. If the output is missing parens, fix the value, not
the caller.
- A manual import register next to a
KtAnnotation — registering
imports: { 'com.fasterxml.jackson.annotation': ['JsonProperty'] }
by hand while also constructing the annotation. Pass packageName
to the annotation and delete the manual register; two statements
drift apart (§3).
- Hand-rendering import or package statements in template
literals — they land in the file body (invalid Kotlin below the
real header) and bypass sorting, dedup, and same-package
suppression.
KtFile owns the header; generators only register.
- Type-tagging imports or building barrels — TypeScript instincts
with no Kotlin counterpart: no type-only imports, no re-exports.
The vocabulary omits both; don't emulate them with strings.
- Faking raw file content as a definition — an identifier whose
name never appears in the emitted code nullifies what an identifier
is. A leading banner belongs on the register vocabulary's
custom
slot (§3); whole-file static content is a FILE fact, not a
definition.
- Producer logic in private methods — a
private toAnnotations() on a value class or projection breaks the
constructor + toString() contract exactly as a public method
does (skmtc-generator §2: private helpers and accessors count).
Annotation-building and union-membership branching belong in
module-level free functions taking { context, … } that construct
KtAnnotation / snippet leaves — the leaves then self-register
their imports.
- Mirroring protocol fields — a getter
(
get annotations() { return this.value.annotations } — a method;
producers are constructor + toString() only, and the structural
eval's method-discipline check counts accessors), a copy
(this.annotations = [...this.value.annotations] — a second array,
two facts that will drift), or reassigning an aliased name
(this.annotations = [] after the alias — silently splits the two
names onto different arrays). What IS sanctioned is
reference-sharing: this.annotations = this.value.annotations in
the constructor points the projection's protocol slot at the routed
value's own array — one array, two names — and is the canonical
wiring (§4); after that line, push, never replace.
- Re-deriving schema facts outside the router — an annotation or
default-value helper that takes a schema and asks
resolved.type === 'string' (or narrows a routed value with
value.type === 'object') after the router already dispatched. The
mapping extracted that fact once; policy consumes it as a protocol
field (annotations / defaultValue — §4) or an in fact-read,
never by a second dispatch. The single-dispatch check flags every
such site.
- Running a formatter over the output — render is unformatted by
design; the consumer's ktfmt/ktlint formats. Trailing commas,
line-wrapping, and indentation niceties are their territory.
- Mismatched
lang-kotlin pins across the stack — two copies of
the package (e.g. a local generator pinning a different version
than a JSR peer) break cross-copy instanceof: KtFile /
KtIdentifier checks fail in ways that look like engine bugs. Every
package in one build must pin the SAME @skmtc/lang-kotlin version.
- Export paths that cannot be packages —
@/my-models/…,
@/models/v1.2/…, a hard-keyword segment. toPackageName throws at
render, far from the toExportPath that caused it; fix the path
policy (§5).
7. Boundary with other skills
skmtc-generator — everything engine-side: Projections vs
Snippets, transform returning void, insertModel /
insertOperation / findDefinition, the constructor/toString()
contract, enrichments, variants, cross-generator coordination. If
the question is "how do generators work", it's there; if it's "what
does the emitted Kotlin look like", it's here.
skmtc-cli — install/bundle/generate commands, registering a
local generator in a workspace.
skmtc-debug — broken output, verify-first stance.
skmtc-lang-typescript — the TypeScript counterpart and the
template this skill follows; useful contrastively (type-only
imports, re-exports, and quoted-property fallbacks exist THERE, not
here).
Status note
Current model: the head+value rewrite — the identifier renders its
declaration head, the value renders everything after it, and the
former KtSupertyped / KtConstructed value protocols and the
verbatim identifier kind are gone (historical: supertype clauses and
braced bodies are now written inline; primary constructors compose
KtPrimaryConstructor; raw file content goes through the custom
slot). KtFile.header / fileHeader were superseded by custom.
The package lives at deno/lang-kotlin (see its deno.json for the
current version; released through the workspace cascade against the
pinned @skmtc/core). Proving generators: the gen-kotlin* family —
being rewritten onto this model; the migration log is
notes/lang-v2/02-lang-kotlin-changes-for-generator-rewrite.md.
Appendix — generated API reference
The full deno doc surface for the packages this skill covers lives
in appendix.md, in this skill's directory —
generated from framework source at 71ef53bc, signatures and
field docs only. It is authoritative: when the prose above does
not carry the exact constructor or field shape you need, Read (or
grep) appendix.md instead of diving into package source. Do not
guess signatures. For a symbol not listed there,
deno doc <file> <Symbol> against the framework source beats
grepping it.